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();