From dc9c068802462ae73cbc8f6ff2f826e07934cc00 Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:19:58 +0000 Subject: [PATCH] fix: keep batch prices out of provider model listings Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../openrouter/fetch-provider-models.test.ts | 131 ++++++++++++++++++ .../openrouter/fetch-provider-models.ts | 43 ++++++ .../providers/openrouter/sync-providers.ts | 43 +----- packages/db/src/schema-types.ts | 2 + 4 files changed, 178 insertions(+), 41 deletions(-) create mode 100644 apps/web/src/lib/ai-gateway/providers/openrouter/fetch-provider-models.test.ts create mode 100644 apps/web/src/lib/ai-gateway/providers/openrouter/fetch-provider-models.ts diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/fetch-provider-models.test.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/fetch-provider-models.test.ts new file mode 100644 index 0000000000..f596e595f4 --- /dev/null +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/fetch-provider-models.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import { fetchModelsForProvider } from './fetch-provider-models'; +import { getModelDisplayPricing } from './display-pricing'; +import type { OpenRouterModel, OpenRouterProvider } from './openrouter-types'; + +const provider = { + name: 'OpenAI', + displayName: 'OpenAI', + slug: 'openai', + dataPolicy: { training: false, retainsPrompts: true, canPublish: false }, +} satisfies OpenRouterProvider; + +const standardModel = { + slug: 'openai/gpt-5.6-sol', + name: 'OpenAI: GPT-5.6 Sol', + author: 'openai', + description: 'GPT-5.6 Sol', + context_length: 1050000, + input_modalities: ['text', 'image', 'file'], + output_modalities: ['text'], + group: 'GPT', + updated_at: '2026-09-04T00:00:00Z', + endpoint: { + variant: 'standard', + model_variant_slug: 'openai/gpt-5.6-sol', + provider_display_name: 'OpenAI', + is_free: false, + pricing: { prompt: '0.000002', completion: '0.00001', discount: 0.5 }, + }, +} satisfies OpenRouterModel; + +const batchModel = { + ...standardModel, + endpoint: { + ...standardModel.endpoint, + variant: 'batch', + model_variant_slug: 'openai/gpt-5.6-sol:batch', + pricing: { prompt: '0.000001', completion: '0.000005', discount: 0.5 }, + }, +} satisfies OpenRouterModel; + +function mockModels(models: OpenRouterModel[]) { + jest.spyOn(global, 'fetch').mockResolvedValue(Response.json({ data: { models } })); +} + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('fetchModelsForProvider', () => { + it.each([ + ['standard first', [standardModel, batchModel]], + ['batch first', [batchModel, standardModel]], + ])('keeps standard pricing when duplicate cards arrive %s', async (_order, cards) => { + mockModels(cards); + + const models = await fetchModelsForProvider(provider); + + expect(models).toEqual([standardModel]); + expect(getModelDisplayPricing(models[0].endpoint?.pricing)).toEqual({ + prompt: '0.000004000000', + completion: '0.000020000000', + }); + }); + + it.each([ + ['variant', { ...batchModel, endpoint: { ...batchModel.endpoint, model_variant_slug: null } }], + ['endpoint slug', { ...batchModel, endpoint: { ...batchModel.endpoint, variant: null } }], + [ + 'model slug', + { + ...batchModel, + slug: 'openai/gpt-5.6-sol:batch', + endpoint: { ...batchModel.endpoint, variant: undefined, model_variant_slug: undefined }, + }, + ], + ])('excludes batch-only models identified by %s', async (_field, model) => { + mockModels([model]); + + expect(await fetchModelsForProvider(provider)).toEqual([]); + }); + + it.each(['vendor/model', 'vendor/model:free'])( + 'preserves a free variant with model slug %s', + async slug => { + const freeModel = { + ...standardModel, + slug, + endpoint: { + ...standardModel.endpoint, + variant: 'free', + model_variant_slug: 'vendor/model:free', + is_free: true, + pricing: { prompt: '0', completion: '0' }, + }, + }; + mockModels([freeModel]); + + expect(await fetchModelsForProvider(provider)).toEqual([freeModel]); + } + ); + + it.each([undefined, null])('preserves models with %s variant metadata', async metadata => { + const model = { + ...standardModel, + endpoint: { + ...standardModel.endpoint, + variant: metadata, + model_variant_slug: metadata, + }, + }; + mockModels([model]); + + expect(await fetchModelsForProvider(provider)).toEqual([model]); + }); + + it('preserves models without an endpoint', async () => { + const model = { ...standardModel, endpoint: null }; + mockModels([model]); + + expect(await fetchModelsForProvider(provider)).toEqual([model]); + }); + + it('rejects failed upstream requests', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 503 })); + + await expect(fetchModelsForProvider(provider)).rejects.toThrow( + 'Failed to fetch models for provider OpenAI: 503' + ); + }); +}); diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/fetch-provider-models.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/fetch-provider-models.ts new file mode 100644 index 0000000000..66e95f5317 --- /dev/null +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/fetch-provider-models.ts @@ -0,0 +1,43 @@ +import { + OpenRouterSearchResponse, + type OpenRouterModel, + type OpenRouterProvider, +} from '@/lib/ai-gateway/providers/openrouter/openrouter-types'; +import { ATTRIBUTION_HEADERS } from '@/lib/ai-gateway/providers/openrouter/attribution-headers'; + +export async function fetchModelsForProvider( + provider: OpenRouterProvider +): Promise { + console.log(`Fetching models for provider: ${provider.name} (${provider.slug})`); + + const searchParams = new URLSearchParams({ + providers: provider.name, + fmt: 'cards', + }); + + const response = await fetch( + `https://openrouter.ai/api/frontend/v1/models/find?${searchParams}`, + { + method: 'GET', + headers: ATTRIBUTION_HEADERS, + } + ); + + if (!response.ok) { + throw new Error( + `Failed to fetch models for provider ${provider.name}: ${response.status} ${response.statusText}` + ); + } + + const data = OpenRouterSearchResponse.parse(await response.json()); + const models = data.data.models.filter( + model => + model.endpoint?.variant !== 'batch' && + !model.endpoint?.model_variant_slug?.endsWith(':batch') && + !model.slug.endsWith(':batch') + ); + + console.log(` Found ${models.length} models for provider ${provider.name}`); + + return models; +} 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 31c74d22ed..bc84d840ff 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 @@ -11,10 +11,8 @@ import type { OpenRouterModel, OpenRouterProvider, } from '@/lib/ai-gateway/providers/openrouter/openrouter-types'; -import { - OpenRouterProvidersResponse, - OpenRouterSearchResponse, -} from '@/lib/ai-gateway/providers/openrouter/openrouter-types'; +import { OpenRouterProvidersResponse } from '@/lib/ai-gateway/providers/openrouter/openrouter-types'; +import { fetchModelsForProvider } from '@/lib/ai-gateway/providers/openrouter/fetch-provider-models'; import { modelsByProvider } from '@kilocode/db/schema'; import { db } from '@/lib/drizzle'; import { desc, lt, sql } from 'drizzle-orm'; @@ -152,43 +150,6 @@ async function fetchProviders(): Promise { return providers; } -async function fetchModelsForProvider(provider: OpenRouterProvider): Promise { - console.log(`Fetching models for provider: ${provider.name} (${provider.slug})`); - - // Use the frontend API endpoint with provider filter - const searchParams = new URLSearchParams({ - providers: provider.name, - fmt: 'cards', - }); - - console.log( - 'GET', - `https://openrouter.ai/api/frontend/v1/models/find?${searchParams.toString()}` - ); - - const response = await fetch( - `https://openrouter.ai/api/frontend/v1/models/find?${searchParams}`, - { - method: 'GET', - headers: ATTRIBUTION_HEADERS, - } - ); - - if (!response.ok) { - throw new Error( - `Failed to fetch models for provider ${provider.name}: ${response.status} ${response.statusText}` - ); - } - - const data = await response.json().then(d => OpenRouterSearchResponse.parse(d)); - - console.log(` Found ${data.data.models.length} models for provider ${provider.name}`); - - // Note: Models still contain redundant provider info in endpoint.provider_info, etc. - // This is now available in the comprehensive providers array, but we keep it for compatibility - return data.data.models; -} - async function syncProviders( providers: OpenRouterProvider[], vercelModels: Record diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts index 8a970339ce..a34ed98e33 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -1900,6 +1900,8 @@ export const OpenRouterBaseModel = z.object({ export type OpenRouterEndpoint = z.infer; export const OpenRouterEndpoint = z.object({ + variant: z.string().nullish(), + model_variant_slug: z.string().nullish(), provider_display_name: z.string(), is_free: z.boolean(), pricing: OpenRouterPricing,