mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-27 08:27:43 +00:00
Merge pull request #3630 from HeyPuter/FK/dedupe
refactor: dedupe model catalog aliases and stop mutating shared catalogs
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user