Files
cc-switch/tests/lib/modelMetadata.test.ts
T
Jason bada9a4bb9 fix(models): don't borrow a vendor-hosted entry that points at another vendor
When a relay's model is not recognized by address, the metadata lookup
falls back to the vendor's entry: first the entry that same-name entries
point at via canonical_model_id, and only if none resolves, an entry
listed under a provider that some canonical id points at.

That last step only checked whether the provider counts as a vendor, not
whether the entry claims to be its own. models.dev has no
deepseek/deepseek-v4.1-flash-1017, so the ~40 entries pointing there all
miss, and thinkingmachines (counted as a vendor for its own models) won
with its LoRA fine-tuning entry DeepSeek-V4.1-Flash-1017:peft:262144,
whose canonical id points back at deepseek. It is text-only with a 262144
window, so picking the model filled every app with text-only input and a
quarter of the real window; Codex additionally turned image input off.

Skip entries whose canonical id names another vendor in that fallback.
Against the current models.dev data, 27 ids stop borrowing from hosts of
other vendors' models (thinkingmachines, NVIDIA, vivgrid, alibaba) and
leave the fields empty instead, and laguna-xs-2.1 now resolves to
poolside's own entry rather than being ambiguous. Skipping ':' variants
instead was rejected: it fixes this id but newly resolves nine
thinkingmachines entries carrying 65K training windows (gpt-oss-120b and
others).

Refs #8765, #8682
2026-10-23 20:11:53 +08:00

