Skip to content

Commit 0e648a6

Browse files
thaolaptrinhclaude
andcommitted
test(catalog): cover TTL expiry, retired models, malformed payloads
Closes the four gaps flagged in the review of PR #8: - TTL expiry actually elapses and refetches (Date.now spy) - static entries survive when the API no longer lists them (retired upstream models keep static name/context, source: "static") - malformed payloads: data:null keeps the catalog; id-less and null items are skipped instead of discarding the whole refresh (also hardened the filter in catalog.ts for this); null context_length falls back to 128k - resolveAnthropicModel keeps the claude-* default pinned to the static first entry even when the API returns a reordered list 166 tests pass, typecheck clean. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a67a614 commit 0e648a6

3 files changed

Lines changed: 74 additions & 2 deletions

File tree

‎src/translate/catalog.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,12 @@ export async function refreshCatalog(
115115
inflight = (async () => {
116116
try {
117117
const apiModels = await fetchApiModels(apiBase, apiKey);
118-
const open = apiModels.filter((m) => m.id && !isClosedModel(m.id));
118+
// Tolerate junk entries (null items, missing ids) rather than letting
119+
// one malformed record discard the whole refresh.
120+
const open = apiModels.filter(
121+
(m): m is ApiModel & { id: string } =>
122+
typeof m === "object" && m !== null && typeof m.id === "string" && !isClosedModel(m.id),
123+
);
119124
if (open.length > 0) {
120125
const apiById = new Map(open.map((m) => [m.id, m]));
121126
const merged: CatalogModel[] = [];

‎tests/anthropic-models.test.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
1-
import { describe, test, expect, beforeEach, afterEach } from "vitest";
1+
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
22
import { resolveAnthropicModel } from "@/translate/anthropic-models.js";
3+
import { refreshCatalog, __resetCatalogForTests } from "@/translate/catalog.js";
4+
import { mockCcModelsFetch } from "./helpers.js";
35

46
describe("resolveAnthropicModel", () => {
57
let originalEnv: NodeJS.ProcessEnv;
68

79
beforeEach(() => {
810
originalEnv = { ...process.env };
911
delete process.env.ANTHROPIC_DEFAULT_MODEL;
12+
__resetCatalogForTests();
1013
});
1114

1215
afterEach(() => {
1316
process.env = originalEnv;
17+
vi.restoreAllMocks();
1418
});
1519

1620
test("claude-* uses ANTHROPIC_DEFAULT_MODEL env", () => {
@@ -27,4 +31,16 @@ describe("resolveAnthropicModel", () => {
2731
test("non-claude model passes through", () => {
2832
expect(resolveAnthropicModel("custom-model")).toBe("custom-model");
2933
});
34+
35+
test("claude-* default stays stable even when the API returns a reordered list", async () => {
36+
// The API lists models in its own order (closed models first in the wild).
37+
// The static-first merge must keep getDefaultModels()[0] — and thus the
38+
// claude-* fallback — pinned to the static builtin's first entry.
39+
mockCcModelsFetch([
40+
{ id: "zai-org/GLM-5.3", name: "GLM-5.3", context_length: 1000000 },
41+
{ id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash", context_length: 1000000 },
42+
]);
43+
await refreshCatalog("https://api.test-cc.example", "test-key");
44+
expect(resolveAnthropicModel("claude-opus-4-1")).toBe("deepseek/deepseek-v4-pro");
45+
});
3046
});

‎tests/catalog.test.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,57 @@ describe("refreshCatalog", () => {
8989
await refreshCatalog(API_BASE, API_KEY, { force: true });
9090
expect(calls.count).toBe(2);
9191
});
92+
93+
it("refetches once the TTL has elapsed", async () => {
94+
const calls = { count: 0 };
95+
mockFetch(API_MODELS, calls);
96+
const now = vi.spyOn(Date, "now");
97+
await refreshCatalog(API_BASE, API_KEY);
98+
expect(calls.count).toBe(1);
99+
// Jump past the 1h TTL.
100+
now.mockReturnValue(Date.now() + 60 * 60 * 1000 + 1);
101+
try {
102+
await refreshCatalog(API_BASE, API_KEY);
103+
expect(calls.count).toBe(2);
104+
} finally {
105+
now.mockRestore();
106+
}
107+
});
108+
109+
it("keeps static entries the API no longer lists (retired upstream models)", async () => {
110+
// The payload only knows deepseek-v4-pro — every other static builtin
111+
// model has "disappeared" from the API.
112+
mockFetch([{ id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro", context_length: 1 }]);
113+
const catalog = await refreshCatalog(API_BASE, API_KEY);
114+
const glm = catalog.models.find((m) => m.id === "zai-org/GLM-5.2");
115+
expect(glm).toBeDefined();
116+
expect(glm?.source).toBe("static");
117+
// Static fallbacks fill in name + context when the API is silent.
118+
expect(glm?.displayName).toBe("GLM-5.2");
119+
expect(glm?.contextWindow).toBe(1048576);
120+
// The one model the API still knows is flagged as api-sourced.
121+
expect(catalog.models.find((m) => m.id === "deepseek/deepseek-v4-pro")?.source).toBe("api");
122+
});
123+
124+
it("tolerates malformed API payloads without corrupting the catalog", async () => {
125+
const before = getCatalog().ids;
126+
127+
// data: null
128+
mockFetch(null);
129+
expect((await refreshCatalog(API_BASE, API_KEY, { force: true })).ids).toEqual(before);
130+
131+
// items missing id are skipped; null context_length falls back
132+
mockFetch([
133+
null,
134+
{ name: "No Id Model" },
135+
{ id: "neworg/No-Context", name: "No Context", context_length: null },
136+
]);
137+
const catalog = await refreshCatalog(API_BASE, API_KEY, { force: true });
138+
expect(catalog.ids).toContain("neworg/No-Context");
139+
expect(catalog.contextWindows["neworg/No-Context"]).toBe(128_000);
140+
// The id-less entries never made it in.
141+
expect(catalog.ids.length).toBe(before.length + 1);
142+
});
92143
});
93144

94145
describe("resolveModel against the dynamic catalog", () => {

0 commit comments

Comments
 (0)