From 7e512014d074339caac1ebee17a107bdeca5d93c Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Wed, 29 Jul 2026 15:24:00 +0200 Subject: [PATCH 1/6] feat(api): expose cached model endpoints --- .../[provider]/[model]/endpoints/route.ts | 1 + .../[model]/endpoints/route.test.ts | 53 +++++++++++++++++++ .../[provider]/[model]/endpoints/route.ts | 18 +++++++ 3 files changed, 72 insertions(+) create mode 100644 apps/web/src/app/api/gateway/models/[provider]/[model]/endpoints/route.ts create mode 100644 apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.test.ts create mode 100644 apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts diff --git a/apps/web/src/app/api/gateway/models/[provider]/[model]/endpoints/route.ts b/apps/web/src/app/api/gateway/models/[provider]/[model]/endpoints/route.ts new file mode 100644 index 0000000000..b686013c0f --- /dev/null +++ b/apps/web/src/app/api/gateway/models/[provider]/[model]/endpoints/route.ts @@ -0,0 +1 @@ +export { GET } from '@/app/api/openrouter/models/[provider]/[model]/endpoints/route'; diff --git a/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.test.ts b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.test.ts new file mode 100644 index 0000000000..0eacd23816 --- /dev/null +++ b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from '@jest/globals'; +import { NextRequest } from 'next/server'; +import { getOpenRouterModelsMetadataFromDatabase } from '@/lib/ai-gateway/providers/gateway-models-cache'; +import { GET } from './route'; + +jest.mock('@/lib/ai-gateway/providers/gateway-models-cache', () => ({ + getOpenRouterModelsMetadataFromDatabase: jest.fn(), +})); + +const mockedGetOpenRouterModelsMetadataFromDatabase = jest.mocked( + getOpenRouterModelsMetadataFromDatabase +); + +function request(modelId: string) { + return new NextRequest(`http://localhost:3000/api/openrouter/models/${modelId}/endpoints`); +} + +describe('GET /api/openrouter/models/[provider]/[model]/endpoints', () => { + test('returns the cached model without adding missing fields', async () => { + const model = { + id: 'deepseek/deepseek-v4-pro', + name: 'DeepSeek: DeepSeek V4 Pro', + endpoints: [ + { + provider_name: 'DeepSeek', + context_length: 1_048_576, + pricing: { prompt: '0.000000435', completion: '0.00000087' }, + }, + ], + }; + mockedGetOpenRouterModelsMetadataFromDatabase.mockResolvedValue({ [model.id]: model }); + + const response = await GET(request(model.id), { + params: Promise.resolve({ provider: 'deepseek', model: 'deepseek-v4-pro' }), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ data: model }); + }); + + test('returns 404 when the model is absent from the cache', async () => { + mockedGetOpenRouterModelsMetadataFromDatabase.mockResolvedValue({}); + + const response = await GET(request('missing/model'), { + params: Promise.resolve({ provider: 'missing', model: 'model' }), + }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + error: { message: 'Not Found', code: 404 }, + }); + }); +}); diff --git a/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts new file mode 100644 index 0000000000..2d04e7aa7e --- /dev/null +++ b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts @@ -0,0 +1,18 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { getOpenRouterModelsMetadataFromDatabase } from '@/lib/ai-gateway/providers/gateway-models-cache'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ provider: string; model: string }> } +) { + const { provider, model } = await params; + const models = await getOpenRouterModelsMetadataFromDatabase(); + const storedModel = models[`${provider}/${model}`]; + + if (!storedModel) { + return NextResponse.json({ error: { message: 'Not Found', code: 404 } }, { status: 404 }); + } + + return NextResponse.json({ data: storedModel }); +} From a2c85e08a5b5a3d7b80e9cd7de361b60d3889395 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Wed, 29 Jul 2026 15:35:47 +0200 Subject: [PATCH 2/6] fix(api): normalize cached endpoint pricing --- .../[model]/endpoints/route.test.ts | 28 +++++++++++++++++-- .../[provider]/[model]/endpoints/route.ts | 15 +++++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.test.ts b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.test.ts index 0eacd23816..a333f4eede 100644 --- a/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.test.ts +++ b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.test.ts @@ -16,7 +16,7 @@ function request(modelId: string) { } describe('GET /api/openrouter/models/[provider]/[model]/endpoints', () => { - test('returns the cached model without adding missing fields', async () => { + test('undoes discounts for every priced endpoint without adding missing fields', async () => { const model = { id: 'deepseek/deepseek-v4-pro', name: 'DeepSeek: DeepSeek V4 Pro', @@ -24,8 +24,13 @@ describe('GET /api/openrouter/models/[provider]/[model]/endpoints', () => { { provider_name: 'DeepSeek', context_length: 1_048_576, - pricing: { prompt: '0.000000435', completion: '0.00000087' }, + pricing: { prompt: '0.000000435', completion: '0.00000087', discount: 0.5 }, }, + { + provider_name: 'Baidu', + pricing: { prompt: '0.0000006253', completion: '0.0000012506', discount: 0.63 }, + }, + { provider_name: 'Unpriced' }, ], }; mockedGetOpenRouterModelsMetadataFromDatabase.mockResolvedValue({ [model.id]: model }); @@ -35,7 +40,24 @@ describe('GET /api/openrouter/models/[provider]/[model]/endpoints', () => { }); expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ data: model }); + await expect(response.json()).resolves.toEqual({ + data: { + id: model.id, + name: model.name, + endpoints: [ + { + provider_name: 'DeepSeek', + context_length: 1_048_576, + pricing: { prompt: '0.000000870000', completion: '0.000001740000' }, + }, + { + provider_name: 'Baidu', + pricing: { prompt: '0.000001690000', completion: '0.000003380000' }, + }, + { provider_name: 'Unpriced' }, + ], + }, + }); }); test('returns 404 when the model is absent from the cache', async () => { diff --git a/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts index 2d04e7aa7e..48a60d96b4 100644 --- a/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts +++ b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts @@ -1,6 +1,7 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { getOpenRouterModelsMetadataFromDatabase } from '@/lib/ai-gateway/providers/gateway-models-cache'; +import { getModelDisplayPricing } from '@/lib/ai-gateway/providers/openrouter'; export async function GET( _request: NextRequest, @@ -14,5 +15,17 @@ export async function GET( return NextResponse.json({ error: { message: 'Not Found', code: 404 } }, { status: 404 }); } - return NextResponse.json({ data: storedModel }); + return NextResponse.json({ + data: { + ...storedModel, + endpoints: storedModel.endpoints.map(endpoint => + endpoint.pricing + ? { + ...endpoint, + pricing: getModelDisplayPricing(storedModel.id, endpoint.pricing), + } + : endpoint + ), + }, + }); } From e587585962a9fcd3a896f2288569faaa82172b53 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Wed, 29 Jul 2026 15:54:34 +0200 Subject: [PATCH 3/6] fix(api): apply custom endpoint pricing --- .../[model]/endpoints/route.test.ts | 49 +++++++++++++++++++ .../[provider]/[model]/endpoints/route.ts | 17 ++++--- apps/web/src/lib/ai-gateway/custom-pricing.ts | 8 +++ 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.test.ts b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.test.ts index a333f4eede..dc57c47f71 100644 --- a/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.test.ts +++ b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from '@jest/globals'; import { NextRequest } from 'next/server'; import { getOpenRouterModelsMetadataFromDatabase } from '@/lib/ai-gateway/providers/gateway-models-cache'; +import { QWEN37_MAX_MODEL_ID } from '@/lib/ai-gateway/custom-pricing'; import { GET } from './route'; jest.mock('@/lib/ai-gateway/providers/gateway-models-cache', () => ({ @@ -72,4 +73,52 @@ describe('GET /api/openrouter/models/[provider]/[model]/endpoints', () => { error: { message: 'Not Found', code: 404 }, }); }); + + test('applies custom pricing to every priced endpoint', async () => { + const model = { + id: QWEN37_MAX_MODEL_ID, + name: 'Qwen: Qwen3.7 Max', + endpoints: [ + { + provider_name: 'Alibaba', + pricing: { prompt: '0.000001', completion: '0.000002' }, + }, + { + provider_name: 'Another provider', + pricing: { prompt: '0.000003', completion: '0.000004' }, + }, + { provider_name: 'Unpriced' }, + ], + }; + mockedGetOpenRouterModelsMetadataFromDatabase.mockResolvedValue({ [model.id]: model }); + const [provider, modelName] = model.id.split('/'); + + const response = await GET(request(model.id), { + params: Promise.resolve({ provider, model: modelName }), + }); + + expect(response.status).toBe(200); + const data = (await response.json()).data; + expect(data.endpoints).toEqual([ + { + provider_name: 'Alibaba', + pricing: { + prompt: '0.000001250000', + completion: '0.000003750000', + input_cache_read: '0.000000125000', + input_cache_write: '0.000001562500', + }, + }, + { + provider_name: 'Another provider', + pricing: { + prompt: '0.000001250000', + completion: '0.000003750000', + input_cache_read: '0.000000125000', + input_cache_write: '0.000001562500', + }, + }, + { provider_name: 'Unpriced' }, + ]); + }); }); diff --git a/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts index 48a60d96b4..af4c369c7f 100644 --- a/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts +++ b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts @@ -2,6 +2,7 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { getOpenRouterModelsMetadataFromDatabase } from '@/lib/ai-gateway/providers/gateway-models-cache'; import { getModelDisplayPricing } from '@/lib/ai-gateway/providers/openrouter'; +import { applyCustomPricingToPricing } from '@/lib/ai-gateway/custom-pricing'; export async function GET( _request: NextRequest, @@ -18,14 +19,14 @@ export async function GET( return NextResponse.json({ data: { ...storedModel, - endpoints: storedModel.endpoints.map(endpoint => - endpoint.pricing - ? { - ...endpoint, - pricing: getModelDisplayPricing(storedModel.id, endpoint.pricing), - } - : endpoint - ), + endpoints: storedModel.endpoints.map(endpoint => { + if (!endpoint.pricing) return endpoint; + const displayPricing = getModelDisplayPricing(storedModel.id, endpoint.pricing); + return { + ...endpoint, + pricing: applyCustomPricingToPricing(storedModel.id, displayPricing ?? endpoint.pricing), + }; + }), }, }); } diff --git a/apps/web/src/lib/ai-gateway/custom-pricing.ts b/apps/web/src/lib/ai-gateway/custom-pricing.ts index 3e4fab2140..11088c127f 100644 --- a/apps/web/src/lib/ai-gateway/custom-pricing.ts +++ b/apps/web/src/lib/ai-gateway/custom-pricing.ts @@ -84,6 +84,14 @@ function applyPricing(pricing: OpenRouterModel['pricing'], customPricing: Pricin }; } +export function applyCustomPricingToPricing( + modelId: string, + pricing: OpenRouterModel['pricing'] +): OpenRouterModel['pricing'] { + const customPricing = getCustomPricing(modelId); + return customPricing ? applyPricing(pricing, customPricing.pricing[0].pricing) : pricing; +} + export function applyCustomPricingToModel(model: OpenRouterModel): OpenRouterModel { const customPricing = getCustomPricing(model.id); if (!customPricing) return model; From 6a46f90b897dd0330f08e8ad29ec72f90abfb480 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Wed, 29 Jul 2026 16:09:14 +0200 Subject: [PATCH 4/6] fix(api): hide forbidden model endpoints --- .../[provider]/[model]/endpoints/route.test.ts | 15 +++++++++++++++ .../models/[provider]/[model]/endpoints/route.ts | 8 +++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.test.ts b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.test.ts index dc57c47f71..ebf1a23fe3 100644 --- a/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.test.ts +++ b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.test.ts @@ -74,6 +74,21 @@ describe('GET /api/openrouter/models/[provider]/[model]/endpoints', () => { }); }); + test('returns 404 for forbidden free models without reading cached metadata', async () => { + mockedGetOpenRouterModelsMetadataFromDatabase.mockClear(); + const modelId = 'openai/gpt-oss-20b:free'; + + const response = await GET(request(modelId), { + params: Promise.resolve({ provider: 'openai', model: 'gpt-oss-20b:free' }), + }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + error: { message: 'Not Found', code: 404 }, + }); + expect(mockedGetOpenRouterModelsMetadataFromDatabase).not.toHaveBeenCalled(); + }); + test('applies custom pricing to every priced endpoint', async () => { const model = { id: QWEN37_MAX_MODEL_ID, diff --git a/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts index af4c369c7f..0785cadf40 100644 --- a/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts +++ b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts @@ -3,14 +3,20 @@ import { NextResponse } from 'next/server'; import { getOpenRouterModelsMetadataFromDatabase } from '@/lib/ai-gateway/providers/gateway-models-cache'; import { getModelDisplayPricing } from '@/lib/ai-gateway/providers/openrouter'; import { applyCustomPricingToPricing } from '@/lib/ai-gateway/custom-pricing'; +import { isForbiddenFreeModel } from '@/lib/ai-gateway/forbidden-free-models'; export async function GET( _request: NextRequest, { params }: { params: Promise<{ provider: string; model: string }> } ) { const { provider, model } = await params; + const modelId = `${provider}/${model}`; + if (isForbiddenFreeModel(modelId)) { + return NextResponse.json({ error: { message: 'Not Found', code: 404 } }, { status: 404 }); + } + const models = await getOpenRouterModelsMetadataFromDatabase(); - const storedModel = models[`${provider}/${model}`]; + const storedModel = models[modelId]; if (!storedModel) { return NextResponse.json({ error: { message: 'Not Found', code: 404 } }, { status: 404 }); From 755950cf9c8c029f88cb520040eaaa33f883d72a Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Wed, 29 Jul 2026 16:14:24 +0200 Subject: [PATCH 5/6] feat(api): add versioned cached endpoints --- .../[provider]/[model]/endpoints/route.ts | 1 + .../[model]/endpoints/route.test.ts | 16 +++++++ .../[provider]/[model]/endpoints/route.ts | 42 ++++++++++++------- .../[provider]/[model]/endpoints/route.ts | 1 + 4 files changed, 46 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/app/api/gateway/v1/models/[provider]/[model]/endpoints/route.ts create mode 100644 apps/web/src/app/api/openrouter/v1/models/[provider]/[model]/endpoints/route.ts diff --git a/apps/web/src/app/api/gateway/v1/models/[provider]/[model]/endpoints/route.ts b/apps/web/src/app/api/gateway/v1/models/[provider]/[model]/endpoints/route.ts new file mode 100644 index 0000000000..b686013c0f --- /dev/null +++ b/apps/web/src/app/api/gateway/v1/models/[provider]/[model]/endpoints/route.ts @@ -0,0 +1 @@ +export { GET } from '@/app/api/openrouter/models/[provider]/[model]/endpoints/route'; diff --git a/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.test.ts b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.test.ts index ebf1a23fe3..1d8a25af1a 100644 --- a/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.test.ts +++ b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.test.ts @@ -3,6 +3,8 @@ import { NextRequest } from 'next/server'; import { getOpenRouterModelsMetadataFromDatabase } from '@/lib/ai-gateway/providers/gateway-models-cache'; import { QWEN37_MAX_MODEL_ID } from '@/lib/ai-gateway/custom-pricing'; import { GET } from './route'; +import { GET as openRouterV1GET } from '@/app/api/openrouter/v1/models/[provider]/[model]/endpoints/route'; +import { GET as gatewayV1GET } from '@/app/api/gateway/v1/models/[provider]/[model]/endpoints/route'; jest.mock('@/lib/ai-gateway/providers/gateway-models-cache', () => ({ getOpenRouterModelsMetadataFromDatabase: jest.fn(), @@ -17,6 +19,11 @@ function request(modelId: string) { } describe('GET /api/openrouter/models/[provider]/[model]/endpoints', () => { + test('uses the canonical handler for versioned routes', () => { + expect(openRouterV1GET).toBe(GET); + expect(gatewayV1GET).toBe(GET); + }); + test('undoes discounts for every priced endpoint without adding missing fields', async () => { const model = { id: 'deepseek/deepseek-v4-pro', @@ -41,6 +48,9 @@ describe('GET /api/openrouter/models/[provider]/[model]/endpoints', () => { }); expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe( + 'public, max-age=0, s-maxage=60, stale-while-revalidate=60' + ); await expect(response.json()).resolves.toEqual({ data: { id: model.id, @@ -69,6 +79,9 @@ describe('GET /api/openrouter/models/[provider]/[model]/endpoints', () => { }); expect(response.status).toBe(404); + expect(response.headers.get('cache-control')).toBe( + 'public, max-age=0, s-maxage=60, stale-while-revalidate=60' + ); await expect(response.json()).resolves.toEqual({ error: { message: 'Not Found', code: 404 }, }); @@ -83,6 +96,9 @@ describe('GET /api/openrouter/models/[provider]/[model]/endpoints', () => { }); expect(response.status).toBe(404); + expect(response.headers.get('cache-control')).toBe( + 'public, max-age=0, s-maxage=60, stale-while-revalidate=60' + ); await expect(response.json()).resolves.toEqual({ error: { message: 'Not Found', code: 404 }, }); diff --git a/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts index 0785cadf40..a27f01fb90 100644 --- a/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts +++ b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts @@ -5,6 +5,8 @@ import { getModelDisplayPricing } from '@/lib/ai-gateway/providers/openrouter'; import { applyCustomPricingToPricing } from '@/lib/ai-gateway/custom-pricing'; import { isForbiddenFreeModel } from '@/lib/ai-gateway/forbidden-free-models'; +const CACHE_CONTROL = 'public, max-age=0, s-maxage=60, stale-while-revalidate=60'; + export async function GET( _request: NextRequest, { params }: { params: Promise<{ provider: string; model: string }> } @@ -12,27 +14,39 @@ export async function GET( const { provider, model } = await params; const modelId = `${provider}/${model}`; if (isForbiddenFreeModel(modelId)) { - return NextResponse.json({ error: { message: 'Not Found', code: 404 } }, { status: 404 }); + return NextResponse.json( + { error: { message: 'Not Found', code: 404 } }, + { status: 404, headers: { 'Cache-Control': CACHE_CONTROL } } + ); } const models = await getOpenRouterModelsMetadataFromDatabase(); const storedModel = models[modelId]; if (!storedModel) { - return NextResponse.json({ error: { message: 'Not Found', code: 404 } }, { status: 404 }); + return NextResponse.json( + { error: { message: 'Not Found', code: 404 } }, + { status: 404, headers: { 'Cache-Control': CACHE_CONTROL } } + ); } - return NextResponse.json({ - data: { - ...storedModel, - endpoints: storedModel.endpoints.map(endpoint => { - if (!endpoint.pricing) return endpoint; - const displayPricing = getModelDisplayPricing(storedModel.id, endpoint.pricing); - return { - ...endpoint, - pricing: applyCustomPricingToPricing(storedModel.id, displayPricing ?? endpoint.pricing), - }; - }), + return NextResponse.json( + { + data: { + ...storedModel, + endpoints: storedModel.endpoints.map(endpoint => { + if (!endpoint.pricing) return endpoint; + const displayPricing = getModelDisplayPricing(storedModel.id, endpoint.pricing); + return { + ...endpoint, + pricing: applyCustomPricingToPricing( + storedModel.id, + displayPricing ?? endpoint.pricing + ), + }; + }), + }, }, - }); + { headers: { 'Cache-Control': CACHE_CONTROL } } + ); } diff --git a/apps/web/src/app/api/openrouter/v1/models/[provider]/[model]/endpoints/route.ts b/apps/web/src/app/api/openrouter/v1/models/[provider]/[model]/endpoints/route.ts new file mode 100644 index 0000000000..b686013c0f --- /dev/null +++ b/apps/web/src/app/api/openrouter/v1/models/[provider]/[model]/endpoints/route.ts @@ -0,0 +1 @@ +export { GET } from '@/app/api/openrouter/models/[provider]/[model]/endpoints/route'; From e061d1626e4e75c2b2664e991ebe016f75f74ac6 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Wed, 29 Jul 2026 16:24:56 +0200 Subject: [PATCH 6/6] refactor(ai-gateway): isolate OpenRouter display pricing --- .../[provider]/[model]/endpoints/route.ts | 2 +- .../openrouter/display-pricing.test.ts | 66 +++++++++++++++++++ .../providers/openrouter/display-pricing.ts | 27 ++++++++ .../providers/openrouter/index.test.ts | 62 ----------------- .../ai-gateway/providers/openrouter/index.ts | 32 ++------- 5 files changed, 100 insertions(+), 89 deletions(-) create mode 100644 apps/web/src/lib/ai-gateway/providers/openrouter/display-pricing.test.ts create mode 100644 apps/web/src/lib/ai-gateway/providers/openrouter/display-pricing.ts diff --git a/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts index a27f01fb90..b37c9b6dec 100644 --- a/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts +++ b/apps/web/src/app/api/openrouter/models/[provider]/[model]/endpoints/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { getOpenRouterModelsMetadataFromDatabase } from '@/lib/ai-gateway/providers/gateway-models-cache'; -import { getModelDisplayPricing } from '@/lib/ai-gateway/providers/openrouter'; +import { getModelDisplayPricing } from '@/lib/ai-gateway/providers/openrouter/display-pricing'; import { applyCustomPricingToPricing } from '@/lib/ai-gateway/custom-pricing'; import { isForbiddenFreeModel } from '@/lib/ai-gateway/forbidden-free-models'; diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/display-pricing.test.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/display-pricing.test.ts new file mode 100644 index 0000000000..0000cab77a --- /dev/null +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/display-pricing.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from '@jest/globals'; +import { OPENROUTER_GPT56_PROMO_MODEL_IDS } from '@/lib/ai-gateway/providers/openai'; +import { + getModelDisplayPricing, + undoPricingDiscount, +} from '@/lib/ai-gateway/providers/openrouter/display-pricing'; + +describe('undoPricingDiscount', () => { + it('reverses the discount and drops the field without exponential output', () => { + const result = undoPricingDiscount({ + prompt: '0.00000006', + completion: '0.0000006', + input_cache_read: '0.000000015', + discount: 0.7, + }); + expect(result).toEqual({ + prompt: '0.000000200000', + completion: '0.000002000000', + input_cache_read: '0.000000050000', + }); + expect('discount' in result).toBe(false); + for (const value of Object.values(result)) { + expect(value).not.toMatch(/e/i); + } + }); + + it('leaves pricing untouched when there is no discount', () => { + const pricing = { prompt: '0.000001', completion: '0.000005' }; + expect(undoPricingDiscount(pricing)).toBe(pricing); + }); + + it('leaves pricing untouched when the discount is zero', () => { + const pricing = { prompt: '0.000001', completion: '0.000005', discount: 0 }; + expect(undoPricingDiscount(pricing)).toBe(pricing); + }); + + it('drops the field when the discount cannot be reversed', () => { + const result = undoPricingDiscount({ + prompt: '0.000001', + completion: '0.000005', + discount: 1, + }); + expect(result).toEqual({ prompt: '0.000001', completion: '0.000005' }); + }); +}); + +describe('OpenRouter GPT-5.6 promotion', () => { + const discountedPricing = { + prompt: '0.000001', + completion: '0.000004', + input_cache_read: '0.0000001', + discount: 0.5, + }; + + it.each(OPENROUTER_GPT56_PROMO_MODEL_IDS)('preserves discounted pricing for %s', modelId => { + expect(getModelDisplayPricing(modelId, discountedPricing)).toBe(discountedPricing); + }); + + it('continues to undo endpoint discounts for other models', () => { + expect(getModelDisplayPricing('openai/gpt-5.6-sol', discountedPricing)).toEqual({ + prompt: '0.000002000000', + completion: '0.000008000000', + input_cache_read: '0.000000200000', + }); + }); +}); diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/display-pricing.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/display-pricing.ts new file mode 100644 index 0000000000..e4327e02f1 --- /dev/null +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/display-pricing.ts @@ -0,0 +1,27 @@ +import type { StoredModel } from '@kilocode/db'; +import { isOpenRouterGpt56PromoModel } from '@/lib/ai-gateway/providers/openai'; + +export type EndpointPricing = NonNullable; + +export function undoPricingDiscount(pricing: EndpointPricing): EndpointPricing { + const { discount, ...prices } = pricing; + if (discount === undefined || discount <= 0) return pricing; + const factor = 1 - discount; + if (factor <= 0) return prices; + const result = { ...prices }; + for (const key of Object.keys(prices) as (keyof typeof prices)[]) { + const value = prices[key]; + if (value !== undefined) { + result[key] = (Number.parseFloat(value) / factor).toFixed(12); + } + } + return result; +} + +export function getModelDisplayPricing( + modelId: string, + pricing: EndpointPricing | undefined +): EndpointPricing | undefined { + if (!pricing) return undefined; + return isOpenRouterGpt56PromoModel(modelId) ? pricing : undoPricingDiscount(pricing); +} diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/index.test.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/index.test.ts index bd251a7299..49470d02cd 100644 --- a/apps/web/src/lib/ai-gateway/providers/openrouter/index.test.ts +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/index.test.ts @@ -2,10 +2,8 @@ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals import { formatName, getEnhancedOpenRouterModels, - getModelDisplayPricing, getOpenRouterTranscriptionModels, shouldSuppressOpenRouterModel, - undoPricingDiscount, } from '@/lib/ai-gateway/providers/openrouter'; import { createMockResponse, mockOpenRouterModels } from '@/tests/helpers/openrouter-models.helper'; import type { OpenRouterModel } from '@/lib/organizations/organization-types'; @@ -173,66 +171,6 @@ describe('formatName', () => { }); }); -describe('undoPricingDiscount', () => { - it('reverses the discount and drops the field without exponential output', () => { - const result = undoPricingDiscount({ - prompt: '0.00000006', - completion: '0.0000006', - input_cache_read: '0.000000015', - discount: 0.7, - }); - expect(result).toEqual({ - prompt: '0.000000200000', - completion: '0.000002000000', - input_cache_read: '0.000000050000', - }); - expect('discount' in result).toBe(false); - for (const value of Object.values(result)) { - expect(value).not.toMatch(/e/i); - } - }); - - it('leaves pricing untouched when there is no discount', () => { - const pricing = { prompt: '0.000001', completion: '0.000005' }; - expect(undoPricingDiscount(pricing)).toBe(pricing); - }); - - it('leaves pricing untouched when the discount is zero', () => { - const pricing = { prompt: '0.000001', completion: '0.000005', discount: 0 }; - expect(undoPricingDiscount(pricing)).toBe(pricing); - }); - - it('drops the field when the discount cannot be reversed', () => { - const result = undoPricingDiscount({ - prompt: '0.000001', - completion: '0.000005', - discount: 1, - }); - expect(result).toEqual({ prompt: '0.000001', completion: '0.000005' }); - }); -}); - -describe('OpenRouter GPT-5.6 promotion', () => { - const discountedPricing = { - prompt: '0.000001', - completion: '0.000004', - input_cache_read: '0.0000001', - discount: 0.5, - }; - - it.each(OPENROUTER_GPT56_PROMO_MODEL_IDS)('preserves discounted pricing for %s', modelId => { - expect(getModelDisplayPricing(modelId, discountedPricing)).toBe(discountedPricing); - }); - - it('continues to undo endpoint discounts for other models', () => { - expect(getModelDisplayPricing('openai/gpt-5.6-sol', discountedPricing)).toEqual({ - prompt: '0.000002000000', - completion: '0.000008000000', - input_cache_read: '0.000000200000', - }); - }); -}); - describe('shouldSuppressOpenRouterModel', () => { it('does not suppress disabled paid Kilo-exclusive models returned by OpenRouter', () => { expect(disabledPaidModel.pricing).not.toBeNull(); diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/index.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/index.ts index 074e7965f5..01c62181a8 100644 --- a/apps/web/src/lib/ai-gateway/providers/openrouter/index.ts +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/index.ts @@ -5,7 +5,6 @@ import { } from '@/lib/ai-gateway/models'; import { isFreeModel } from '@/lib/ai-gateway/is-free-model'; import PROVIDERS from '@/lib/ai-gateway/providers/provider-definitions'; -import type { StoredModel } from '@kilocode/db'; import type { OpenRouterModel } from '@/lib/organizations/organization-types'; import { OpenRouterModelsResponseSchema, @@ -29,9 +28,15 @@ import { isFreeNemotronModel, NVIDIA_TRIAL_TOS } from '@/lib/ai-gateway/provider import { applyCustomPricingToModel } from '@/lib/ai-gateway/custom-pricing'; import { addMonths } from 'date-fns'; import { isOpenRouterGpt56PromoModel } from '@/lib/ai-gateway/providers/openai'; +import { getModelDisplayPricing } from '@/lib/ai-gateway/providers/openrouter/display-pricing'; // Re-export from shared module for backwards compatibility export { normalizeModelId } from '@/lib/ai-gateway/model-utils'; +export { + getModelDisplayPricing, + undoPricingDiscount, +} from '@/lib/ai-gateway/providers/openrouter/display-pricing'; +export type { EndpointPricing } from '@/lib/ai-gateway/providers/openrouter/display-pricing'; export function buildAutoModelCatalogEntry(m: AutoModel): OpenRouterModel { const input_modalities = ['text']; @@ -107,31 +112,6 @@ export function formatName(model: OpenRouterModel, preferredIndex: number) { return name; } -type EndpointPricing = NonNullable; - -export function undoPricingDiscount(pricing: EndpointPricing): EndpointPricing { - const { discount, ...prices } = pricing; - if (discount === undefined || discount <= 0) return pricing; - const factor = 1 - discount; - if (factor <= 0) return prices; - const result = { ...prices }; - for (const key of Object.keys(prices) as (keyof typeof prices)[]) { - const value = prices[key]; - if (value !== undefined) { - result[key] = (Number.parseFloat(value) / factor).toFixed(12); - } - } - return result; -} - -export function getModelDisplayPricing( - modelId: string, - pricing: EndpointPricing | undefined -): EndpointPricing | undefined { - if (!pricing) return undefined; - return isOpenRouterGpt56PromoModel(modelId) ? pricing : undoPricingDiscount(pricing); -} - export function shouldSuppressOpenRouterModel(model: KiloExclusiveModel): boolean { return model.status !== 'disabled' || model.pricing === null; }