diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts index 133abdd62..8975d3907 100644 --- a/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts +++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts @@ -252,6 +252,106 @@ describe('ChatCompletionDriver.complete auth and model resolution', () => { expect(passed.model).toBe('realfake'); expect(passed.provider).toBe('fake-chat'); }); + + // Catalogs are hand-written, so alias lists repeat themselves: an entry + // may list its own id, list one alias twice, or differ only by case. + // Routing must not depend on anyone having tidied that up. + it('routes correctly from a catalog whose aliases repeat the id and each other', async () => { + vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([ + { + id: 'messy', + // self-alias, a repeat, and a case variant of the id + aliases: ['messy', 'vendor/messy', 'vendor/messy', 'MESSY'], + puterId: 'puter-messy', + costs_currency: 'usd-cents', + costs: { 'input-tokens': 0, 'output-tokens': 0 }, + max_tokens: 8192, + }, + ] as never); + const d = await makeDriver(); + + const completeSpy = vi.spyOn(FakeChatProvider.prototype, 'complete'); + + // Every spelling reaches the same model, and the provider is always + // handed the canonical id. + for (const requested of [ + 'messy', + 'MESSY', + 'vendor/messy', + 'puter-messy', + ]) { + completeSpy.mockResolvedValueOnce({ + message: { + role: 'assistant', + content: [{ type: 'text', text: 'ok' }], + }, + usage: {}, + finish_reason: 'stop', + } as never); + + await withTestActor(() => + d.complete({ + model: requested, + messages: [{ role: 'user', content: 'hi' }], + }), + ); + + const call = completeSpy.mock.calls.at(-1)!; + const passed = call[0] as ICompleteArguments; + expect(passed.model, `requested '${requested}'`).toBe('messy'); + } + + // The repeats must not have split the model across buckets or + // registered a phantom extra route. + const listed = (await d.models()).filter((m) => m.id === 'messy'); + expect(listed).toHaveLength(1); + }); + + it('does not mutate the catalog objects a provider hands back', async () => { + // #buildModelMap used to normalize the id and append puterId in + // place. The catalogs are module-level constants shared by every + // driver instance, so that accumulated: build the map twice and the + // aliases array grew a duplicate puterId each time. + const catalog = [ + { + id: 'Shared-Case', + aliases: ['shared-alias'], + puterId: 'puter-shared', + costs_currency: 'usd-cents', + costs: { 'input-tokens': 0, 'output-tokens': 0 }, + max_tokens: 8192, + }, + ]; + const before = structuredClone(catalog); + + vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValue( + catalog as never, + ); + await makeDriver(); + await makeDriver(); + + expect(catalog).toEqual(before); + vi.mocked(FakeChatProvider.prototype.models).mockRestore(); + }); + + it('leaves aliases absent in models() for an entry that declares none', async () => { + // models() is serialized to the API, so the copy #buildModelMap + // stores must not sprout an `aliases: []` key the catalog entry + // never had. + vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([ + { + id: 'nameless', + costs_currency: 'usd-cents', + costs: { 'input-tokens': 0, 'output-tokens': 0 }, + max_tokens: 8192, + }, + ] as never); + const d = await makeDriver(); + + const listed = (await d.models()).find((m) => m.id === 'nameless')!; + expect(listed).toBeDefined(); + expect('aliases' in listed).toBe(false); + }); }); // ── Happy path: events + cost emission ────────────────────────────── diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts index 73b024dce..346aa2076 100644 --- a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts +++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts @@ -1316,20 +1316,40 @@ export class ChatCompletionDriver extends PuterDriver { for (const providerName in this.#providers) { const provider = this.#providers[providerName]; - for (const model of await provider.models()) { - model.id = normalizeModelKey(model.id); - if (model.puterId) { - model.aliases = model.aliases - ? [...model.aliases, model.puterId] - : [model.puterId]; - } + for (const entry of await provider.models()) { + // Catalogs are module-level constants shared by every driver + // instance, so they are read and never written: normalizing + // the id or appending puterId in place would accumulate across + // instantiations. The bucket gets its own copy instead. + const aliases = + entry.puterId && + !(entry.aliases ?? []).includes(entry.puterId) + ? [...(entry.aliases ?? []), entry.puterId] + : entry.aliases; + const model = { + ...entry, + id: normalizeModelKey(entry.id), + }; + // Assigned only when the entry has names to carry: models() + // is serialized to the API, and an entry that declared no + // aliases should not sprout an `aliases: []` key on the wire. + if (aliases) model.aliases = aliases; // Catalogs derive an alias by stripping the vendor org off the // id, which yields '' for ids that carry no org. Drop those — // an empty key would pool unrelated models together. - const keys = [model.id, ...(model.aliases ?? [])] - .map(normalizeModelKey) - .filter((key) => key.length > 0); + // + // Names may repeat: an entry is free to list its own id among + // its aliases, and normalizing can collapse two spellings onto + // one key. Deduplicate so a repeat can neither register a key + // twice nor make the bucket search consider it twice. + const keys = [ + ...new Set( + [model.id, ...(aliases ?? [])] + .map(normalizeModelKey) + .filter((key) => key.length > 0), + ), + ]; const bucket = keys diff --git a/src/backend/drivers/ai-chat/providers/alibaba/AlibabaProvider.ts b/src/backend/drivers/ai-chat/providers/alibaba/AlibabaProvider.ts index a825e36b1..0ab886afb 100644 --- a/src/backend/drivers/ai-chat/providers/alibaba/AlibabaProvider.ts +++ b/src/backend/drivers/ai-chat/providers/alibaba/AlibabaProvider.ts @@ -24,6 +24,7 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ import type { IChatProvider, ICompleteArguments } from '../../types.js'; import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { ALIBABA_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; type AlibabaConfig = { apiKey: string; @@ -54,15 +55,7 @@ export class AlibabaProvider implements IChatProvider { } async list() { - const models = this.models(); - const modelNames: string[] = []; - for (const model of models) { - modelNames.push(model.id); - if (model.aliases) { - modelNames.push(...model.aliases); - } - } - return modelNames; + return modelLookupNames(this.models()); } async complete({ diff --git a/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts b/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts index e25e5d212..811401869 100644 --- a/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts +++ b/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts @@ -34,6 +34,7 @@ import * as OpenAiUtil from '../../utils/OpenAIUtil.js'; import { buildCostsOverride } from '../../utils/pricing.js'; import { processPuterPathUploads } from '../openai/fileUpload.js'; import { AZURE_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; /** * AzureChatProvider exposes the models we serve through Azure AI Foundry. @@ -102,15 +103,7 @@ export class AzureChatProvider implements IChatProvider { } list() { - const models = this.models(); - const modelNames: string[] = []; - for (const model of models) { - modelNames.push(model.id); - if (model.aliases) { - modelNames.push(...model.aliases); - } - } - return modelNames; + return modelLookupNames(this.models()); } getDefaultModel() { diff --git a/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts b/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts index 8f2d5c060..d6d3c0f65 100644 --- a/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts +++ b/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts @@ -31,6 +31,7 @@ import { buildCostsOverride } from '../../utils/pricing.js'; import { processPuterPathUploads } from '../openai/fileUpload.js'; import { AZURE_MODELS } from './models.js'; import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; /** * AzureResponsesProvider serves the Responses-API-only models we expose through @@ -85,15 +86,7 @@ export class AzureResponsesProvider implements IChatProvider { } list() { - const models = this.models({ no_restrictions: false }); - const modelNames: string[] = []; - for (const model of models) { - modelNames.push(model.id); - if (model.aliases) { - modelNames.push(...model.aliases); - } - } - return modelNames; + return modelLookupNames(this.models({ no_restrictions: false })); } getDefaultModel() { diff --git a/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.ts b/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.ts index b28b64f4b..974fa8f4d 100644 --- a/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.ts +++ b/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.ts @@ -24,6 +24,7 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ import type { IChatProvider, ICompleteArguments } from '../../types.js'; import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { BYTEPLUS_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; type BytePlusConfig = { apiKey: string; @@ -76,14 +77,7 @@ export class BytePlusProvider implements IChatProvider { } list() { - const modelIds: string[] = []; - for (const model of this.models()) { - modelIds.push(model.id); - if (model.aliases) { - modelIds.push(...model.aliases); - } - } - return modelIds; + return modelLookupNames(this.models()); } async complete( diff --git a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts index 6e5ac5b51..285c86f76 100644 --- a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts @@ -253,6 +253,52 @@ describe('ClaudeProvider.complete request shape', () => { expect(args.max_tokens).toBe(0); }); + // With no explicit max_tokens the ceiling has to come from the entry being + // called. Deriving it from a second lookup by name-or-alias instead capped + // at 4096 every id the catalog doesn't also list among that entry's own + // aliases -- which is every dated id. + it.each(CLAUDE_MODELS.map((m) => ({ id: m.id, ceiling: m.max_tokens })))( + 'defaults max_tokens to the catalog ceiling for $id', + async ({ id, ceiling }) => { + const { provider } = makeProvider(); + messagesCreateMock.mockResolvedValueOnce(baseResponse); + + await withTestActor(() => + provider.complete({ + model: id, + messages: [{ role: 'user', content: 'hello' }], + }), + ); + + const [args] = messagesCreateMock.mock.calls[0]!; + expect(args.max_tokens).toBe(ceiling); + }, + ); + + // A name with no catalog entry is silently served by the default model, + // so the ceiling is that entry's own — not the 4096 floor the old second + // lookup fell back to. Unreachable through ChatCompletionDriver (which + // rejects unknown ids), but pinned here so the fallback's cost profile + // can't drift unnoticed for direct callers. + it('defaults max_tokens to the default model ceiling for an unknown name', async () => { + const { provider } = makeProvider(); + messagesCreateMock.mockResolvedValueOnce(baseResponse); + + await withTestActor(() => + provider.complete({ + model: 'claude-model-that-does-not-exist', + messages: [{ role: 'user', content: 'hello' }], + }), + ); + + const fallback = CLAUDE_MODELS.find( + (m) => m.id === provider.getDefaultModel(), + )!; + const [args] = messagesCreateMock.mock.calls[0]!; + expect(args.model).toBe(fallback.id); + expect(args.max_tokens).toBe(fallback.max_tokens); + }); + it('extracts system messages and forwards them as the top-level `system` field', async () => { const { provider } = makeProvider(); messagesCreateMock.mockResolvedValueOnce(baseResponse); diff --git a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts index bbba0873e..e1a2ada3b 100644 --- a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts +++ b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts @@ -47,6 +47,7 @@ import type { } from '../../utils/Streaming.js'; import { FILES_API_BETA, processPuterPathUploads } from './fileUpload.js'; import { CLAUDE_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; // Anthropic inline-compaction beta. The vendored SDK (0.68.0) doesn't type the // `compact_20260112` edit or the `compaction` content block, so the request @@ -86,15 +87,7 @@ export class ClaudeProvider implements IChatProvider { } async list() { - const models = this.models(); - const model_names: string[] = []; - for (const model of models) { - model_names.push(model.id); - if (model.aliases) { - model_names.push(...model.aliases); - } - } - return model_names; + return modelLookupNames(this.models()); } async complete({ @@ -318,16 +311,18 @@ export class ClaudeProvider implements IChatProvider { betas?: string[]; } = { model: modelUsed.id, + // The ceiling belongs to the entry actually being called, so it + // comes off `modelUsed` — already matched by id or alias — rather + // than a second lookup that repeats the matching and can disagree. + // The two 3.5 Sonnet ids predate the catalog and have no entry, so + // `modelUsed` is the default model for them and their ceiling has + // to be named outright. max_tokens: Math.floor( max_tokens ?? (model === 'claude-3-5-sonnet-20241022' || model === 'claude-3-5-sonnet-20240620' ? 8192 - : this.models().filter( - (e) => - (e as any).name === model || - e.aliases?.includes(model), - )[0]?.max_tokens || 4096), + : modelUsed.max_tokens || 4096), ), ...(resolvedTemperature !== undefined ? { temperature: resolvedTemperature } diff --git a/src/backend/drivers/ai-chat/providers/claude/models.ts b/src/backend/drivers/ai-chat/providers/claude/models.ts index c6e99bcdd..06a013c9c 100644 --- a/src/backend/drivers/ai-chat/providers/claude/models.ts +++ b/src/backend/drivers/ai-chat/providers/claude/models.ts @@ -32,7 +32,6 @@ export const CLAUDE_MODELS: IChatModel[] = [ 'claude-fable', 'claude-fable-latest', 'claude-fable-5-latest', - 'claude-fable-5', 'anthropic/claude-fable-5', ], name: 'Claude Fable 5', @@ -61,7 +60,6 @@ export const CLAUDE_MODELS: IChatModel[] = [ 'claude-sonnet', 'claude-sonnet-latest', 'claude-sonnet-5-latest', - 'claude-sonnet-5', 'anthropic/claude-sonnet-5', ], name: 'Claude Sonnet 5', @@ -91,7 +89,6 @@ export const CLAUDE_MODELS: IChatModel[] = [ 'claude-opus', 'claude-opus-latest', 'claude-opus-5-latest', - 'claude-opus-5', 'anthropic/claude-opus-5', ], name: 'Claude Opus 5', @@ -120,7 +117,6 @@ export const CLAUDE_MODELS: IChatModel[] = [ aliases: [ 'claude-opus-4-8-latest', 'claude-opus-4.8', - 'claude-opus-4-8', 'anthropic/claude-opus-4-8', ], name: 'Claude Opus 4.8', @@ -149,7 +145,6 @@ export const CLAUDE_MODELS: IChatModel[] = [ aliases: [ 'claude-opus-4-7-latest', 'claude-opus-4.7', - 'claude-opus-4-7', 'anthropic/claude-opus-4-7', ], name: 'Claude Opus 4.7', @@ -178,7 +173,6 @@ export const CLAUDE_MODELS: IChatModel[] = [ aliases: [ 'claude-sonnet-4-6-latest', 'claude-sonnet-4.6', - 'claude-sonnet-4-6', 'anthropic/claude-sonnet-4-6', ], name: 'Claude Sonnet 4.6', @@ -207,7 +201,6 @@ export const CLAUDE_MODELS: IChatModel[] = [ aliases: [ 'claude-opus-4-6-latest', 'claude-opus-4.6', - 'claude-opus-4-6', 'anthropic/claude-opus-4-6', ], name: 'Claude Opus 4.6', diff --git a/src/backend/drivers/ai-chat/providers/deepseek/DeepSeekProvider.ts b/src/backend/drivers/ai-chat/providers/deepseek/DeepSeekProvider.ts index 58b0320fe..a8faac7ae 100644 --- a/src/backend/drivers/ai-chat/providers/deepseek/DeepSeekProvider.ts +++ b/src/backend/drivers/ai-chat/providers/deepseek/DeepSeekProvider.ts @@ -25,6 +25,7 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ import type { IChatProvider, ICompleteArguments } from '../../types.js'; import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { DEEPSEEK_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; export class DeepSeekProvider implements IChatProvider { #openai: OpenAI; @@ -48,15 +49,7 @@ export class DeepSeekProvider implements IChatProvider { } async list() { - const models = this.models(); - const modelNames: string[] = []; - for (const model of models) { - modelNames.push(model.id); - if (model.aliases) { - modelNames.push(...model.aliases); - } - } - return modelNames; + return modelLookupNames(this.models()); } async complete({ diff --git a/src/backend/drivers/ai-chat/providers/deepseek/models.ts b/src/backend/drivers/ai-chat/providers/deepseek/models.ts index 307f72076..95f7cd2e6 100644 --- a/src/backend/drivers/ai-chat/providers/deepseek/models.ts +++ b/src/backend/drivers/ai-chat/providers/deepseek/models.ts @@ -31,11 +31,9 @@ export const DEEPSEEK_MODELS: IChatModel[] = [ release_date: '2026-04-24', name: 'DeepSeek Chat', aliases: [ - 'deepseek-v4-flash', 'deepseek/deepseek-v4-flash', 'deepseek-chat', 'deepseek/deepseek-chat', - 'deepseek/deepseek-v4-flash', 'deepseek/deepseek-reasoner', 'deepseek:deepseek/deepseek-reasoner', 'deepseek:deepseek/deepseek-chat', @@ -61,7 +59,7 @@ export const DEEPSEEK_MODELS: IChatModel[] = [ knowledge: '2026-04', release_date: '2026-04-24', name: 'DeepSeek Chat', - aliases: ['deepseek/deepseek-v4-pro', 'deepseek-v4-pro'], + aliases: ['deepseek/deepseek-v4-pro'], context: 1_000_000, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', diff --git a/src/backend/drivers/ai-chat/providers/gemini/GeminiChatProvider.test.ts b/src/backend/drivers/ai-chat/providers/gemini/GeminiChatProvider.test.ts index 7a06ddb62..2f8e8e829 100644 --- a/src/backend/drivers/ai-chat/providers/gemini/GeminiChatProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/gemini/GeminiChatProvider.test.ts @@ -171,6 +171,19 @@ describe('GeminiChatProvider model catalog', () => { expect(ids).toContain('gemini-2.5-flash'); expect(ids).toContain('google/gemini-2.5-flash'); }); + + // The assertion above is blind to duplicates: toContain passes just as + // happily on a doubled id, and a doubled id is not hypothetical here -- + // gemini-3.7-flash was once declared twice with two different cache + // prices. Catalog-wide uniqueness is enforced for every provider in + // providers/modelCatalogs.test.ts; this checks the other end, that the + // provider actually routes through the deduplicating helper rather than + // flattening the catalog itself. + it('list() emits every id exactly once', async () => { + const { provider } = makeProvider(); + const ids = await provider.list(); + expect(ids).toHaveLength(new Set(ids).size); + }); }); // ── Request shape ────────────────────────────────────────────────── diff --git a/src/backend/drivers/ai-chat/providers/gemini/GeminiChatProvider.ts b/src/backend/drivers/ai-chat/providers/gemini/GeminiChatProvider.ts index 069c66c09..c58a170dc 100644 --- a/src/backend/drivers/ai-chat/providers/gemini/GeminiChatProvider.ts +++ b/src/backend/drivers/ai-chat/providers/gemini/GeminiChatProvider.ts @@ -30,6 +30,7 @@ import { } from '../../utils/OpenAIUtil.js'; import { buildCostsOverride } from '../../utils/pricing.js'; import { GEMINI_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; export class GeminiChatProvider implements IChatProvider { meteringService: MeteringService; @@ -53,9 +54,7 @@ export class GeminiChatProvider implements IChatProvider { return GEMINI_MODELS; } async list() { - return (await this.models()) - .map((m) => [m.id, ...(m.aliases || [])]) - .flat(); + return modelLookupNames(await this.models()); } async complete({ diff --git a/src/backend/drivers/ai-chat/providers/gemini/models.ts b/src/backend/drivers/ai-chat/providers/gemini/models.ts index f1767e0be..259c6be63 100644 --- a/src/backend/drivers/ai-chat/providers/gemini/models.ts +++ b/src/backend/drivers/ai-chat/providers/gemini/models.ts @@ -112,7 +112,7 @@ export const GEMINI_MODELS: IChatModel[] = [ }, open_weights: false, tool_call: true, - knowledge: '2025-01', + knowledge: '2026-03', release_date: '2026-08-13', name: 'Gemini 3.7 Flash', aliases: ['google/gemini-3.7-flash'], @@ -126,7 +126,7 @@ export const GEMINI_MODELS: IChatModel[] = [ prompt_tokens: 75, completion_tokens: 375, thinking_tokens: 375, - cached_tokens: 8, + cached_tokens: 7.5, grounding_requests: 1_400_000, }, }, @@ -301,32 +301,4 @@ export const GEMINI_MODELS: IChatModel[] = [ }, max_tokens: 65536, }, - { - puterId: 'google:google/gemini-3.7-flash', - id: 'gemini-3.7-flash', - modalities: { - input: ['text', 'image', 'video', 'audio', 'pdf'], - output: ['text'], - }, - open_weights: false, - tool_call: true, - knowledge: '2026-03', - release_date: '2026-08-13', - name: 'Gemini 3.7 Flash', - aliases: ['google/gemini-3.7-flash'], - context: 1_048_576, - max_tokens: 65_536, - costs_currency: 'usd-cents', - input_cost_key: 'prompt_tokens', - output_cost_key: 'completion_tokens', - costs: { - tokens: 1_000_000, - prompt_tokens: 75, - completion_tokens: 375, - thinking_tokens: 375, - cached_tokens: 7.5, - // Gemini 3.x grounding is $14 / 1,000 requests - grounding_requests: 1_400_000, - }, - }, ]; diff --git a/src/backend/drivers/ai-chat/providers/groq/GroqAIProvider.ts b/src/backend/drivers/ai-chat/providers/groq/GroqAIProvider.ts index ffcb8ef5a..4710f6da6 100644 --- a/src/backend/drivers/ai-chat/providers/groq/GroqAIProvider.ts +++ b/src/backend/drivers/ai-chat/providers/groq/GroqAIProvider.ts @@ -25,6 +25,7 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ import type { IChatProvider, ICompleteArguments } from '../../types.js'; import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { GROQ_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; export class GroqAIProvider implements IChatProvider { #client: Groq; @@ -47,15 +48,7 @@ export class GroqAIProvider implements IChatProvider { } async list() { - const models = this.models(); - const modelNames: string[] = []; - for (const model of models) { - modelNames.push(model.id); - if (model.aliases) { - modelNames.push(...model.aliases); - } - } - return modelNames; + return modelLookupNames(this.models()); } async complete({ diff --git a/src/backend/drivers/ai-chat/providers/groq/models.ts b/src/backend/drivers/ai-chat/providers/groq/models.ts index 03b0449a2..2f06fc2bf 100644 --- a/src/backend/drivers/ai-chat/providers/groq/models.ts +++ b/src/backend/drivers/ai-chat/providers/groq/models.ts @@ -29,7 +29,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: true, release_date: '2024-07-23', name: 'Llama 3.1 8B Instant', - aliases: ['llama-3.1-8b-instant'], context: 131072, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -50,7 +49,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: true, release_date: '2024-12-06', name: 'Llama 3.3 70B Versatile', - aliases: ['llama-3.3-70b-versatile'], context: 131072, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -71,7 +69,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: true, release_date: '2025-08-05', name: 'GPT OSS 120B', - aliases: ['openai/gpt-oss-120b'], context: 131072, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -92,7 +89,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: true, release_date: '2025-08-05', name: 'GPT OSS 20B', - aliases: ['openai/gpt-oss-20b'], context: 131072, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -113,7 +109,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: true, release_date: '2025-10-29', name: 'GPT OSS Safeguard 20B', - aliases: ['openai/gpt-oss-safeguard-20b'], context: 131072, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -134,7 +129,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: false, release_date: '2025-09-04', name: 'Groq Compound', - aliases: ['groq/compound'], context: 131072, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -155,7 +149,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: false, release_date: '2025-09-04', name: 'Groq Compound Mini', - aliases: ['groq/compound-mini'], context: 131072, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -176,7 +169,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: true, release_date: '2026-04-22', name: 'Qwen3.6 27B', - aliases: ['qwen/qwen3.6-27b'], context: 131072, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -197,7 +189,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: false, release_date: '2025-05-29', name: 'Llama Prompt Guard 2 22M', - aliases: ['meta-llama/llama-prompt-guard-2-22m'], context: 512, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -218,7 +209,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: false, release_date: '2025-05-29', name: 'Llama Prompt Guard 2 86M', - aliases: ['meta-llama/llama-prompt-guard-2-86m'], context: 512, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -239,7 +229,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: false, release_date: '2025-01-23', name: 'ALLaM 2 7B', - aliases: ['allam-2-7b'], context: 4096, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -260,7 +249,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: false, release_date: '2025-04-05', name: 'Llama Guard 4 12B', - aliases: ['meta-llama/llama-guard-4-12b'], context: 131072, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', diff --git a/src/backend/drivers/ai-chat/providers/meta/MetaProvider.ts b/src/backend/drivers/ai-chat/providers/meta/MetaProvider.ts index 10639311d..eef6c3625 100644 --- a/src/backend/drivers/ai-chat/providers/meta/MetaProvider.ts +++ b/src/backend/drivers/ai-chat/providers/meta/MetaProvider.ts @@ -30,6 +30,7 @@ import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { buildCostsOverride } from '../../utils/pricing.js'; import { processPuterPathUploads } from '../openai/fileUpload.js'; import { META_MODELS, MUSE_SPARK_DEFAULT_MODEL } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; const DEFAULT_API_BASE_URL = 'https://api.meta.ai/v1'; @@ -98,14 +99,7 @@ export class MetaProvider implements IChatProvider { } list() { - const modelIds: string[] = []; - for (const model of this.models()) { - modelIds.push(model.id); - if (model.aliases) { - modelIds.push(...model.aliases); - } - } - return modelIds; + return modelLookupNames(this.models()); } async complete( diff --git a/src/backend/drivers/ai-chat/providers/minimax/MiniMaxProvider.ts b/src/backend/drivers/ai-chat/providers/minimax/MiniMaxProvider.ts index 13ed5ae58..bdf0871c8 100644 --- a/src/backend/drivers/ai-chat/providers/minimax/MiniMaxProvider.ts +++ b/src/backend/drivers/ai-chat/providers/minimax/MiniMaxProvider.ts @@ -24,6 +24,7 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ import type { IChatProvider, ICompleteArguments } from '../../types.js'; import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { MINIMAX_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; type MiniMaxConfig = { apiKey: string; @@ -54,14 +55,7 @@ export class MiniMaxProvider implements IChatProvider { } list() { - const modelIds: string[] = []; - for (const model of this.models()) { - modelIds.push(model.id); - if (model.aliases) { - modelIds.push(...model.aliases); - } - } - return modelIds; + return modelLookupNames(this.models()); } async complete({ diff --git a/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts b/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts index a742ffa44..19e7fb471 100644 --- a/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts +++ b/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts @@ -28,6 +28,7 @@ import type { } from '../../types.js'; import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { MISTRAL_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; export class MistralAIProvider implements IChatProvider { #client: Mistral; @@ -50,23 +51,15 @@ export class MistralAIProvider implements IChatProvider { } async list() { - const models = await this.models(); - const ids: string[] = []; - for (const model of models) { - ids.push(model.id); - if (model.aliases) { - ids.push(...model.aliases); - } - } - return ids; + return modelLookupNames(await this.models()); } /** - * Mistral's API expects `image_url` content parts to carry a plain - * string URL, not the OpenAI-style `{ url: string }` object. - * This method normalises any `{ type: 'image_url', image_url: { url } }` - * parts to `{ type: 'image_url', image_url: url }` before the request - * is sent. Messages whose `content` is a plain string are left untouched. + * Mistral's API expects `image_url` content parts to carry a plain string + * URL, not the OpenAI-style `{ url: string }` object. This method + * normalises any `{ type: 'image_url', image_url: { url } }` parts to `{ + * type: 'image_url', image_url: url }` before the request is sent. Messages + * whose `content` is a plain string are left untouched. */ #coerceImageUrls( messages: { role: string; content: unknown }[], diff --git a/src/backend/drivers/ai-chat/providers/mistral/models.ts b/src/backend/drivers/ai-chat/providers/mistral/models.ts index e8112d290..f1994b322 100644 --- a/src/backend/drivers/ai-chat/providers/mistral/models.ts +++ b/src/backend/drivers/ai-chat/providers/mistral/models.ts @@ -245,7 +245,6 @@ export const MISTRAL_MODELS: IChatModel[] = [ 'voxtral-small-latest', 'mistralai/voxtral-small-2507', 'mistralai/voxtral-small-latest', - 'voxtral-small-latest', ], context: 32_768, max_tokens: 32_768, diff --git a/src/backend/drivers/ai-chat/providers/modelCatalogs.test.ts b/src/backend/drivers/ai-chat/providers/modelCatalogs.test.ts new file mode 100644 index 000000000..6af8c1c46 --- /dev/null +++ b/src/backend/drivers/ai-chat/providers/modelCatalogs.test.ts @@ -0,0 +1,252 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Cross-provider invariants for the hardcoded model catalogs. + * + * Providers resolve a requested model with `models().find((m) => [m.id, + * ...m.aliases].includes(requested))` and build `list()` by flattening the same + * ids and aliases. Both go wrong quietly when one identifier is claimed by two + * entries: `.find()` returns whichever comes first, so the later entry is dead + * config that no request can ever reach, and `list()` advertises the model + * twice. Nothing throws, so the only symptom is wrong prices or wrong metadata + * being served from the entry that happened to win. + * + * This is easy to introduce and hard to spot in review — two branches adding + * the same model independently is enough, which is exactly how + * `gemini-3.7-flash` ended up in GEMINI_MODELS twice with two different cache + * prices. These tests are the cheap backstop for that class of mistake, so they + * live here once rather than being copy-pasted into every provider suite. + * + * Two entries claiming one identifier is a genuine defect and the first test + * below is the guard for it. The other two are hygiene: `modelLookupNames` + * deduplicates, so a repeated or self-referential alias can no longer change + * behaviour — it is just noise that reads as if it were load-bearing. Keeping + * the catalogs free of it is what lets the next reader trust that an alias + * exists because something needs it. + * + * Add new static catalogs to CATALOGS below — the last test in this file fails + * if one is missing, since a catalog nobody registered is a catalog none of + * this checks. (Its scan keys on filenames containing "model"; see the note + * on that test.) + */ + +import { readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import type { IChatModel } from '../types.js'; +import { ALIBABA_MODELS } from './alibaba/models.js'; +import { AZURE_MODELS } from './azure/models.js'; +import { BYTEPLUS_MODELS } from './byteplus/models.js'; +import { CLAUDE_MODELS } from './claude/models.js'; +import { DEEPSEEK_MODELS } from './deepseek/models.js'; +import { GEMINI_MODELS } from './gemini/models.js'; +import { GROQ_MODELS } from './groq/models.js'; +import { META_MODELS } from './meta/models.js'; +import { MINIMAX_MODELS } from './minimax/models.js'; +import { MISTRAL_MODELS } from './mistral/models.js'; +import { MOONSHOT_MODELS } from './moonshot/models.js'; +import { OPEN_AI_MODELS } from './openai/models.js'; +import { OPEN_ROUTER_MODEL_OVERRIDES } from './openrouter/modelOverrides.js'; +import { XAI_MODELS } from './xai/models.js'; +import { ZAI_MODELS } from './zai/models.js'; + +// Providers whose catalog is fetched at runtime (OpenRouter, Ollama, Together, +// Infron, Neuralwatt) have nothing static to check and are absent by design; +// OpenRouter's hardcoded *overrides* list is still covered. +const CATALOGS: [name: string, models: readonly IChatModel[]][] = [ + ['ALIBABA_MODELS', ALIBABA_MODELS], + ['AZURE_MODELS', AZURE_MODELS], + ['BYTEPLUS_MODELS', BYTEPLUS_MODELS], + ['CLAUDE_MODELS', CLAUDE_MODELS], + ['DEEPSEEK_MODELS', DEEPSEEK_MODELS], + ['GEMINI_MODELS', GEMINI_MODELS], + ['GROQ_MODELS', GROQ_MODELS], + ['META_MODELS', META_MODELS], + ['MINIMAX_MODELS', MINIMAX_MODELS], + ['MISTRAL_MODELS', MISTRAL_MODELS], + ['MOONSHOT_MODELS', MOONSHOT_MODELS], + ['OPEN_AI_MODELS', OPEN_AI_MODELS], + ['OPEN_ROUTER_MODEL_OVERRIDES', OPEN_ROUTER_MODEL_OVERRIDES], + ['XAI_MODELS', XAI_MODELS], + ['ZAI_MODELS', ZAI_MODELS], +]; + +// A label for the entry an identifier came from, good enough to grep for in a +// failure message even when the duplicated field *is* the id. +const describeEntry = (m: IChatModel, index: number) => + `#${index} (${m.name ?? m.id ?? 'unnamed'})`; + +describe.each(CATALOGS)('%s', (_name, models) => { + it('is not empty', () => { + // Guards the tests below from passing vacuously if an import breaks. + expect(models.length).toBeGreaterThan(0); + }); + + it('never lets two entries claim the same id, puterId, or alias', () => { + // Owner of each identifier seen so far, so a collision can name both + // sides rather than just saying "duplicate found". + const owners = new Map(); + const collisions: string[] = []; + + models.forEach((m, index) => { + const here = describeEntry(m, index); + const claimed: [field: string, value: string | undefined][] = [ + ['id', m.id], + ['puterId', m.puterId], + ...(m.aliases ?? []).map( + (a) => ['alias', a] as [string, string], + ), + ]; + + // Compare against *other* entries only. An entry repeating a + // name against itself is caught by the two tests below, which + // name the exact shape instead of saying "already claimed" — + // except for an id equal to its own puterId, which no test covers + // because it registers one key either way and so costs nothing. + const seenHere = new Set(); + for (const [field, value] of claimed) { + if (value === undefined) continue; + if (seenHere.has(value)) continue; + seenHere.add(value); + + const owner = owners.get(value); + if (owner !== undefined) { + collisions.push( + `'${value}' (${field}) is already claimed by entry ${owner}`, + ); + } else { + owners.set(value, here); + } + } + }); + + expect(collisions, collisions.join('\n')).toEqual([]); + }); + + it('never repeats an alias within a single entry', () => { + // A string listed twice in one aliases array is always a slip: it + // changes nothing about resolution and just doubles the model in + // list(). + const repeats: string[] = []; + + models.forEach((m, index) => { + const seen = new Set(); + for (const alias of m.aliases ?? []) { + if (seen.has(alias)) { + repeats.push( + `entry ${describeEntry(m, index)} lists '${alias}' more than once`, + ); + } + seen.add(alias); + } + }); + + expect(repeats, repeats.join('\n')).toEqual([]); + }); + + it('never re-declares its own id or puterId as an alias', () => { + // Resolution matches m.id before it ever looks at the aliases, and + // the driver appends puterId to an entry's lookup names on its own, + // so either self-alias buys nothing. It reads as though the bare name + // would stop working without it, which is the actual cost: every + // later reader has to re-derive that it is inert. + // + // flatMap rather than filter().map(): the index has to be the entry's + // position in the catalog, which a filtered array no longer knows. + const selfDeclared = models.flatMap((m, index) => { + const aliases = m.aliases ?? []; + const fields = [ + ...(aliases.includes(m.id) ? ['id'] : []), + ...(m.puterId && aliases.includes(m.puterId) + ? ['puterId'] + : []), + ]; + return fields.map( + (field) => + `${describeEntry(m, index)} aliases its own ${field}`, + ); + }); + + expect(selfDeclared, selfDeclared.join('\n')).toEqual([]); + }); +}); + +// -- Registration ---------------------------------------------------- + +describe('CATALOGS', () => { + // Everything above is opt-in: a provider added tomorrow gets none of it + // until someone remembers to list its catalog. That is the same kind of + // silent gap these tests are about, so the list is checked against what + // is actually on disk. + // + // The scan covers every non-test source file under providers/*/ with + // "model" in its name — models.ts, but also siblings like openrouter's + // modelOverrides.ts. Importing every provider file regardless of name + // would drag in SDK modules for a filename sweep, so that naming + // convention is the one assumption left unenforced here: a catalog in a + // file named without "model" would escape this net. + it('lists every static catalog under providers/', async () => { + const here = dirname(fileURLToPath(import.meta.url)); + const registered = new Map(CATALOGS); + const problems: string[] = []; + + for (const dir of readdirSync(here, { withFileTypes: true })) { + if (!dir.isDirectory()) continue; + + for (const file of readdirSync(join(here, dir.name))) { + if (!/model/i.test(file)) continue; + if (!file.endsWith('.ts') || file.endsWith('.test.ts')) { + continue; + } + + const module = await import( + pathToFileURL(join(here, dir.name, file)).href + ); + for (const [name, value] of Object.entries(module)) { + // A catalog is a non-empty array of entries carrying an + // id; these files also export default-model ids and + // helpers. + const isCatalog = + Array.isArray(value) && + value.length > 0 && + typeof value[0]?.id === 'string'; + if (!isCatalog) continue; + + if (!registered.has(name)) { + problems.push(`${dir.name}/${file} exports ${name}`); + } else if (registered.get(name) !== value) { + // The row's label names this export but its value is + // a different array — the wrong catalog would be the + // one getting checked. + problems.push( + `the CATALOGS row named ${name} does not hold ` + + `the ${name} that ${dir.name}/${file} exports`, + ); + } + } + } + } + + expect(problems, problems.join('\n')).toEqual([]); + }); +}); diff --git a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts index df886c1c0..111191db1 100644 --- a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts +++ b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts @@ -29,6 +29,7 @@ import type { import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { inlineHttpImageUrls } from './imageHandling.js'; import { MOONSHOT_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; export class MoonshotProvider implements IChatProvider { #openai: OpenAI; @@ -52,15 +53,7 @@ export class MoonshotProvider implements IChatProvider { } async list() { - const models = this.models(); - const modelNames: string[] = []; - for (const model of models) { - modelNames.push(model.id); - if (model.aliases) { - modelNames.push(...model.aliases); - } - } - return modelNames; + return modelLookupNames(this.models()); } async complete({ diff --git a/src/backend/drivers/ai-chat/providers/neuralwatt/NeuralwattProvider.ts b/src/backend/drivers/ai-chat/providers/neuralwatt/NeuralwattProvider.ts index 589f500be..c06c73319 100644 --- a/src/backend/drivers/ai-chat/providers/neuralwatt/NeuralwattProvider.ts +++ b/src/backend/drivers/ai-chat/providers/neuralwatt/NeuralwattProvider.ts @@ -32,6 +32,7 @@ import type { ICompleteArguments, } from '../../types.js'; import { inlineHttpImageUrls } from '../moonshot/imageHandling.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; import { mapNeuralwattApiModel, messagesHaveImageContent, @@ -84,13 +85,7 @@ export class NeuralwattProvider implements IChatProvider { } async list() { - const models = await this.models(); - const modelNames: string[] = []; - for (const model of models) { - modelNames.push(model.id); - if (model.aliases) modelNames.push(...model.aliases); - } - return modelNames; + return modelLookupNames(await this.models()); } async models(): Promise { diff --git a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts index 8f97012f4..315042e80 100644 --- a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts +++ b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts @@ -35,6 +35,7 @@ import { buildCostsOverride } from '../../utils/pricing.js'; import { processPuterPathUploads } from './fileUpload.js'; import { OPEN_AI_MODELS } from './models.js'; import type { OpenAiResponsesChatProvider } from './OpenAiChatResponsesProvider.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; /** * OpenAICompletionService class provides an interface to OpenAI's chat @@ -87,15 +88,7 @@ export class OpenAiChatProvider implements IChatProvider { } list() { - const models = this.models(); - const modelNames: string[] = []; - for (const model of models) { - modelNames.push(model.id); - if (model.aliases) { - modelNames.push(...model.aliases); - } - } - return modelNames; + return modelLookupNames(this.models()); } getDefaultModel() { diff --git a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts index a353b36f7..7df7910bf 100644 --- a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts +++ b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts @@ -31,6 +31,7 @@ import { buildCostsOverride } from '../../utils/pricing.js'; import { processPuterPathUploads } from './fileUpload.js'; import { OPEN_AI_MODELS } from './models.js'; import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; /** * OpenAICompletionService class provides an interface to OpenAI's chat @@ -77,15 +78,7 @@ export class OpenAiResponsesChatProvider implements IChatProvider { } list() { - const models = this.models({ no_restrictions: false }); - const modelNames: string[] = []; - for (const model of models) { - modelNames.push(model.id); - if (model.aliases) { - modelNames.push(...model.aliases); - } - } - return modelNames; + return modelLookupNames(this.models({ no_restrictions: false })); } getDefaultModel() { diff --git a/src/backend/drivers/ai-chat/providers/openai/models.ts b/src/backend/drivers/ai-chat/providers/openai/models.ts index d44658763..026b5d6be 100644 --- a/src/backend/drivers/ai-chat/providers/openai/models.ts +++ b/src/backend/drivers/ai-chat/providers/openai/models.ts @@ -30,12 +30,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ open_weights: false, tool_call: true, knowledge: '2026-02-16', - aliases: [ - 'gpt-5.6', - 'gpt-5.6-sol', - 'openai/gpt-5.6', - 'openai/gpt-5.6-sol', - ], + aliases: ['gpt-5.6', 'openai/gpt-5.6', 'openai/gpt-5.6-sol'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -56,7 +51,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ open_weights: false, tool_call: true, knowledge: '2026-02-16', - aliases: ['gpt-5.6-terra', 'openai/gpt-5.6-terra'], + aliases: ['openai/gpt-5.6-terra'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -77,7 +72,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ open_weights: false, tool_call: true, knowledge: '2026-02-16', - aliases: ['gpt-5.6-luna', 'openai/gpt-5.6-luna'], + aliases: ['openai/gpt-5.6-luna'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -163,7 +158,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ tool_call: true, knowledge: '2025-08-31', release_date: '2026-03-05', - aliases: ['gpt-5.4-pro', 'openai/gpt-5.4-pro'], + aliases: ['openai/gpt-5.4-pro'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -183,7 +178,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ open_weights: false, tool_call: true, knowledge: '2025-08-31', - aliases: ['gpt-5.4-mini', 'openai/gpt-5.4-mini'], + aliases: ['openai/gpt-5.4-mini'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -205,7 +200,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ tool_call: true, knowledge: '2025-08-31', release_date: '2026-03-19', - aliases: ['gpt-5.4-nano', 'openai/gpt-5.4-nano'], + aliases: ['openai/gpt-5.4-nano'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -226,7 +221,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ tool_call: true, knowledge: '2025-10', release_date: '2025-10-06', - aliases: ['gpt-5-pro', 'openai/gpt-5-pro'], + aliases: ['openai/gpt-5-pro'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', diff --git a/src/backend/drivers/ai-chat/providers/together/TogetherAIProvider.ts b/src/backend/drivers/ai-chat/providers/together/TogetherAIProvider.ts index 925029a3d..8558a08ab 100644 --- a/src/backend/drivers/ai-chat/providers/together/TogetherAIProvider.ts +++ b/src/backend/drivers/ai-chat/providers/together/TogetherAIProvider.ts @@ -23,6 +23,7 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ import { kv } from '../../../../util/kvSingleton.js'; import { IChatModel, IChatProvider, ICompleteArguments } from '../../types.js'; import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; const TOGETHER_AI_CHAT_COST_MAP = { prompt_tokens: 'input', @@ -131,15 +132,7 @@ export class TogetherAIProvider implements IChatProvider { } async list() { - const models = await this.models(); - const modelIds: string[] = []; - for (const model of models) { - modelIds.push(model.id); - if (model.aliases) { - modelIds.push(...model.aliases); - } - } - return modelIds; + return modelLookupNames(await this.models()); } async complete({ diff --git a/src/backend/drivers/ai-chat/providers/xai/XAIProvider.ts b/src/backend/drivers/ai-chat/providers/xai/XAIProvider.ts index 6c0815349..7a634fd48 100644 --- a/src/backend/drivers/ai-chat/providers/xai/XAIProvider.ts +++ b/src/backend/drivers/ai-chat/providers/xai/XAIProvider.ts @@ -28,6 +28,7 @@ import type { IChatCompleteResult, } from '../../types.js'; import { XAI_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; export class XAIProvider implements IChatProvider { #openai: OpenAI; @@ -51,15 +52,7 @@ export class XAIProvider implements IChatProvider { } async list() { - const models = this.models(); - const modelNames: string[] = []; - for (const model of models) { - modelNames.push(model.id); - if (model.aliases) { - modelNames.push(...model.aliases); - } - } - return modelNames; + return modelLookupNames(this.models()); } async complete({ diff --git a/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.ts b/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.ts index 714c38bbf..9d983d792 100644 --- a/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.ts +++ b/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.ts @@ -24,6 +24,7 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ import type { IChatProvider, ICompleteArguments } from '../../types.js'; import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { ZAI_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; type ZAIConfig = { apiBaseUrl?: string; @@ -72,14 +73,7 @@ export class ZAIProvider implements IChatProvider { } list() { - const modelIds: string[] = []; - for (const model of this.models()) { - modelIds.push(model.id); - if (model.aliases) { - modelIds.push(...model.aliases); - } - } - return modelIds; + return modelLookupNames(this.models()); } async complete( diff --git a/src/backend/drivers/ai-chat/utils/modelRouting.test.ts b/src/backend/drivers/ai-chat/utils/modelRouting.test.ts index 018bcc000..c6a0e6b02 100644 --- a/src/backend/drivers/ai-chat/utils/modelRouting.test.ts +++ b/src/backend/drivers/ai-chat/utils/modelRouting.test.ts @@ -24,12 +24,12 @@ import type { IChatModel } from '../types.js'; import { compareModelPreference, isIdentityKey, + modelLookupNames, normalizeModelKey, } from './modelRouting.js'; -// `#buildModelMap` mutates the catalogs providers hand back, and // `GeminiChatProvider.models()` returns the module-level `GEMINI_MODELS` by -// reference — clone so these fixtures can't be perturbed by another suite. +// reference — clone so these fixtures stay independent of it. const geminiModel = (id: string, provider = 'gemini'): IChatModel => { const found = GEMINI_MODELS.find((m) => m.id === id); if (!found) throw new Error(`no such gemini model: ${id}`); @@ -186,3 +186,45 @@ describe('isIdentityKey', () => { expect(isIdentityKey('')).toBe(false); }); }); + +describe('modelLookupNames', () => { + const m = (id: string, aliases?: string[]) => + ({ id, ...(aliases ? { aliases } : {}) }) as IChatModel; + + it('returns the id even when the entry declares no aliases', () => { + expect(modelLookupNames([m('solo')])).toEqual(['solo']); + }); + + it('keeps declaration order, id first', () => { + expect(modelLookupNames([m('a', ['vendor/a', 'a-latest'])])).toEqual([ + 'a', + 'vendor/a', + 'a-latest', + ]); + }); + + // The three shapes this helper exists to absorb, so no caller has to. + it('collapses an alias that merely repeats the entry id', () => { + expect(modelLookupNames([m('a', ['a', 'vendor/a'])])).toEqual([ + 'a', + 'vendor/a', + ]); + }); + + it('collapses an alias repeated within one entry', () => { + expect(modelLookupNames([m('a', ['x', 'x'])])).toEqual(['a', 'x']); + }); + + it('collapses a name two entries both claim', () => { + expect( + modelLookupNames([m('a', ['shared']), m('b', ['shared'])]), + ).toEqual(['a', 'shared', 'b']); + }); + + it('is unchanged by stripping self-aliases from a catalog', () => { + // The property that makes removing them from the catalogs a no-op. + const withSelf = [m('a', ['a', 'vendor/a']), m('b', ['b'])]; + const without = [m('a', ['vendor/a']), m('b')]; + expect(modelLookupNames(withSelf)).toEqual(modelLookupNames(without)); + }); +}); diff --git a/src/backend/drivers/ai-chat/utils/modelRouting.ts b/src/backend/drivers/ai-chat/utils/modelRouting.ts index a8bb849f9..990e184cb 100644 --- a/src/backend/drivers/ai-chat/utils/modelRouting.ts +++ b/src/backend/drivers/ai-chat/utils/modelRouting.ts @@ -48,6 +48,22 @@ const providerRank = (provider?: string): number => { export const normalizeModelKey = (key: string): string => key.trim().toLowerCase(); +/** + * Every name a model answers to, in declaration order and without repeats. + * + * A catalog entry's `id` is already one of its names, so an `aliases` array + * that also lists the id is redundant rather than wrong -- and catalogs do + * that, because alias lists get written as "every spelling a caller might type" + * and the id is one of those spellings. Deduplicating here means the flattened + * list stays honest no matter how the catalog is written, instead of every + * caller having to reason about it. + */ +export const modelLookupNames = ( + models: readonly Pick[], +): string[] => [ + ...new Set(models.flatMap((m) => [m.id, ...(m.aliases ?? [])])), +]; + /** * Whether a key asserts _which model this is_, rather than merely being another * way to name it. diff --git a/src/backend/drivers/ai-image/ImageGenerationDriver.test.ts b/src/backend/drivers/ai-image/ImageGenerationDriver.test.ts index bb03971e6..daa669a1f 100644 --- a/src/backend/drivers/ai-image/ImageGenerationDriver.test.ts +++ b/src/backend/drivers/ai-image/ImageGenerationDriver.test.ts @@ -44,7 +44,11 @@ import { runWithContext } from '../../core/context.js'; import { SYSTEM_ACTOR } from '../../core/actor.js'; import { PuterServer } from '../../server.js'; import { setupTestServer } from '../../testUtil.js'; +import { CLOUDFLARE_IMAGE_GENERATION_MODELS } from './providers/cloudflare/models.js'; +import { GEMINI_IMAGE_GENERATION_MODELS } from './providers/gemini/models.js'; import { OPEN_AI_IMAGE_GENERATION_MODELS } from './providers/openai/models.js'; +import { REPLICATE_IMAGE_GENERATION_MODELS } from './providers/replicate/models.js'; +import { TOGETHER_IMAGE_GENERATION_MODELS } from './providers/together/models.js'; import { XAI_IMAGE_GENERATION_MODELS } from './providers/xai/models.js'; import type { ImageGenerationDriver } from './ImageGenerationDriver.js'; @@ -220,7 +224,32 @@ describe('ImageGenerationDriver.generate argument validation', () => { // ── Catalog & list ────────────────────────────────────────────────── +// Providers hand these catalogs to the driver by module-level reference, so +// #buildModelMap must never write through to them: an in-place id +// normalization or puterId append would accumulate across map builds. Cloned +// at import time, before beforeAll boots the server that builds the map. +// (Same regression as in ChatCompletionDriver.test.ts.) +const pristineCatalogs = structuredClone({ + CLOUDFLARE_IMAGE_GENERATION_MODELS, + GEMINI_IMAGE_GENERATION_MODELS, + OPEN_AI_IMAGE_GENERATION_MODELS, + REPLICATE_IMAGE_GENERATION_MODELS, + TOGETHER_IMAGE_GENERATION_MODELS, + XAI_IMAGE_GENERATION_MODELS, +}); + describe('ImageGenerationDriver model catalog', () => { + it('does not mutate the catalog objects providers hand back', () => { + expect({ + CLOUDFLARE_IMAGE_GENERATION_MODELS, + GEMINI_IMAGE_GENERATION_MODELS, + OPEN_AI_IMAGE_GENERATION_MODELS, + REPLICATE_IMAGE_GENERATION_MODELS, + TOGETHER_IMAGE_GENERATION_MODELS, + XAI_IMAGE_GENERATION_MODELS, + }).toEqual(pristineCatalogs); + }); + it('models() returns a deduped list across providers, sorted by provider then id', async () => { const all = await driver.models(); // Every catalog id from at least one provider must be reachable. @@ -642,7 +671,3 @@ describe('ImageGenerationDriver.generate puter_output_path', () => { expect(openaiImagesGenerateMock).not.toHaveBeenCalled(); }); }); - -// Avoid coupling the 'unused' XAI export to lint. The catalog reference -// is also used implicitly by the routing tests above. -void XAI_IMAGE_GENERATION_MODELS; diff --git a/src/backend/drivers/ai-image/ImageGenerationDriver.ts b/src/backend/drivers/ai-image/ImageGenerationDriver.ts index 6628b8ba3..8ab5c1e0f 100644 --- a/src/backend/drivers/ai-image/ImageGenerationDriver.ts +++ b/src/backend/drivers/ai-image/ImageGenerationDriver.ts @@ -361,7 +361,12 @@ export class ImageGenerationDriver extends PuterDriver { async #buildModelMap() { for (const providerName in this.#providers) { const provider = this.#providers[providerName]; - for (const model of await provider.models()) { + for (const entry of await provider.models()) { + // Catalogs are module-level constants that providers hand + // back by reference, so they are read and never written: + // normalizing the id or appending puterId in place would + // accumulate across map builds. Work on a copy instead. + const model = { ...entry }; model.id = model.id.trim().toLowerCase(); if (!this.#modelIdMap[model.id]) { this.#modelIdMap[model.id] = []; diff --git a/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts b/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts index 3c723a08e..c71e1f6e9 100644 --- a/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts +++ b/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts @@ -45,6 +45,9 @@ import { SYSTEM_ACTOR } from '../../core/actor.js'; import { PuterServer } from '../../server.js'; import type { MeteringService } from '../../services/metering/MeteringService.js'; import { setupTestServer } from '../../testUtil.js'; +import { GEMINI_VIDEO_GENERATION_MODELS } from './providers/gemini/models.js'; +import { OPENAI_VIDEO_MODELS } from './providers/openai/models.js'; +import { TOGETHER_VIDEO_GENERATION_MODELS } from './providers/together/models.js'; import type { VideoGenerationDriver } from './VideoGenerationDriver.js'; // ── SDK mocks ────────────────────────────────────────────────────── @@ -214,7 +217,27 @@ describe('VideoGenerationDriver.generate argument validation', () => { // ── Catalog & list ────────────────────────────────────────────────── +// Providers hand these catalogs to the driver by module-level reference +// (OpenAI's directly; Gemini's and Together's via per-call copies), so +// #buildModelMap must never write through to them: an in-place id +// normalization or puterId append would accumulate across map builds. Cloned +// at import time, before beforeAll boots the server that builds the map. +// (Same regression as in ChatCompletionDriver.test.ts.) +const pristineCatalogs = structuredClone({ + GEMINI_VIDEO_GENERATION_MODELS, + OPENAI_VIDEO_MODELS, + TOGETHER_VIDEO_GENERATION_MODELS, +}); + describe('VideoGenerationDriver catalog', () => { + it('does not mutate the catalog objects providers hand back', () => { + expect({ + GEMINI_VIDEO_GENERATION_MODELS, + OPENAI_VIDEO_MODELS, + TOGETHER_VIDEO_GENERATION_MODELS, + }).toEqual(pristineCatalogs); + }); + it('models() returns deduped entries sorted by provider then id', async () => { const all = await driver.models(); const ids = all.map((m) => m.id); diff --git a/src/backend/drivers/ai-video/VideoGenerationDriver.ts b/src/backend/drivers/ai-video/VideoGenerationDriver.ts index 58906717f..e48c2a203 100644 --- a/src/backend/drivers/ai-video/VideoGenerationDriver.ts +++ b/src/backend/drivers/ai-video/VideoGenerationDriver.ts @@ -328,7 +328,13 @@ export class VideoGenerationDriver extends PuterDriver { async #buildModelMap() { for (const providerName in this.#providers) { const provider = this.#providers[providerName]; - for (const model of await provider.models()) { + for (const entry of await provider.models()) { + // Catalogs are module-level constants that providers hand + // back by reference, so they are read and never written: + // normalizing fields or appending puterId in place would + // accumulate across map builds. Work on a copy instead — + // every alias write below lands on an array created here. + const model = { ...entry }; model.id = model.id.trim().toLowerCase(); if (model.puterId) { model.puterId = model.puterId.trim().toLowerCase();