/** * Window-derived request limits (llm/context-limits.ts, issue #218): the per-request * output-cap arithmetic, the input-size estimator it feeds on, and the window-derived * compaction threshold. All pure functions — no timers, no network. */ import { describe, expect, it } from "vitest"; import { COMPACTION_HEADROOM, DEFAULT_CONTEXT_WINDOW, MIN_OUTPUT_TOKENS, MIN_USABLE_CONTEXT_WINDOW, OUTPUT_SAFETY_MARGIN, approximateMessagesTokens, approximateTokens, effectiveMaxContextLength, effectiveMaxOutputTokens, resolveContextWindow, } from "../src/llm/context-limits.js"; import { toolCallOutput, userText } from "../src/omnimessage/index.js"; import type { OmniMessage } from "../src/omnimessage/index.js"; describe("constant derivation (one tunable: OUTPUT_SAFETY_MARGIN)", () => { it("derives the floor and headroom from the margin so their invariants cannot drift", () => { // Floor below the margin: a floored request still fits the window when the input // estimate is accurate. Headroom above the margin: at the compaction trigger the // summary request keeps a usable output budget (~1k), not just the floor. expect(MIN_OUTPUT_TOKENS).toBe(Math.floor(OUTPUT_SAFETY_MARGIN / 2)); expect(COMPACTION_HEADROOM).toBe(OUTPUT_SAFETY_MARGIN * 2); expect(COMPACTION_HEADROOM - OUTPUT_SAFETY_MARGIN).toBeGreaterThanOrEqual(1000); }); }); describe("resolveContextWindow", () => { it("takes a plausible configured window at face value, anything else is unconfigured", () => { expect(resolveContextWindow(32768)).toBe(32768); expect(resolveContextWindow(MIN_USABLE_CONTEXT_WINDOW)).toBe(MIN_USABLE_CONTEXT_WINDOW); expect(resolveContextWindow(undefined)).toBeUndefined(); expect(resolveContextWindow("unknown")).toBeUndefined(); expect(resolveContextWindow(0)).toBeUndefined(); expect(resolveContextWindow(-1)).toBeUndefined(); expect(resolveContextWindow(Number.NaN)).toBeUndefined(); // Below the sane minimum = a typo'd window (no real model sits under 4096): treated as // unconfigured rather than clamping every request against a bogus number. expect(resolveContextWindow(2048)).toBeUndefined(); }); }); describe("approximateTokens (character heuristic, not a tokenizer)", () => { it("counts ASCII at ~4 chars/token and non-ASCII at 1 token/char", () => { expect(approximateTokens("")).toBe(0); expect(approximateTokens("abcd")).toBe(1); expect(approximateTokens("abcde")).toBe(2); // ceil(5/4) // CJK errs high on purpose: underestimating input risks a provider 400. expect(approximateTokens("你好世界")).toBe(4); expect(approximateTokens("ab你好")).toBe(3); // ceil(2/4) + 2 }); }); describe("approximateMessagesTokens", () => { it("estimates text-bearing payloads from their serialized size", () => { const text = userText("x".repeat(400)); const est = approximateMessagesTokens([text]); // ~100 tokens of body + the JSON envelope: a loose sanity band, not an exact figure // (the heuristic's contract is "close and erring high"). expect(est).toBeGreaterThanOrEqual(100); expect(est).toBeLessThan(150); // Tool outputs go through the same serialized-payload path; a genuinely huge text // output still counts as text. const big = toolCallOutput({ output: "y".repeat(120_000), toolCallId: "c1", stopReason: "completed", }); expect(approximateMessagesTokens([big])).toBeGreaterThanOrEqual(30_000); }); it("gives image payloads a flat allowance instead of counting data-URI characters", () => { const image = { type: "model_msg", payload: { type: "image_url", role: "user", image_url: `data:image/png;base64,${"A".repeat(100_000)}`, }, } as unknown as OmniMessage; const est = approximateMessagesTokens([image]); // 100k base64 chars would be ~25k "tokens" by the char heuristic; the flat allowance // stays in the low thousands so one pasted screenshot cannot floor the output cap. expect(est).toBeLessThan(2000); expect(est).toBeGreaterThan(1000); }); it("counts a tool output's `images` data URLs at the flat allowance, not as base64 text", () => { // read_image-style outputs carry the image as tool_call_output.images (data URLs). A // 1 MB base64 string serialized as text would estimate ~262k "tokens" (~163x over) // and floor the NEXT request's cap even on a 128k window. const withImage = toolCallOutput({ output: "Read image OK (1024x768).", toolCallId: "c1", stopReason: "completed", images: [`data:image/png;base64,${"A".repeat(1_000_000)}`], }); const est = approximateMessagesTokens([withImage]); expect(est).toBeLessThan(2000); // flat image allowance + a small text payload expect(est).toBeGreaterThan(1600 - 1); // Two images: two allowances. const twoImages = toolCallOutput({ output: "ok", toolCallId: "c2", stopReason: "completed", images: [ `data:image/png;base64,${"A".repeat(500_000)}`, `data:image/jpeg;base64,${"B".repeat(500_000)}`, ], }); expect(approximateMessagesTokens([twoImages])).toBeLessThan(3400); expect(approximateMessagesTokens([twoImages])).toBeGreaterThan(3200 - 1); }); }); describe("effectiveMaxOutputTokens (per-request output clamp)", () => { it("is a no-op for big-window models: the configured cap comes back unchanged", () => { expect(effectiveMaxOutputTokens(32000, 1_000_000, 50_000)).toBe(32000); expect(effectiveMaxOutputTokens(32000, DEFAULT_CONTEXT_WINDOW, 10_000)).toBe(32000); }); it("keeps the no-explicit-cap contract: unset or non-positive stays undefined", () => { expect(effectiveMaxOutputTokens(undefined, 32768, 1000)).toBeUndefined(); expect(effectiveMaxOutputTokens(-1, 32768, 1000)).toBeUndefined(); expect(effectiveMaxOutputTokens(0, 32768, 1000)).toBeUndefined(); }); it("does not clamp without a configured window: no hard cap from an assumption", () => { // An entry without context_window used to derive a clamp from the assumed 128000; // with compaction disabled that pinned the cap to the floor past ~127k of real // context on a model that used to work. Unconfigured window = configured cap as-is. expect(effectiveMaxOutputTokens(32000, undefined, 500_000)).toBe(32000); expect(effectiveMaxOutputTokens(32000, undefined, 10)).toBe(32000); }); it("clamps so estimated input + cap + margin fits the window (the issue #218 report)", () => { // The reported failure: window 32768, configured cap 32000, prompt ~769 tokens — // the fixed cap overflowed the window on the very first request. const cap = effectiveMaxOutputTokens(32000, 32768, 769)!; expect(cap).toBe(32768 - 769 - OUTPUT_SAFETY_MARGIN); expect(769 + cap + OUTPUT_SAFETY_MARGIN).toBeLessThanOrEqual(32768); // The margin binds exactly: one token less input buys one token more cap. expect(effectiveMaxOutputTokens(32000, 32768, 768)).toBe(cap + 1); }); it("floors at MIN_OUTPUT_TOKENS instead of emitting a non-positive cap (degenerate case)", () => { // Remaining window smaller than the floor — compaction should have fired long before // this point; the deterministic floor beats sending max_tokens <= 0. expect(effectiveMaxOutputTokens(32000, 32768, 32_500)).toBe(MIN_OUTPUT_TOKENS); expect(effectiveMaxOutputTokens(32000, 32768, 99_999)).toBe(MIN_OUTPUT_TOKENS); // The floor stays below the safety margin, so a floored request still fits the window // whenever the input estimate is accurate. expect(MIN_OUTPUT_TOKENS).toBeLessThanOrEqual(OUTPUT_SAFETY_MARGIN); }); it("only ever lowers a cap: a configured cap below the floor is never raised", () => { // A pinned meta budget (metaMaxTokens can hand a cap as small as the user pinned it) // must come back verbatim — the clamp lowers caps, it never raises them. expect(effectiveMaxOutputTokens(128, 1_000_000, 100)).toBe(128); expect(effectiveMaxOutputTokens(128, 32768, 99_999)).toBe(128); // degenerate: still the configured cap }); }); describe("effectiveMaxContextLength (window-derived compaction threshold)", () => { it("caps the configured threshold at context_window − COMPACTION_HEADROOM", () => { // 32k window: compaction now fires at ~30.7k instead of never (the 128000 default was // unreachable inside the window). expect(effectiveMaxContextLength(128000, 32768)).toBe(32768 - COMPACTION_HEADROOM); expect(effectiveMaxContextLength(128000, 200000)).toBe(128000); // ample window: unchanged expect(effectiveMaxContextLength(8000, 32768)).toBe(8000); // tighter user setting wins // A window at the sanity minimum still derives a positive threshold, so a usable // window can never flip the value into the "<=0 disables" contract. expect(effectiveMaxContextLength(128000, MIN_USABLE_CONTEXT_WINDOW)).toBe( MIN_USABLE_CONTEXT_WINDOW - COMPACTION_HEADROOM, ); }); it("derives from the 128000 default when the window is unconfigured or implausible", () => { expect(effectiveMaxContextLength(128000, undefined)).toBe( DEFAULT_CONTEXT_WINDOW - COMPACTION_HEADROOM, ); expect(effectiveMaxContextLength(128000, "unknown")).toBe( DEFAULT_CONTEXT_WINDOW - COMPACTION_HEADROOM, ); // A typo'd tiny window counts as unconfigured (resolveContextWindow's sanity // threshold): the threshold derives from the assumption instead of collapsing to a // near-zero value that would compact after every request. expect(effectiveMaxContextLength(128000, 2048)).toBe( DEFAULT_CONTEXT_WINDOW - COMPACTION_HEADROOM, ); }); it("keeps <=0 as 'compaction disabled'", () => { expect(effectiveMaxContextLength(-1, 32768)).toBe(-1); // off: no clamping expect(effectiveMaxContextLength(0, 32768)).toBe(0); // off: no clamping }); });