Files
cc-switch/tests/lib/modelMetadata.test.ts
T
Jason be0a9cf0f5 fix(presets): cap GPT context windows at the 272K subscription window
models.dev lists the OpenAI API window for GPT models (1.05M for the
GPT-5.6 and GPT-6 families), but many relays serve GPT through ChatGPT
subscriptions, whose Codex backend catalogs these models at 272K. The
base URL cannot tell the two apart. An overstated window keeps clients
from compacting until the upstream rejects the prompt and the turn
fails, while an understated one only compacts earlier.

- Cap every GPT window resolveModelMetadata returns at 272K, whether it
  comes from a preset, the recognized models.dev provider or the vendor
  fallback; smaller windows such as gpt-4o's are left alone
- Lower the GPT windows in the Hermes, OpenClaw, OpenCode, Pi and Codex
  presets to the same cap, with a test guarding every preset source
- Pin the Claude Code ChatGPT subscription preset's context and
  auto-compact windows to 272K; the 372K it used was the backend's
  mid-2026 value and now sits above the backend's limit
2026-10-15 21:34:24 +08:00

366 lines
10 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 } } },
},
};
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("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("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 at the subscription window whatever the source", () => {
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",
},
},
},
};
const resolve = (id: string, baseUrl: string) =>
resolveModelMetadata(id, { baseUrl, modelsDev: openai })?.contextWindow;
// 认出了 OpenAI 自己、走原厂兜底的中转、带命名空间或平台前缀的写法都封顶。
expect(resolve("gpt-6-astra", "https://api.openai.com/v1")).toBe(
GPT_CONTEXT_WINDOW_CAP,
);
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(
resolveModelMetadata("openai-gpt-6-astra", {
baseUrl: "https://relay.example.com/v1",
presets: [
preset("https://relay.example.com/v1", {
"openai-gpt-6-astra": { contextWindow: 1050000 },
}),
],
})?.contextWindow,
).toBe(GPT_CONTEXT_WINDOW_CAP);
// 本来就小的 GPT 不往上抬,其他模型不受影响。
expect(resolve("gpt-4o", "https://api.openai.com/v1")).toBe(128000);
expect(resolve("o9-big", "https://api.openai.com/v1")).toBe(1050000);
});
});