Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
Original file line number Diff line number Diff line change
@@ -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<OpenRouterModel[]> {
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;
}
43 changes: 2 additions & 41 deletions apps/web/src/lib/ai-gateway/providers/openrouter/sync-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -152,43 +150,6 @@ async function fetchProviders(): Promise<OpenRouterProvider[]> {
return providers;
}

async function fetchModelsForProvider(provider: OpenRouterProvider): Promise<OpenRouterModel[]> {
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<string, StoredModel>
Expand Down
2 changes: 2 additions & 0 deletions packages/db/src/schema-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1900,6 +1900,8 @@ export const OpenRouterBaseModel = z.object({

export type OpenRouterEndpoint = z.infer<typeof OpenRouterEndpoint>;
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,
Expand Down