Skip to content
Merged
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
21 changes: 12 additions & 9 deletions apps/web/src/app/api/openrouter/[...path]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
41 changes: 13 additions & 28 deletions apps/web/src/lib/ai-gateway/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
autoFreeModels,
findKiloExclusiveModel,
getKiloExclusiveInferenceProviderRestriction,
isDisabledKiloExclusiveModel,
isKiloExclusiveRateLimitedModel,
kiloExclusiveModels,
preferredModels,
Expand All @@ -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';

Expand All @@ -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);
});
});

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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();
Expand Down
22 changes: 0 additions & 22 deletions apps/web/src/lib/ai-gateway/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -52,29 +50,11 @@ export const autoFreeModels: ReadonlyArray<AutoFreeModel> = [
} 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,
Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 0 additions & 17 deletions apps/web/src/lib/ai-gateway/providers/longcat.ts
Original file line number Diff line number Diff line change
@@ -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: [],
};
16 changes: 0 additions & 16 deletions apps/web/src/lib/ai-gateway/providers/tencent.ts

This file was deleted.

1 change: 1 addition & 0 deletions apps/web/src/lib/ai-gateway/unavailable-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/ai-gateway/unavailable-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { normalizeModelId } from '@/lib/ai-gateway/model-utils';
const unavailableModelIds: ReadonlySet<string> = 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',
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/tests/openrouter-models-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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 => {
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/tests/openrouter-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof fetch>, Parameters<typeof fetch>>()
.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');

Expand Down