diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 13:25:23 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 13:25:23 +0900 |
| commit | 6433cc42de1ceca7210e2b64ad3b98b3a5ce7d02 (patch) | |
| tree | 78b30dedd471ab76177b3631a956ab160615e303 /packages/core/tests | |
| parent | 3f629a8469fe483243671e1ca15582a111e96541 (diff) | |
| download | dispatch-6433cc42de1ceca7210e2b64ad3b98b3a5ce7d02.tar.gz dispatch-6433cc42de1ceca7210e2b64ad3b98b3a5ce7d02.zip | |
feat(context-window): show current/max context usage per tab/model
Add a 'Context Window' sidebar view showing the live context occupancy
(latest request's input+output) against the model's maximum context
window, resolved dynamically from the models.dev catalog.
- core: models.dev catalog module (resolveContextLimit) with disk cache,
TTL, stale-fallback + offline penalty memo; null for unknown models.
- api: GET /models/context-limit?provider=&modelId=.
- frontend: ContextWindowPanel + computeContextUsage helper; App resolves
+ caches the active model's max (anthropic/opencode-anthropic only);
percent shown to 2 decimals; degrades to bare token count when max
unknown.
- tests: core catalog (13), api route (3), frontend helper (6).
Diffstat (limited to 'packages/core/tests')
| -rw-r--r-- | packages/core/tests/models/catalog.test.ts | 158 |
1 files changed, 158 insertions, 0 deletions
diff --git a/packages/core/tests/models/catalog.test.ts b/packages/core/tests/models/catalog.test.ts new file mode 100644 index 0000000..51043e6 --- /dev/null +++ b/packages/core/tests/models/catalog.test.ts @@ -0,0 +1,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(); + }); +}); |
