diff --git a/apps/web/src/app/api/openrouter/[...path]/route.test.ts b/apps/web/src/app/api/openrouter/[...path]/route.test.ts index 9993e41055..0c1d7058f6 100644 --- a/apps/web/src/app/api/openrouter/[...path]/route.test.ts +++ b/apps/web/src/app/api/openrouter/[...path]/route.test.ts @@ -784,16 +784,19 @@ describe('POST /api/openrouter/v1/chat/completions rules-engine actions', () => expect(mockedUpstreamRequest).not.toHaveBeenCalled(); }); - it('rejects the disabled LongCat free model before upstream', async () => { - const { POST } = await import('./route'); - const response = await POST(makeRequest(makeBody('meituan/longcat-2.0-free')) as never); + it.each(['tencent/hy3:free', 'meituan/longcat-2.0-free'])( + 'rejects the removed free model %s before upstream', + async modelId => { + const { POST } = await import('./route'); + const response = await POST(makeRequest(makeBody(modelId)) as never); - expect(response.status).toBe(404); - expect(await response.json()).toMatchObject({ - error_type: 'unavailable_model', - }); - expect(mockedUpstreamRequest).not.toHaveBeenCalled(); - }); + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ + error_type: 'unavailable_model', + }); + expect(mockedUpstreamRequest).not.toHaveBeenCalled(); + } + ); it('rate limits rules-engine rate-limit actions before upstream', async () => { mockedRedisGet.mockResolvedValue(cachedRulesEngineAction('rate-limit')); 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 f74394e1b3..0d0a5fbfc7 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 @@ -87,23 +87,26 @@ describe('GET /api/openrouter/models/[provider]/[model]/endpoints', () => { }); }); - test('returns 404 for unavailable 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); - 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 }, - }); - expect(mockedGetOpenRouterModelsMetadataFromDatabase).not.toHaveBeenCalled(); - }); + test.each(['openai/gpt-oss-20b:free', 'tencent/hy3:free', 'meituan/longcat-2.0-free'])( + 'returns 404 for unavailable model %s without reading cached metadata', + async modelId => { + mockedGetOpenRouterModelsMetadataFromDatabase.mockClear(); + const [provider, model] = modelId.split('/'); + + const response = await GET(request(modelId), { + params: Promise.resolve({ provider, model }), + }); + + 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 }, + }); + expect(mockedGetOpenRouterModelsMetadataFromDatabase).not.toHaveBeenCalled(); + } + ); test('applies custom pricing to every priced endpoint', async () => { const model = { diff --git a/apps/web/src/lib/ai-gateway/models.test.ts b/apps/web/src/lib/ai-gateway/models.test.ts index cf0102cf83..bdf3e72386 100644 --- a/apps/web/src/lib/ai-gateway/models.test.ts +++ b/apps/web/src/lib/ai-gateway/models.test.ts @@ -3,7 +3,6 @@ import { autoFreeModels, findKiloExclusiveModel, getKiloExclusiveInferenceProviderRestriction, - isDisabledKiloExclusiveModel, isKiloExclusiveRateLimitedModel, kiloExclusiveModels, preferredModels, @@ -20,9 +19,7 @@ import { claude_opus_4_6_stealth_model, } from './providers/anthropic.constants'; import { gpt_5_6_sol_discounted_model } from './providers/openai-exclusive'; -import { tencent_hy3_free_model } from './providers/tencent'; import { gemma_4_26b_a4b_it_free_model } from './providers/google'; -import { longcat_2_free_model } from './providers/longcat'; import { isUnavailableModel } from './unavailable-models'; import { getRandomNumber } from './getRandomNumber'; @@ -32,7 +29,7 @@ describe('rate-limited Kilo-exclusive models', () => { gemma_4_26b_a4b_it_free_model, ]); expect(isKiloExclusiveRateLimitedModel(gemma_4_26b_a4b_it_free_model.public_id)).toBe(true); - expect(isKiloExclusiveRateLimitedModel(tencent_hy3_free_model.public_id)).toBe(false); + expect(isKiloExclusiveRateLimitedModel('tencent/hy3:free')).toBe(false); }); }); @@ -82,26 +79,16 @@ describe('isFreeModel', () => { expect(findKiloExclusiveModel('qwen/qwen3.7-plus')).toBeNull(); }); - test('disables Tencent Hy3 free and excludes it from Auto Free and preferred models', () => { - expect(tencent_hy3_free_model.status).toBe('disabled'); - expect(findKiloExclusiveModel('tencent/hy3:free')).toBeNull(); - expect(autoFreeModels.map(({ model }) => model)).not.toContain( - tencent_hy3_free_model.public_id - ); - expect(preferredModels).not.toContain(tencent_hy3_free_model.public_id); - }); - - test('disables LongCat 2.0 free and excludes it from Auto Free and preferred models', async () => { - expect(kiloExclusiveModels).toContain(longcat_2_free_model); - expect(longcat_2_free_model.status).toBe('disabled'); - expect(isDisabledKiloExclusiveModel(longcat_2_free_model.public_id)).toBe(true); - expect(findKiloExclusiveModel(longcat_2_free_model.public_id)).toBeNull(); - expect(await isFreeModel(longcat_2_free_model.public_id)).toBe(false); - expect(autoFreeModels.map(({ model }) => model)).not.toContain( - longcat_2_free_model.public_id - ); - expect(preferredModels).not.toContain(longcat_2_free_model.public_id); - }); + test.each(['tencent/hy3:free', 'meituan/longcat-2.0-free'])( + 'removes %s from exclusive, Auto Free, and preferred models while keeping it unavailable', + modelId => { + expect(kiloExclusiveModels.some(model => model.public_id === modelId)).toBe(false); + expect(findKiloExclusiveModel(modelId)).toBeNull(); + expect(isUnavailableModel(modelId)).toBe(true); + expect(autoFreeModels.map(({ model }) => model)).not.toContain(modelId); + expect(preferredModels).not.toContain(modelId); + } + ); test.each(['minimax/minimax-m3:free', 'minimax/minimax-m2.7:free'])( 'inherits %s without an exclusive definition or availability restriction', @@ -392,10 +379,8 @@ describe('getKiloExclusiveInferenceProviderRestriction', () => { ).toEqual(new Set(['openai'])); }); - test('does not treat disabled or unrestricted exclusives or unknown ids as restricted', () => { - expect( - getKiloExclusiveInferenceProviderRestriction(tencent_hy3_free_model.public_id) - ).toBeUndefined(); + test('does not treat removed or unrestricted exclusives or unknown ids as restricted', () => { + expect(getKiloExclusiveInferenceProviderRestriction('tencent/hy3:free')).toBeUndefined(); expect( getKiloExclusiveInferenceProviderRestriction(gemma_4_26b_a4b_it_free_model.public_id) ).toBeUndefined(); diff --git a/apps/web/src/lib/ai-gateway/models.ts b/apps/web/src/lib/ai-gateway/models.ts index 6076998d99..417e678382 100644 --- a/apps/web/src/lib/ai-gateway/models.ts +++ b/apps/web/src/lib/ai-gateway/models.ts @@ -23,8 +23,6 @@ import { KIMI_CURRENT_MODEL_ID } from '@/lib/ai-gateway/providers/moonshotai'; import { gemma_4_26b_a4b_it_free_model, isGeminiModel } from '@/lib/ai-gateway/providers/google'; import { qwen36_plus_stealth_model } from '@/lib/ai-gateway/providers/qwen'; import { stepfun_37_flash_free_model } from '@/lib/ai-gateway/providers/stepfun'; -import { tencent_hy3_free_model } from '@/lib/ai-gateway/providers/tencent'; -import { longcat_2_free_model } from '@/lib/ai-gateway/providers/longcat'; import { isGrokModel } from '@/lib/ai-gateway/providers/xai'; import { isClaudeModel } from '@/lib/ai-gateway/providers/anthropic.constants'; import { GPT_CURRENT_MODEL_ID, isOpenAiModel } from '@/lib/ai-gateway/providers/openai'; @@ -52,29 +50,11 @@ export const autoFreeModels: ReadonlyArray = [ } satisfies AutoFreeModel, ] : []), - ...(tencent_hy3_free_model.status === 'public' - ? [ - { - model: tencent_hy3_free_model.public_id, - weight: 1, - reasoning: { enabled: true, effort: 'high' }, - } satisfies AutoFreeModel, - ] - : []), { model: 'poolside/laguna-s-2.1:free', weight: 1, reasoning: { enabled: true, effort: 'high' }, } satisfies AutoFreeModel, - ...(longcat_2_free_model.status === 'public' - ? [ - { - model: longcat_2_free_model.public_id, - weight: 1, - reasoning: { enabled: true, effort: 'high' }, - } satisfies AutoFreeModel, - ] - : []), { model: 'minimax/minimax-m3:free', weight: 1, @@ -152,8 +132,6 @@ export const kiloExclusiveModels = [ claude_sonnet_4_6_stealth_model, claude_opus_4_6_stealth_model, stepfun_37_flash_free_model, - tencent_hy3_free_model, - longcat_2_free_model, ] as KiloExclusiveModel[]; export function isKiloStealthModel(model: string): boolean { diff --git a/apps/web/src/lib/ai-gateway/providers/longcat.ts b/apps/web/src/lib/ai-gateway/providers/longcat.ts index 0dff990047..164613f806 100644 --- a/apps/web/src/lib/ai-gateway/providers/longcat.ts +++ b/apps/web/src/lib/ai-gateway/providers/longcat.ts @@ -1,20 +1,3 @@ -import type { KiloExclusiveModel } from '@/lib/ai-gateway/providers/kilo-exclusive-model'; - export function isLongCatModel(requestedModel: string) { return requestedModel.includes('longcat'); } - -export const longcat_2_free_model: KiloExclusiveModel = { - public_id: 'meituan/longcat-2.0-free', - display_name: 'Meituan: LongCat 2.0 (free)', - description: - 'LongCat 2.0 is a sparse mixture-of-experts language model from Meituan, with 48B active parameters out of 1.6T total. It is suited for coding, repository-level changes, long-horizon problem solving, and agentic workflows. Available free in Kilo for a limited time.', - context_length: 1_048_756, - max_completion_tokens: 131_072, - status: 'disabled', - flags: ['reasoning'], - gateway: 'longcat', - internal_id: 'LongCat-2.0', - pricing: null, - inference_provider_restriction: [], -}; diff --git a/apps/web/src/lib/ai-gateway/providers/tencent.ts b/apps/web/src/lib/ai-gateway/providers/tencent.ts deleted file mode 100644 index a0a13902ee..0000000000 --- a/apps/web/src/lib/ai-gateway/providers/tencent.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { KiloExclusiveModel } from '@/lib/ai-gateway/providers/kilo-exclusive-model'; - -export const tencent_hy3_free_model: KiloExclusiveModel = { - public_id: 'tencent/hy3:free', - display_name: 'Tencent: Hy3 (free)', - description: - 'Hy3 is a 295B-parameter Mixture-of-Experts model from Tencent, activating 21B parameters per token. It supports configurable reasoning effort, agentic workflows, reliable tool calling, and long-context tasks across coding, document processing, financial analysis, and frontend development.', - context_length: 262_144, - max_completion_tokens: 128_000, - status: 'disabled', - flags: ['reasoning'], - gateway: 'openrouter', - internal_id: 'tencent/hy3', - pricing: null, - inference_provider_restriction: ['tencent'], -}; diff --git a/apps/web/src/lib/ai-gateway/unavailable-models.test.ts b/apps/web/src/lib/ai-gateway/unavailable-models.test.ts index 0b152a4270..a80fc5670e 100644 --- a/apps/web/src/lib/ai-gateway/unavailable-models.test.ts +++ b/apps/web/src/lib/ai-gateway/unavailable-models.test.ts @@ -12,6 +12,7 @@ describe('unavailable models', () => { expect(isUnavailableModel('openai/gpt-oss-20b')).toBe(false); expect(isUnavailableModel('tencent/hy3:free')).toBe(true); expect(isUnavailableModel('tencent/hy3')).toBe(false); + expect(isUnavailableModel('meituan/longcat-2.0-free')).toBe(true); }); test('matches normalized families for provider metadata', () => { diff --git a/apps/web/src/lib/ai-gateway/unavailable-models.ts b/apps/web/src/lib/ai-gateway/unavailable-models.ts index c8a465fbee..d8b09f686a 100644 --- a/apps/web/src/lib/ai-gateway/unavailable-models.ts +++ b/apps/web/src/lib/ai-gateway/unavailable-models.ts @@ -3,6 +3,7 @@ import { normalizeModelId } from '@/lib/ai-gateway/model-utils'; const unavailableModelIds: ReadonlySet = new Set([ 'google/gemma-4-26b-a4b-it:free', // usable through kilo-auto 'google/gemma-4-31b-it:free', + 'meituan/longcat-2.0-free', 'nvidia/nemotron-3-nano-30b-a3b:free', 'nvidia/nemotron-nano-12b-v2-vl:free', 'nvidia/nemotron-nano-9b-v2:free', diff --git a/apps/web/src/tests/openrouter-models-config.test.ts b/apps/web/src/tests/openrouter-models-config.test.ts index eedd486cc1..b649f29d3c 100644 --- a/apps/web/src/tests/openrouter-models-config.test.ts +++ b/apps/web/src/tests/openrouter-models-config.test.ts @@ -7,7 +7,6 @@ import { import { GPT_CURRENT_MODEL_ID } from '@/lib/ai-gateway/providers/openai'; import { gpt_5_6_sol_discounted_model } from '@/lib/ai-gateway/providers/openai-exclusive'; import { QWEN37_PLUS_MODEL_ID } from '@/lib/ai-gateway/providers/qwen'; -import { tencent_hy3_free_model } from '@/lib/ai-gateway/providers/tencent'; describe('OpenRouter Models Config', () => { test('preferred models should contain expected models', () => { @@ -28,7 +27,8 @@ describe('OpenRouter Models Config', () => { 'stealth/qwen3.6-plus', QWEN37_PLUS_MODEL_ID, 'deepseek/deepseek-v4-pro', - tencent_hy3_free_model.public_id, + 'tencent/hy3:free', + 'meituan/longcat-2.0-free', ]; supersededModels.forEach(model => { diff --git a/apps/web/src/tests/openrouter-models.test.ts b/apps/web/src/tests/openrouter-models.test.ts index 185c7fa1ac..c59aac1815 100644 --- a/apps/web/src/tests/openrouter-models.test.ts +++ b/apps/web/src/tests/openrouter-models.test.ts @@ -282,6 +282,36 @@ describe('GET /api/openrouter/models', () => { expect(Array.isArray(responseData.data)).toBe(true); }); + test('excludes retired free Tencent and LongCat models advertised upstream while retaining paid Tencent', async () => { + const original = mockOpenRouterModels.data.find(model => model.id === 'some-other-model'); + if (!original) throw new Error('Expected catalog fixture'); + const upstream = { + data: [ + ...mockOpenRouterModels.data, + { ...original, id: 'tencent/hy3', name: 'Tencent: HY3' }, + ...['tencent/hy3:free', 'meituan/longcat-2.0-free'].map(id => ({ + ...original, + id, + name: id, + pricing: { ...original.pricing, prompt: '0', completion: '0' }, + })), + ], + }; + global.fetch = jest + .fn, Parameters>() + .mockResolvedValue(createMockResponse({ jsonData: upstream })); + + const response = await GET(createTestRequest('/api/openrouter/models')); + const responseData = OpenRouterModelsResponseSchema.parse(await response.json()); + const modelIds = responseData.data.map(model => model.id); + + expect(captureException).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + expect(modelIds).not.toContain('tencent/hy3:free'); + expect(modelIds).not.toContain('meituan/longcat-2.0-free'); + expect(modelIds).toContain('tencent/hy3'); + }); + test('should include publishable Terminal Bench summaries for canonical models', async () => { const request = createTestRequest('/api/openrouter/models');