Merge pull request #3630 from HeyPuter/FK/dedupe

refactor: dedupe model catalog aliases and stop mutating shared catalogs
This commit is contained in:
404oops
2026-08-25 18:07:27 +02:00
committed by GitHub
35 changed files with 624 additions and 243 deletions
@@ -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 ──────────────────────────────
@@ -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
@@ -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({
@@ -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() {
@@ -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() {
@@ -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(
@@ -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);
@@ -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 }
@@ -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',
@@ -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({
@@ -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',
@@ -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 ──────────────────────────────────────────────────
@@ -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({
@@ -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,
},
},
];
@@ -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({
@@ -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',
@@ -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(
@@ -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({
@@ -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 }[],
@@ -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,
@@ -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 <https://www.gnu.org/licenses/>.
*/
/**
* 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<string, string>();
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<string>();
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<string>();
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([]);
});
});
@@ -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({
@@ -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<IChatModel[]> {
@@ -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() {
@@ -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() {
@@ -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',
@@ -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({
@@ -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({
@@ -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(
@@ -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));
});
});
@@ -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<IChatModel, 'id' | 'aliases'>[],
): 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.
@@ -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;
@@ -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] = [];
@@ -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);
@@ -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();