490 lines
14 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
GPT_CONTEXT_WINDOW_CAP,
resolveModelMetadata,
type KnownModelMetadata,
type PresetModelSource,
} from "@/lib/modelMetadata";
import type { ModelsDevResponse } from "@/lib/modelsDev";
const modelsDev: ModelsDevResponse = {
zhipuai: {
id: "zhipuai",
api: "https://open.bigmodel.cn/api/paas/v4",
models: {
"glm-5": {
id: "glm-5",
limit: { context: 204800, output: 131072 },
reasoning: true,
reasoning_options: [
{ type: "toggle" },
{ type: "effort", values: ["high"] },
],
modalities: { input: ["text"], output: ["text"] },
cost: { input: 1, output: 3.2 },
},
},
},
zai: {
id: "zai",
api: "https://api.z.ai/api/paas/v4",
models: {
"glm-5": { id: "glm-5", limit: { context: 200000, output: 128000 } },
},
},
"zai-coding-plan": {
id: "zai-coding-plan",
api: "https://api.z.ai/api/coding/paas/v4",
models: {
"glm-5": {
id: "glm-5",
limit: { context: 202752, output: 98304 },
cost: { input: 0, output: 0 },
},
},
},
"opencode-go": {
id: "opencode-go",
api: "https://opencode.ai/zen/go/v1",
models: {
"glm-5.3": {
id: "glm-5.3",
limit: { context: 1000000, output: 131072 },
reasoning_options: [{ type: "effort", values: ["low", "high", "max"] }],
},
},
},
openrouter: {
id: "openrouter",
api: "https://openrouter.ai/api/v1",
models: {
"z-ai/glm-5": {
id: "z-ai/glm-5",
limit: { context: 202800 },
canonical_model_id: "zhipuai/glm-5",
},
"someone/kimi-k9": {
id: "someone/kimi-k9",
limit: { context: 65536 },
canonical_model_id: "moonshotai/kimi-k9",
},
"minimax/minimax-m9": {
id: "minimax/minimax-m9",
canonical_model_id: "minimax/MiniMax-M9",
},
"nvidia/nemotron-9": {
id: "nvidia/nemotron-9",
canonical_model_id: "nvidia/nemotron-9",
},
},
},
minimax: {
id: "minimax",
models: {
"MiniMax-M9": { id: "MiniMax-M9", limit: { context: 204800 } },
},
},
// 本身是原厂(有条目指向它),也托管别家的模型,且这些条目不指向原厂。
nvidia: {
id: "nvidia",
models: {
"nemotron-9": { id: "nemotron-9" },
"minimaxai/minimax-m9": {
id: "minimaxai/minimax-m9",
limit: { context: 196608 },
},
},
},
moonshotai: {
id: "moonshotai",
api: "https://api.moonshot.ai/v1",
models: {
"kimi-k9": { id: "kimi-k9", limit: { context: 262144 } },
},
},
other: {
id: "other",
models: {
"kimi-k9": {
id: "kimi-k9",
limit: { context: 100000 },
canonical_model_id: "elsewhere/kimi-k9",
},
},
},
elsewhere: {
id: "elsewhere",
models: { "kimi-k9": { id: "kimi-k9", limit: { context: 50000 } } },
},
// 原厂名下没有 deepseek-v9-flash-1017,各家条目都指向它却落空。
deepseek: {
id: "deepseek",
models: { "deepseek-flash": { id: "deepseek-flash" } },
},
"ds-relay": {
id: "ds-relay",
models: {
"deepseek-v9-flash-1017": {
id: "deepseek-v9-flash-1017",
limit: { context: 1000000 },
modalities: { input: ["text", "image"], output: ["text"] },
canonical_model_id: "deepseek/deepseek-v9-flash-1017",
},
"tinker/inkling-9": {
id: "tinker/inkling-9",
canonical_model_id: "tinker/inkling-9",
},
},
},
// 原厂(有条目指向它),也托管别家的微调条目,这些条目指回别家原厂。
tinker: {
id: "tinker",
models: {
"inkling-9": { id: "inkling-9" },
"deepseek-ai/DeepSeek-V9-Flash-1017:peft:262144": {
id: "deepseek-ai/DeepSeek-V9-Flash-1017:peft:262144",
limit: { context: 262144 },
modalities: { input: ["text"], output: ["text"] },
canonical_model_id: "deepseek/deepseek-v9-flash-1017",
},
"poolside/Laguna-9": {
id: "poolside/Laguna-9",
limit: { context: 65536 },
canonical_model_id: "poolside/laguna-9",
},
},
},
// 原厂自己的条目指向自己,但 ID 写法和指向的不一致,指向也落空。
poolside: {
id: "poolside",
models: {
"poolside/laguna-9": {
id: "poolside/laguna-9",
limit: { context: 262144 },
canonical_model_id: "poolside/laguna-9",
},
},
},
};
const preset = (
baseUrl: string,
models: Record<string, KnownModelMetadata>,
): PresetModelSource => ({ baseUrl, models: new Map(Object.entries(models)) });
describe("resolveModelMetadata", () => {
it("uses the provider recognized by its API address", () => {
expect(
resolveModelMetadata("glm-5.3", {
baseUrl: "https://opencode.ai/zen/go/v1/",
modelsDev,
}),
).toMatchObject({
contextWindow: 1000000,
maxOutputTokens: 131072,
reasoningEfforts: ["low", "high", "max"],
});
});
it("prefers the provider whose path matches the address best", () => {
expect(
resolveModelMetadata("glm-5", {
baseUrl: "https://api.z.ai/api/coding/paas/v4",
modelsDev,
})?.contextWindow,
).toBe(202752);
expect(
resolveModelMetadata("glm-5", {
baseUrl: "https://api.z.ai/api/paas/v4",
modelsDev,
})?.contextWindow,
).toBe(200000);
});
it("matches ids case-insensitively and across namespaces", () => {
expect(
resolveModelMetadata("Z-AI/GLM-5", {
baseUrl: "https://openrouter.ai/api/v1",
modelsDev,
})?.contextWindow,
).toBe(202800);
expect(
resolveModelMetadata("zai-org/glm-5", {
baseUrl: "https://open.bigmodel.cn/api/paas/v4",
modelsDev,
})?.contextWindow,
).toBe(204800);
});
it("falls back to the vendor entry without carrying its price", () => {
const metadata = resolveModelMetadata("glm-5", {
baseUrl: "https://relay.example.com/v1",
modelsDev,
});
expect(metadata).toMatchObject({
contextWindow: 204800,
reasoning: true,
inputModalities: ["text"],
reasoningEfforts: ["high"],
sources: ["models-dev"],
});
expect(metadata?.cost).toBeUndefined();
});
it("keeps the price when the provider itself is recognized", () => {
expect(
resolveModelMetadata("glm-5", {
baseUrl: "https://open.bigmodel.cn/api/paas/v4",
modelsDev,
}),
).toMatchObject({
cost: { input: 1, output: 3.2 },
reasoningEfforts: ["high"],
});
});
it("ignores a vendor hosting another vendor's model", () => {
expect(
resolveModelMetadata("MiniMax-M9", {
baseUrl: "https://relay.example.com/v1",
modelsDev,
})?.contextWindow,
).toBe(204800);
});
it("does not borrow an entry that points at another vendor", () => {
expect(
resolveModelMetadata("DeepSeek-v9-flash-1017", {
baseUrl: "https://relay.example.com/v1",
modelsDev,
}),
).toBeNull();
});
it("still uses the vendor's own entry when its pointer is dangling", () => {
expect(
resolveModelMetadata("laguna-9", {
baseUrl: "https://relay.example.com/v1",
modelsDev,
})?.contextWindow,
).toBe(262144);
});
it("gives up when the same name points at different vendors", () => {
expect(
resolveModelMetadata("kimi-k9", {
baseUrl: "https://relay.example.com/v1",
modelsDev,
}),
).toBeNull();
});
it("lets the matching preset row win field by field", () => {
const presets = [
preset("https://api.z.ai/api/coding/paas/v4", {
"glm-5": { contextWindow: 500000 },
}),
preset("https://relay.example.com/v1", {
"glm-5": { contextWindow: 1 },
}),
];
expect(
resolveModelMetadata("glm-5", {
baseUrl: "https://api.z.ai/api/coding/paas/v4",
presets,
modelsDev,
}),
).toMatchObject({
contextWindow: 500000,
maxOutputTokens: 98304,
sources: ["preset", "models-dev"],
});
});
it("does not take another plan's preset on the same host", () => {
expect(
resolveModelMetadata("glm-5", {
baseUrl: "https://api.z.ai/api/paas/v4",
presets: [
preset("https://api.z.ai/api/coding/paas/v4", {
"glm-5": { contextWindow: 500000 },
}),
],
modelsDev,
}),
).toMatchObject({ contextWindow: 200000, sources: ["models-dev"] });
});
it("accepts the same endpoint written with or without a version suffix", () => {
expect(
resolveModelMetadata("glm-5.3", {
baseUrl: "https://opencode.ai/zen/go",
modelsDev,
})?.contextWindow,
).toBe(1000000);
});
it("matches the old OpenCode address to the entry models.dev moved to /inference", () => {
const moved: ModelsDevResponse = {
"opencode-go": {
id: "opencode-go",
api: "https://opencode.ai/inference/go/openai/v1",
models: {
"deepseek-v4.1-flash": {
id: "deepseek-v4.1-flash",
limit: { context: 1000000 },
reasoning: true,
modalities: { input: ["text", "image"], output: ["text"] },
},
},
},
opencode: {
id: "opencode",
api: "https://opencode.ai/inference/openai/v1",
models: {
"deepseek-v4.1-flash": {
id: "deepseek-v4.1-flash",
limit: { context: 200000 },
},
},
},
};
expect(
resolveModelMetadata("deepseek-v4.1-flash", {
baseUrl: "https://opencode.ai/zen/go/v1",
modelsDev: moved,
}),
).toMatchObject({
contextWindow: 1000000,
reasoning: true,
inputModalities: ["text", "image"],
});
expect(
resolveModelMetadata("deepseek-v4.1-flash", {
baseUrl: "https://opencode.ai/zen/v1",
modelsDev: moved,
})?.contextWindow,
).toBe(200000);
});
it("does not treat a parent path as the plans nested under it", () => {
expect(
resolveModelMetadata("glm-5.3", {
baseUrl: "https://opencode.ai/zen",
presets: [
preset("https://opencode.ai/zen/go/v1", {
"glm-5.3": { contextWindow: 1 },
}),
],
modelsDev,
}),
).toBeNull();
});
it("ignores a pasted request path after the base address", () => {
expect(
resolveModelMetadata("glm-5", {
baseUrl: "https://api.z.ai/api/coding/paas/v4/chat/completions",
modelsDev,
})?.contextWindow,
).toBe(202752);
});
it("fills fields the provider entry leaves out from the vendor entry", () => {
const metadata = resolveModelMetadata("glm-5", {
baseUrl: "https://api.z.ai/api/paas/v4",
modelsDev,
});
expect(metadata).toMatchObject({
contextWindow: 200000,
maxOutputTokens: 128000,
reasoning: true,
inputModalities: ["text"],
});
expect(metadata?.cost).toBeUndefined();
});
it("uses presets alone when models.dev is unavailable", () => {
expect(
resolveModelMetadata("glm-5", {
baseUrl: "https://api.z.ai/api/coding/paas/v4",
presets: [
preset("https://api.z.ai/api/coding/paas/v4", {
"glm-5": { contextWindow: 500000 },
}),
],
}),
).toEqual({ contextWindow: 500000, sources: ["preset"] });
expect(
resolveModelMetadata("glm-5", { baseUrl: "https://api.z.ai/v1" }),
).toBeNull();
});
it("caps GPT windows only when they are borrowed from the vendor entry", () => {
const openai: ModelsDevResponse = {
openai: {
id: "openai",
api: "https://api.openai.com/v1",
models: {
"gpt-6-astra": {
id: "gpt-6-astra",
limit: { context: 1050000, output: 128000 },
},
"gpt-4o": { id: "gpt-4o", limit: { context: 128000 } },
"o9-big": { id: "o9-big", limit: { context: 1050000 } },
},
},
relay: {
id: "relay",
models: {
"openai/gpt-6-astra": {
id: "openai/gpt-6-astra",
canonical_model_id: "openai/gpt-6-astra",
},
},
},
gateway: {
id: "gateway",
api: "https://gateway.example.com/v1",
models: {
"gpt-6-astra": {
id: "gpt-6-astra",
canonical_model_id: "openai/gpt-6-astra",
},
},
},
};
const resolve = (id: string, baseUrl: string) =>
resolveModelMetadata(id, { baseUrl, modelsDev: openai })?.contextWindow;
// 认不出地址、借原厂条目的中转封顶,带命名空间的写法也一样。
expect(resolve("gpt-6-astra", "https://relay.example.com/v1")).toBe(
GPT_CONTEXT_WINDOW_CAP,
);
expect(resolve("openai/GPT-6-Astra", "https://relay.example.com/v1")).toBe(
GPT_CONTEXT_WINDOW_CAP,
);
// 认出了地址但那家没写窗口,借来的原厂窗口同样封顶。
expect(resolve("gpt-6-astra", "https://gateway.example.com/v1")).toBe(
GPT_CONTEXT_WINDOW_CAP,
);
// 按地址认出的那家、同地址预设照用自己的值。
expect(resolve("gpt-6-astra", "https://api.openai.com/v1")).toBe(1050000);
expect(
resolveModelMetadata("openai-gpt-6-astra", {
baseUrl: "https://relay.example.com/v1",
presets: [
preset("https://relay.example.com/v1", {
"openai-gpt-6-astra": { contextWindow: 1050000 },
}),
],
modelsDev: openai,
})?.contextWindow,
).toBe(1050000);
// 本来就小的 GPT 不往上抬,其他模型不受影响。
expect(resolve("gpt-4o", "https://api.openai.com/v1")).toBe(128000);
expect(resolve("o9-big", "https://api.openai.com/v1")).toBe(1050000);
});
});