Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions apps/web/src/app/api/openrouter/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@
import { getEnhancedOpenRouterModels } from '@/lib/providers/openrouter';
import { getUserFromAuth } from '@/lib/user.server';
import { getDirectByokModelsForUser } from '@/lib/providers/direct-byok';
import { unstable_cache } from 'next/cache';
import { getOrCreateRedisCachedFetch } from '@/lib/cached-fetch';
import { getAvailableModelsForOrganization } from '@/lib/organizations/organization-models';
import { FEATURE_HEADER, validateFeatureHeader } from '@/lib/feature-detection';
import { filterByFeature } from '@/lib/models';

const getDirectByokModels = unstable_cache(
(userId: string) => getDirectByokModelsForUser(userId),
undefined,
{ revalidate: 60 }
);
function getDirectByokModels(userId: string) {
return getOrCreateRedisCachedFetch(
`openrouter:direct-byok-models:${userId}`,
() => getDirectByokModelsForUser(userId),
60_000
)();

Check failure

Code scanning / CodeQL

Unvalidated dynamic method call High

Invocation of method with
user-controlled
name may dispatch to unexpected target and cause an exception.
Invocation of method with
user-controlled
name may dispatch to unexpected target and cause an exception.
Comment on lines +14 to +18
}

async function tryGetUserFromAuth() {
try {
Expand Down
13 changes: 4 additions & 9 deletions apps/web/src/app/api/openrouter/providers/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { unstable_cache } from 'next/cache';
import { createRedisCachedFetch } from '@/lib/cached-fetch';
import { captureException } from '@sentry/nextjs';
import type { OpenRouterProvider } from '@/lib/organizations/organization-types';

export const revalidate = 86400; // 24 hours

// Cache the providers fetch for 24 hours
const getCachedProviders = unstable_cache(
const getCachedProviders = createRedisCachedFetch(
'openrouter:providers',
async () => {
const response = await fetch('https://openrouter.ai/api/frontend/all-providers', {
method: 'GET',
Expand All @@ -27,11 +26,7 @@ const getCachedProviders = unstable_cache(

return response.json() as Promise<OpenRouterProvider[]>;
},
['openrouter-providers'], // Cache key
{
revalidate: 86400, // 24 hours in seconds
tags: ['openrouter-providers'], // Cache tags for granular invalidation
}
86_400_000
);

/**
Expand Down
68 changes: 37 additions & 31 deletions apps/web/src/lib/byok/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,40 +10,46 @@
type UserByokProviderId,
} from '@/lib/providers/openrouter/inference-provider-id';
import { isCodestralModel } from '@/lib/providers/mistral';
import { unstable_cache } from 'next/cache';
import { getOrCreateRedisCachedFetch } from '@/lib/cached-fetch';
import { mapModelIdToVercel } from '@/lib/providers/vercel/mapModelIdToVercel';
import type { BYOKResult } from '@/lib/providers/types';

const getModelUserByokProviders_cached = unstable_cache(
async (modelId: string) => {
const vercelModelMetadata = (
await readDb
.select({ vercel: modelsByProvider.vercel })
.from(modelsByProvider)
.orderBy(desc(modelsByProvider.id))
.limit(1)
).at(0)?.vercel;
if (!vercelModelMetadata) {
console.error('[getModelUserByokProviders_cached] no Vercel model metadata in the database');
return [];
}
const providers =
vercelModelMetadata[mapModelIdToVercel(modelId)]?.endpoints
.map(ep => VercelUserByokInferenceProviderIdSchema.safeParse(ep.tag).data)
.filter(providerId => providerId !== undefined) ?? [];
if (providers.length === 0) {
console.debug(`[getModelUserByokProviders_cached] no user byok providers for ${modelId}`);
return [];
}
console.debug(
`[getModelUserByokProviders_cached] found user byok providers for ${modelId}`,
providers
);
return providers;
},
undefined,
{ revalidate: 300 }
);
function getModelUserByokProviders_cached(modelId: string) {
const redisKey = `byok:providers:${modelId}`;
const get = getOrCreateRedisCachedFetch(
redisKey,
async () => {
const vercelModelMetadata = (
await readDb
.select({ vercel: modelsByProvider.vercel })
.from(modelsByProvider)
.orderBy(desc(modelsByProvider.id))
.limit(1)
).at(0)?.vercel;
if (!vercelModelMetadata) {
console.error(
'[getModelUserByokProviders_cached] no Vercel model metadata in the database'
);
return [];
}
const providers =
vercelModelMetadata[mapModelIdToVercel(modelId)]?.endpoints
.map(ep => VercelUserByokInferenceProviderIdSchema.safeParse(ep.tag).data)
.filter(providerId => providerId !== undefined) ?? [];
if (providers.length === 0) {
console.debug(`[getModelUserByokProviders_cached] no user byok providers for ${modelId}`);

Check warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.
Log entry depends on a
user-provided value
.
return [];
}
console.debug(
`[getModelUserByokProviders_cached] found user byok providers for ${modelId}`,

Check failure

Code scanning / CodeQL

Use of externally-controlled format string High

Format string depends on a
user-provided value
.
Format string depends on a
user-provided value
.

Check warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.
Log entry depends on a
user-provided value
.
providers
);
return providers;
},
300_000
);
return get();

Check failure

Code scanning / CodeQL

Unvalidated dynamic method call High

Invocation of method with
user-controlled
name may dispatch to unexpected target and cause an exception.
Invocation of method with
user-controlled
name may dispatch to unexpected target and cause an exception.
}

export async function getModelUserByokProviders(model: string): Promise<UserByokProviderId[]> {
return isCodestralModel(model) ? ['codestral'] : await getModelUserByokProviders_cached(model);
Expand Down
93 changes: 93 additions & 0 deletions apps/web/src/lib/cached-fetch.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { redisGet, redisSet } from '@/lib/redis';

/**
* In-process stale-while-revalidate cache for async fetchers.
*
Expand All @@ -22,3 +24,94 @@ export function createCachedFetch<T>(fetcher: () => Promise<T>, ttlMs: number, d
}
};
}

/**
* Combines `createCachedFetch` with Redis caching.
*
* Checks Redis first; on miss, calls `fetcher`, stores the result in Redis,
* and caches it in-process. Only writes to Redis when a fresh value is
* fetched, so existing values are never overwritten by stale data.
*
* Unlike `createCachedFetch`, this throws on a true cold-cache failure
* (both Redis and the fetcher are unavailable) so callers can decide
* whether to fail open or handle the error explicitly.
*/
export function createRedisCachedFetch<T>(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this feels a bit tricky with more than one instance. we probably want to at least add jitter to the ttlMs right?

redisKey: string,
fetcher: () => Promise<T>,
ttlMs: number
) {
let cached: { value: T; at: number } | null = null;

return async function get(): Promise<T> {
if (cached && Date.now() - cached.at < ttlMs) {
return cached.value;
}

try {
const raw = await redisGet(redisKey);
if (raw) {
const value = JSON.parse(raw) as T;
cached = { value, at: Date.now() };
return value;
}
} catch {
// Redis GET failed; fall through to fetcher and stale-cache logic below
}

try {
const value = await fetcher();
try {
await redisSet(redisKey, JSON.stringify(value), Math.ceil(ttlMs / 1000));
} catch {
// Ignore Redis SET failures so a write outage doesn't fail requests
}
cached = { value, at: Date.now() };
return value;
} catch (err) {
if (cached) {
return cached.value;
}
throw err;
}
};
}

const redisCacheRegistry = new Map<string, () => Promise<unknown>>();
const MAX_REGISTRY_SIZE = 100;

function touchRegistryKey(key: string): void {
const fn = redisCacheRegistry.get(key);
if (fn) {
redisCacheRegistry.delete(key);
redisCacheRegistry.set(key, fn);
}
}

/**
* Returns a shared `createRedisCachedFetch` instance for the given key.
* Useful when the cache is accessed from parameterized functions.
* The registry is bounded to `MAX_REGISTRY_SIZE` entries with LRU eviction.
*/
export function getOrCreateRedisCachedFetch<T>(
redisKey: string,
fetcher: () => Promise<T>,
ttlMs: number
): () => Promise<T> {
const existing = redisCacheRegistry.get(redisKey);
if (existing) {
touchRegistryKey(redisKey);
return existing as () => Promise<T>;
}

if (redisCacheRegistry.size >= MAX_REGISTRY_SIZE) {
const firstKey = redisCacheRegistry.keys().next().value as string | undefined;
if (firstKey) {
redisCacheRegistry.delete(firstKey);
}
}

const cached = createRedisCachedFetch(redisKey, fetcher, ttlMs);
redisCacheRegistry.set(redisKey, cached);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: The shared registry grows without bound for parameterized keys

getOrCreateRedisCachedFetch never removes entries from redisCacheRegistry, so every unique user id, model id, or PostHog query hash stays resident for the life of the process. In this PR the helper is used with per-user and per-query keys, so a long-lived server can accumulate an unbounded number of closures even after their TTL has expired.

return cached;
}
46 changes: 26 additions & 20 deletions apps/web/src/lib/posthog-query.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getEnvVariable } from '@/lib/dotenvx';
import { unstable_cache } from 'next/cache';
import { createHash } from 'crypto';
import { getOrCreateRedisCachedFetch } from '@/lib/cached-fetch';
import * as z from 'zod';

/**
Expand Down Expand Up @@ -58,23 +59,28 @@
};
}
export function cachedPosthogQuery<Output>(schema: z.ZodType<Output[]>) {
return unstable_cache(
async (name: string, query: string) => {
const startTime = performance.now();
const response = await posthogQuery(name, query);
if (response.status !== 'ok') {
throw new Error(`${name} query failed: ${JSON.stringify(response.error, undefined, 2)}`);
}
const result = schema.safeParse(response.body.results);
if (!result.success) {
throw new Error(`${name} parse failed: ${z.prettifyError(result.error)}`);
}
console.debug(
`[cachedPosthogQuery] ${name} returned ${result.data.length} rows in ${performance.now() - startTime}ms`
);
return result.data;
},
undefined,
{ revalidate: 60 * 60 * 24 } // 24 hours
);
return (name: string, query: string) => {
const queryHash = createHash('sha256').update(query).digest('hex');
const redisKey = `posthog-query:${name}:${queryHash}`;
const get = getOrCreateRedisCachedFetch<Output[]>(
redisKey,
async () => {
const startTime = performance.now();
const response = await posthogQuery(name, query);
if (response.status !== 'ok') {
throw new Error(`${name} query failed: ${JSON.stringify(response.error, undefined, 2)}`);
}
const result = schema.safeParse(response.body.results);
if (!result.success) {
throw new Error(`${name} parse failed: ${z.prettifyError(result.error)}`);
}
console.debug(
`[cachedPosthogQuery] ${name} returned ${result.data.length} rows in ${performance.now() - startTime}ms`
);
return result.data;
},
86_400_000
);
return get();

Check failure

Code scanning / CodeQL

Unvalidated dynamic method call High

Invocation of method with
user-controlled
name may dispatch to unexpected target and cause an exception.
Invocation of method with
user-controlled
name may dispatch to unexpected target and cause an exception.
};
}
9 changes: 4 additions & 5 deletions apps/web/src/lib/providers/vercel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,13 @@ import type {
} from '@/lib/providers/openrouter/types';
import { mapModelIdToVercel } from '@/lib/providers/vercel/mapModelIdToVercel';
import * as crypto from 'crypto';
import { unstable_cache } from 'next/cache';
import { readDb } from '@/lib/drizzle';
import { modelsByProvider } from '@kilocode/db/schema';
import { desc } from 'drizzle-orm';
import { StoredModelSchema } from '@kilocode/db';
import * as z from 'zod';
import { redisGet } from '@/lib/redis';
import { createCachedFetch } from '@/lib/cached-fetch';
import { createCachedFetch, createRedisCachedFetch } from '@/lib/cached-fetch';
import {
VERCEL_ROUTING_REDIS_KEY,
GatewayPercentageSchema,
Expand All @@ -41,7 +40,8 @@ const getVercelRoutingPercentage = createCachedFetch(
DEFAULT_VERCEL_PERCENTAGE
);

const getVercelModels_cached = unstable_cache(
const getVercelModels_cached = createRedisCachedFetch(
'vercel:models',
async () => {
const result = await readDb
.select({ vercel: modelsByProvider.vercel })
Expand All @@ -52,8 +52,7 @@ const getVercelModels_cached = unstable_cache(
.filter(model => model.type === 'language' && model.endpoints.length > 0)
.map(model => model.id);
},
undefined,
{ revalidate: 3600 }
3_600_000
);

async function getVercelModels() {
Expand Down
Loading
Loading