From 6433cc42de1ceca7210e2b64ad3b98b3a5ce7d02 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Tue, 2 Jun 2026 13:25:23 +0900 Subject: 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). --- packages/frontend/tests/context-window.test.ts | 84 ++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 packages/frontend/tests/context-window.test.ts (limited to 'packages/frontend/tests') diff --git a/packages/frontend/tests/context-window.test.ts b/packages/frontend/tests/context-window.test.ts new file mode 100644 index 0000000..bb64ed5 --- /dev/null +++ b/packages/frontend/tests/context-window.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { computeContextUsage } from "../src/lib/context-window.js"; +import type { CacheStats } from "../src/lib/types.js"; + +function stats(last: CacheStats["last"]): CacheStats { + return { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + requests: last ? 1 : 0, + last, + }; +} + +describe("computeContextUsage", () => { + it("derives current context from the LAST request's input + output", () => { + const usage = computeContextUsage( + stats({ + inputTokens: 47000, + outputTokens: 1200, + cacheReadTokens: 40000, + cacheWriteTokens: 0, + }), + 200000, + ); + // 47000 + 1200 — NOT the cumulative totals, and cache tokens are already + // inside inputTokens (not re-added). + expect(usage.current).toBe(48200); + expect(usage.max).toBe(200000); + expect(usage.percent).toBeCloseTo(24.1, 5); // 48200 / 200000 * 100, unrounded + }); + + it("returns max=null and percent=null when the limit is unknown", () => { + const usage = computeContextUsage( + stats({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }), + null, + ); + expect(usage.current).toBe(100); + expect(usage.max).toBeNull(); + expect(usage.percent).toBeNull(); + }); + + it("treats a non-positive limit as unknown", () => { + const usage = computeContextUsage( + stats({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }), + 0, + ); + expect(usage.max).toBeNull(); + expect(usage.percent).toBeNull(); + }); + + it("reports zero usage when no request has completed yet", () => { + expect(computeContextUsage(null, 200000)).toEqual({ + current: 0, + max: 200000, + percent: 0, + }); + expect(computeContextUsage(stats(null), 200000)).toEqual({ + current: 0, + max: 200000, + percent: 0, + }); + }); + + it("clamps percent to 100 when context overflows the window", () => { + const usage = computeContextUsage( + stats({ inputTokens: 250000, outputTokens: 5000, cacheReadTokens: 0, cacheWriteTokens: 0 }), + 200000, + ); + expect(usage.current).toBe(255000); + expect(usage.percent).toBe(100); + }); + + it("keeps an unrounded percent so the UI can show 2 decimals", () => { + const usage = computeContextUsage( + stats({ inputTokens: 3690, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }), + 1000000, + ); + // 3690 / 1,000,000 * 100 = 0.369 → displayed as "0.37%" (toFixed(2)). + expect(usage.percent).toBeCloseTo(0.369, 6); + expect((usage.percent as number).toFixed(2)).toBe("0.37"); + }); +}); -- cgit v1.2.3