diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/openrouter-types.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/openrouter-types.ts index b8134a87f..96da19e13 100644 --- a/apps/web/src/lib/ai-gateway/providers/openrouter/openrouter-types.ts +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/openrouter-types.ts @@ -7,6 +7,8 @@ export { OpenRouterSearchResponse, OpenRouterProvider, OpenRouterProvidersResponse, + OpenRouterApiProvider, + OpenRouterApiProvidersResponse, NormalizedProvider, NormalizedOpenRouterResponse, } from '@kilocode/db/schema-types'; diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/sync-providers.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/sync-providers.ts index e97ecd41a..76d1b1720 100644 --- a/apps/web/src/lib/ai-gateway/providers/openrouter/sync-providers.ts +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/sync-providers.ts @@ -9,6 +9,7 @@ import type { OpenRouterProvider, } from '@/lib/ai-gateway/providers/openrouter/openrouter-types'; import { + OpenRouterApiProvidersResponse, OpenRouterProvidersResponse, OpenRouterSearchResponse, } from '@/lib/ai-gateway/providers/openrouter/openrouter-types'; @@ -19,9 +20,23 @@ import PROVIDERS from '@/lib/ai-gateway/providers/provider-definitions'; import type { Provider } from '@/lib/ai-gateway/providers/types'; import type { StoredModel } from '@/lib/ai-gateway/providers/vercel/types'; import { EndpointsSchema, ModelsSchema } from '@/lib/ai-gateway/providers/vercel/types'; +import { redisSet } from '@/lib/redis'; + +const GATEWAY_METADATA_REDIS_KEYS = { + allProviders: 'ai-gateway.metadata:all-providers', + openrouterModels: 'ai-gateway.metadata:openrouter-models', + vercelModels: 'ai-gateway.metadata:vercel-models', + openrouterProviders: 'ai-gateway.metadata:openrouter-providers', +} as const; + +const ATTRIBUTION_HEADERS = { + 'HTTP-Referer': 'https://kilocode.ai', + 'X-Title': 'Kilo Code', +} as const; async function fetchGatewayModels(gateway: Provider) { const headers = { + ...ATTRIBUTION_HEADERS, authorization: `Bearer ${gateway.apiKey}`, }; @@ -71,11 +86,7 @@ async function fetchProviders(): Promise { const response = await fetch(`https://openrouter.ai/api/frontend/all-providers`, { method: 'GET', - headers: { - // NOTE: Changing HTTP-Referer; per OpenRouter docs it would identify us as a different app, but can be merged by Openrouter later - 'HTTP-Referer': 'https://kilocode.ai', - 'X-Title': 'Kilo Code', - }, + headers: ATTRIBUTION_HEADERS, }); if (!response.ok) { @@ -112,11 +123,7 @@ async function fetchModelsForProvider(provider: OpenRouterProvider): Promise { + console.log('Fetching OpenRouter providers from public API endpoint...'); + + const response = await fetch('https://openrouter.ai/api/v1/providers', { + method: 'GET', + headers: ATTRIBUTION_HEADERS, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch OpenRouter API providers: ${response.status} ${response.statusText}` + ); + } + + const parsed = OpenRouterApiProvidersResponse.parse(await response.json()); + console.log(`Found ${parsed.data.length} providers from /api/v1/providers`); + return parsed; +} + +async function mirrorToRedis(values: { + providers: NormalizedOpenRouterResponse; + openrouter: Record; + vercel: Record; + openrouterProviders: OpenRouterApiProvidersResponse | null; +}): Promise { + const entries: [string, unknown][] = [ + [GATEWAY_METADATA_REDIS_KEYS.allProviders, values.providers], + [GATEWAY_METADATA_REDIS_KEYS.openrouterModels, values.openrouter], + [GATEWAY_METADATA_REDIS_KEYS.vercelModels, values.vercel], + ]; + if (values.openrouterProviders) { + entries.push([GATEWAY_METADATA_REDIS_KEYS.openrouterProviders, values.openrouterProviders]); + } + await Promise.all(entries.map(([key, value]) => redisSet(key, JSON.stringify(value)))); +} + export async function syncAndStoreProviders() { const startTime = performance.now(); const openrouter_data = await fetchGatewayModels(PROVIDERS.OPENROUTER); const vercel_data = await fetchGatewayModels(PROVIDERS.VERCEL_AI_GATEWAY); + const openrouter_providers = await fetchOpenRouterApiProviders(); + if (openrouter_providers.data.length < 10) { + throw new Error( + `Suspicious: total number of OpenRouter API providers is ${openrouter_providers.data.length} < 10` + ); + } + const providers = await syncProviders(); if (providers.total_providers < 10) { @@ -305,12 +355,23 @@ export async function syncAndStoreProviders() { const result = await db.transaction(async tx => { const results = await tx .insert(modelsByProvider) - .values({ data: providers, openrouter: openrouter_data, vercel: vercel_data }) + .values({ + data: providers, + openrouter: openrouter_data, + vercel: vercel_data, + }) .returning(); await tx.delete(modelsByProvider).where(lt(modelsByProvider.id, results[0].id)); return results[0]; }); + await mirrorToRedis({ + providers, + openrouter: openrouter_data, + vercel: vercel_data, + openrouterProviders: openrouter_providers, + }); + return { id: result.id, generated_at: result.data.generated_at, diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts index 7978fbe37..1d9b74db0 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -652,6 +652,25 @@ export const OpenRouterProvidersResponse = z.union([ z.array(OpenRouterProvider), ]); +// Response shape for the public OpenRouter API endpoint GET /api/v1/providers. +// Distinct from the frontend `all-providers` endpoint above: this returns a +// smaller, stable public view used by external API consumers. +export type OpenRouterApiProvider = z.infer; +export const OpenRouterApiProvider = z.object({ + name: z.string(), + slug: z.string(), + privacy_policy_url: z.string().nullable(), + terms_of_service_url: z.string().nullable(), + status_page_url: z.string().nullable(), + headquarters: z.string().nullable(), + datacenters: z.array(z.string()).nullable(), +}); + +export type OpenRouterApiProvidersResponse = z.infer; +export const OpenRouterApiProvidersResponse = z.object({ + data: z.array(OpenRouterApiProvider), +}); + export type NormalizedProvider = z.infer; export const NormalizedProvider = z.object({ name: z.string(),