summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests/models/catalog.test.ts
blob: 51043e6032d2b40fa4f830392c9a7ba7f2e7994d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
import { existsSync, rmSync, utimesSync, writeFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
	__resetCatalogCacheForTests,
	getModelsCatalog,
	resolveContextLimit,
} from "../../src/models/catalog.js";

const CACHE_PATH = "/tmp/dispatch/models-dev.json";

// A trimmed models.dev-shaped catalog covering the providers we support.
const CATALOG = {
	anthropic: {
		id: "anthropic",
		models: {
			"claude-sonnet-4-5": { limit: { context: 200000, output: 64000 } },
			"claude-sonnet-4-6": { limit: { context: 1000000, output: 64000 } },
		},
	},
	opencode: {
		id: "opencode",
		models: {
			"glm-4-6": { limit: { context: 131072, output: 8192 } },
		},
	},
};

function mockFetchOnce(catalog: unknown, ok = true, status = 200) {
	const fn = vi.fn(() =>
		Promise.resolve({
			ok,
			status,
			text: () => Promise.resolve(JSON.stringify(catalog)),
		} as Response),
	);
	vi.stubGlobal("fetch", fn);
	return fn;
}

beforeEach(() => {
	__resetCatalogCacheForTests();
	if (existsSync(CACHE_PATH)) rmSync(CACHE_PATH);
	delete process.env.DISPATCH_DISABLE_MODELS_FETCH;
});

afterEach(() => {
	vi.unstubAllGlobals();
	if (existsSync(CACHE_PATH)) rmSync(CACHE_PATH);
});

describe("resolveContextLimit", () => {
	it("resolves a known anthropic model to its context window", async () => {
		mockFetchOnce(CATALOG);
		expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBe(200000);
		expect(await resolveContextLimit("anthropic", "claude-sonnet-4-6")).toBe(1000000);
	});

	it("maps opencode-anthropic to the anthropic catalog, then opencode fallback", async () => {
		mockFetchOnce(CATALOG);
		// Present in the anthropic catalog.
		expect(await resolveContextLimit("opencode-anthropic", "claude-sonnet-4-5")).toBe(200000);
		// Absent in anthropic, found in the opencode gateway catalog.
		expect(await resolveContextLimit("opencode-anthropic", "glm-4-6")).toBe(131072);
	});

	it("returns null for an unknown model id", async () => {
		mockFetchOnce(CATALOG);
		expect(await resolveContextLimit("anthropic", "no-such-model")).toBeNull();
	});

	it("returns null for an unsupported provider (no network needed)", async () => {
		const fetchFn = mockFetchOnce(CATALOG);
		expect(await resolveContextLimit("google", "gemini-2.5-pro")).toBeNull();
		expect(await resolveContextLimit("anthropic", "")).toBeNull();
		expect(fetchFn).not.toHaveBeenCalled();
	});

	it("returns null when the model has no positive context limit", async () => {
		mockFetchOnce({
			anthropic: { id: "anthropic", models: { broken: { limit: { context: 0 } } } },
		});
		expect(await resolveContextLimit("anthropic", "broken")).toBeNull();
	});

	it("does not throw on a malformed provider entry missing `models`", async () => {
		// A provider object without a `models` map must degrade to null, not crash.
		mockFetchOnce({ anthropic: { id: "anthropic" } });
		expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBeNull();
	});

	it("does not throw when limit/context fields are absent", async () => {
		mockFetchOnce({ anthropic: { id: "anthropic", models: { m: {} } } });
		expect(await resolveContextLimit("anthropic", "m")).toBeNull();
	});
});

describe("getModelsCatalog caching", () => {
	it("fetches once and serves the in-process memo on subsequent calls", async () => {
		const fetchFn = mockFetchOnce(CATALOG);
		await resolveContextLimit("anthropic", "claude-sonnet-4-5");
		await resolveContextLimit("anthropic", "claude-sonnet-4-6");
		await getModelsCatalog();
		expect(fetchFn).toHaveBeenCalledTimes(1);
	});

	it("reuses a fresh disk cache without re-fetching across processes", async () => {
		// Simulate another process having written a fresh cache.
		writeFileSync(CACHE_PATH, JSON.stringify(CATALOG), "utf-8");
		const fetchFn = vi.fn(() => Promise.reject(new Error("network should not be hit")));
		vi.stubGlobal("fetch", fetchFn);
		expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBe(200000);
		expect(fetchFn).not.toHaveBeenCalled();
	});

	it("falls back to a STALE disk cache when the network fails", async () => {
		writeFileSync(CACHE_PATH, JSON.stringify(CATALOG), "utf-8");
		// Age the cache well past the TTL so the fetch path is taken.
		const old = Date.now() / 1000 - 3600;
		utimesSync(CACHE_PATH, old, old);
		const fetchFn = vi.fn(() => Promise.reject(new Error("offline")));
		vi.stubGlobal("fetch", fetchFn);
		const warn = vi.spyOn(console, "warn").mockImplementation(() => {});

		expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBe(200000);
		expect(fetchFn).toHaveBeenCalledTimes(1);
		warn.mockRestore();
	});

	it("returns null when fetch fails and no cache exists", async () => {
		const fetchFn = vi.fn(() => Promise.reject(new Error("offline")));
		vi.stubGlobal("fetch", fetchFn);
		const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
		expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBeNull();
		warn.mockRestore();
	});

	it("does not hit the network when DISPATCH_DISABLE_MODELS_FETCH is set", async () => {
		process.env.DISPATCH_DISABLE_MODELS_FETCH = "1";
		const fetchFn = vi.fn(() => Promise.reject(new Error("should not fetch")));
		vi.stubGlobal("fetch", fetchFn);
		expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBeNull();
		expect(fetchFn).not.toHaveBeenCalled();
	});

	it("memoizes the fallback after a failed fetch so it does not re-hit the network", async () => {
		const fetchFn = vi.fn(() => Promise.reject(new Error("offline")));
		vi.stubGlobal("fetch", fetchFn);
		const warn = vi.spyOn(console, "warn").mockImplementation(() => {});

		// First lookup triggers the (failing) fetch.
		expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBeNull();
		// Subsequent lookups within the penalty window must NOT re-fetch.
		expect(await resolveContextLimit("anthropic", "claude-sonnet-4-6")).toBeNull();
		await getModelsCatalog();
		expect(fetchFn).toHaveBeenCalledTimes(1);
		warn.mockRestore();
	});
});