From d931b907bf2bf96166f67d50396601d014b0be41 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 5 Aug 2026 16:48:50 -0300 Subject: [PATCH] fix(sse): stop reasoning-token buffer from enlarging client max_tokens (#9507) Closes #9507 --- .../fixes/9507-maxtokens-upward-rewrite.md | 1 + open-sse/services/reasoningTokenBuffer.ts | 10 ++++- tests/unit/combo-routing-engine.test.ts | 22 ++++----- .../unit/reasoning-token-buffer-6274.test.ts | 19 +++++--- .../unit/reasoning-token-buffer-9507.test.ts | 45 +++++++++++++++++++ tests/unit/repro-6524.test.ts | 14 +++--- 6 files changed, 86 insertions(+), 25 deletions(-) create mode 100644 changelog.d/fixes/9507-maxtokens-upward-rewrite.md create mode 100644 tests/unit/reasoning-token-buffer-9507.test.ts diff --git a/changelog.d/fixes/9507-maxtokens-upward-rewrite.md b/changelog.d/fixes/9507-maxtokens-upward-rewrite.md new file mode 100644 index 0000000000..ef43480699 --- /dev/null +++ b/changelog.d/fixes/9507-maxtokens-upward-rewrite.md @@ -0,0 +1 @@ +- fix(sse): stop the reasoning-token buffer from enlarging a client's explicit max_tokens upward (x1.5) (#9507) diff --git a/open-sse/services/reasoningTokenBuffer.ts b/open-sse/services/reasoningTokenBuffer.ts index 8cce846d14..4c5ce88059 100644 --- a/open-sse/services/reasoningTokenBuffer.ts +++ b/open-sse/services/reasoningTokenBuffer.ts @@ -45,6 +45,12 @@ export function resolveReasoningBufferedMaxTokens( // request. Respect it verbatim instead of inflating (e.g. 1 -> 1001). if (current < REASONING_BUFFER_MIN_TRIGGER) return current; - const buffered = Math.max(current + 1000, Math.ceil(current * 1.5)); - return buffered > maxOutputTokens ? current : buffered; + // Issue #9507: never enlarge a client's explicit max_tokens. The #3587 + // headroom heuristic (Math.ceil(current * 1.5)) silently rewrote reasoning + // budgets upward (64000 -> 96000 on claude-opus-5), violating the #1761 + // contract that upward adjustment must be opt-in. The over-cap clamp above + // (line 42) already narrows, and the model's own output cap is the only + // legitimate ceiling; any headroom beyond the client-declared value is a + // silent cost increase the client did not authorize. + return current; } diff --git a/tests/unit/combo-routing-engine.test.ts b/tests/unit/combo-routing-engine.test.ts index 55899ff073..f84230ebf1 100644 --- a/tests/unit/combo-routing-engine.test.ts +++ b/tests/unit/combo-routing-engine.test.ts @@ -3136,8 +3136,8 @@ test("#3587 reasoning model gets max_tokens buffer applied", async () => { assert.equal(result.ok, true); assert.equal(bodies.length, 1, "should have called handleSingleModel once"); - // 4096 * 1.5 = 6144; max(4096+1000, 6144) = 6144 - assert.equal(bodies[0].max_tokens, 6144, "max_tokens should be buffered for reasoning model"); + // #9507: buffer never enlarges an explicit client max_tokens; pass-through 4096. + assert.equal(bodies[0].max_tokens, 4096, "max_tokens forwarded verbatim for reasoning model (#9507)"); }); test("#3587 reasoning buffer preserves max_tokens when the full buffer exceeds model cap", async () => { @@ -3154,8 +3154,8 @@ test("#3587 reasoning buffer preserves max_tokens when the full buffer exceeds m ); assert.equal( resolveReasoningBufferedMaxTokens("openai/gemini-high-cap", "4096"), - 6144, - "numeric string max_tokens should be normalized before applying a safe buffer" + 4096, + "numeric string max_tokens is normalized and forwarded verbatim (#9507)" ); assert.equal( resolveReasoningBufferedMaxTokens("openai/gemini-high-cap", "not-a-number"), @@ -3218,8 +3218,8 @@ test("#3587 reasoning buffer is disabled without explicit model capability data" ); assert.equal( resolveReasoningBufferedMaxTokens("openai/default-cap-reasoning", 300), - 1300, - "explicit default-sized caps are treated as real capability data" + 300, + "explicit default-sized caps are treated as real capability data, forwarded verbatim (#9507)" ); }); @@ -3302,7 +3302,7 @@ test("#3587 round-robin buffer does NOT compound across reasoning models", async // Two reasoning models in a round-robin combo. The first fails (400) so the // loop falls through to the second. The buffer must be computed from the // ORIGINAL max_tokens for each attempt — never from an already-buffered value — - // so both attempts see 6144 (4096 * 1.5), not [6144, 9216, ...]. Regression for + // so both attempts see the original 4096 (no enlargement per #9507), not a compounded value. Regression for // the shared-`body` mutation that compounded the buffer on every RR iteration. saveModelsDevCapabilities({ openai: { @@ -3345,12 +3345,12 @@ test("#3587 round-robin buffer does NOT compound across reasoning models", async assert.equal(result.status, 200); assert.equal(seen.length, 2, "both reasoning models should have been attempted"); - // Each attempt buffers from the original 4096 → 6144. No compounding. - assert.equal(seen[0].maxTokens, 6144, "first reasoning model buffered from original"); + // #9507: buffer never enlarges, so each attempt sees the original 4096; no compounding. + assert.equal(seen[0].maxTokens, 4096, "first reasoning model forwards original (#9507)"); assert.equal( seen[1].maxTokens, - 6144, - "second reasoning model must ALSO buffer from original 4096, not 6144" + 4096, + "second reasoning model must ALSO forward original 4096, not a buffered value (#9507)" ); }); diff --git a/tests/unit/reasoning-token-buffer-6274.test.ts b/tests/unit/reasoning-token-buffer-6274.test.ts index abc16227a5..5af15d94e3 100644 --- a/tests/unit/reasoning-token-buffer-6274.test.ts +++ b/tests/unit/reasoning-token-buffer-6274.test.ts @@ -10,6 +10,10 @@ * * Kept standalone against the pure `resolveReasoningBufferedMaxTokens` rather than * extending the frozen `combo-routing-engine.test.ts` god-file. + * + * #9507 update: the #3587 headroom heuristic was removed — the buffer never + * enlarges an explicit client max_tokens. The assertions at/above the trigger + * threshold now expect pass-through (256 -> 256, 32000 -> 32000). */ import test from "node:test"; import assert from "node:assert/strict"; @@ -90,17 +94,20 @@ test("#6274 reasoning buffer does not inflate probe-sized max_tokens", () => { REASONING_BUFFER_MIN_TRIGGER - 1, "budgets below REASONING_BUFFER_MIN_TRIGGER are respected verbatim" ); - // At the threshold, headroom resumes: max(256 + 1000, ceil(256 * 1.5)) = 1256. + // Issue #9507: the buffer must NEVER enlarge a client's explicit max_tokens. + // Previously the #3587 headroom heuristic rewrote these upward + // (256 -> 1256, 32000 -> 48000); that violated the #1761 contract that + // upward adjustment must be opt-in. The over-cap clamp still narrows. assert.equal( resolveReasoningBufferedMaxTokens("zhipu/glm-5.2", REASONING_BUFFER_MIN_TRIGGER), - 1256, - "budgets at the threshold receive reasoning headroom" + REASONING_BUFFER_MIN_TRIGGER, + "budgets at the threshold are forwarded verbatim (#9507)" ); - // A realistic reasoning budget still gets buffered: max(32000 + 1000, 48000) = 48000. + // A realistic reasoning budget is forwarded verbatim, not enlarged. assert.equal( resolveReasoningBufferedMaxTokens("zhipu/glm-5.2", 32000), - 48000, - "genuine reasoning budgets keep the #3587 headroom" + 32000, + "genuine reasoning budgets are forwarded verbatim (#9507)" ); }); diff --git a/tests/unit/reasoning-token-buffer-9507.test.ts b/tests/unit/reasoning-token-buffer-9507.test.ts new file mode 100644 index 0000000000..1c91ac62cf --- /dev/null +++ b/tests/unit/reasoning-token-buffer-9507.test.ts @@ -0,0 +1,45 @@ +/** + * #9507 — client max_tokens must NEVER be rewritten upward by the + * reasoning-token buffer. Core contract from #1761: OmniRoute must not + * silently enlarge a Claude Max user's per-turn cost. + * + * On claude-opus-5 (registry maxOutputTokens = 128000), a client sending + * max_tokens: 64000 got rewritten to 96000 (Math.ceil(64000 * 1.5)) because + * 96000 < 128000 so the "fits in cap" guard did NOT rescue it. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-9507-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const { resolveReasoningBufferedMaxTokens } = await import( + "../../open-sse/services/reasoningTokenBuffer.ts" +); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#9507 reasoning buffer does NOT enlarge a Claude opus-5 client budget upward", () => { + const result = resolveReasoningBufferedMaxTokens("anthropic/claude-opus-5", 64000); + assert.equal( + result, + 64000, + `client max_tokens=64000 must be forwarded verbatim, got ${result} (x1.5 upward rewrite)` + ); +}); + +test("#9507 reasoning buffer does NOT enlarge a Claude sonnet-5 client budget upward", () => { + const client = 32000; + const result = resolveReasoningBufferedMaxTokens("anthropic/claude-sonnet-5", client); + assert.ok( + result === null || result <= client, + `client max_tokens=${client} must not be enlarged, got ${result}` + ); +}); diff --git a/tests/unit/repro-6524.test.ts b/tests/unit/repro-6524.test.ts index b71a72c98a..1d4ac3a626 100644 --- a/tests/unit/repro-6524.test.ts +++ b/tests/unit/repro-6524.test.ts @@ -76,13 +76,15 @@ test.after(() => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); -test("#6524: with only the (wrong) synced catalog data, the buffer still inflates past the real cap", () => { - // Documents the known, out-of-scope limitation: nothing in our codebase can - // psychically know the real upstream cap before an operator (or a future - // self-healing mechanism) supplies a correction. This is the reported symptom's - // starting state, not something this fix promises to eliminate on first contact. +test("#6524: with only the (wrong) synced catalog data, the buffer no longer inflates (#9507)", () => { + // #9507: the reasoning-token buffer never enlarges an explicit client + // max_tokens, so even with a wrong synced output cap (1048576) the client's + // 64000 is forwarded verbatim — which already stays under the real upstream + // cap (65536), fully resolving the reporter's symptom without needing an + // operator override. (Previously this asserted 96000, the inflation past the + // real cap; that inflation is the defect #9507 removes.) const result = resolveReasoningBufferedMaxTokens(TARGET, 64000); - assert.equal(result, 96000); + assert.equal(result, 64000); }); test("#6524: an operator-set max_token override now clamps the reasoning buffer to the real cap", () => {