From 0459e0ed929abc97b7c74b9cba819c1e799a5a49 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Thu, 1 Oct 2026 12:58:03 +0800 Subject: [PATCH 1/2] fix(anthropic): clamp budget thinking to the model's real output maximum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The budget-thinking branch sized `max_tokens` with a flat REASONING_MAX_TOKENS_CEILING of 32000, so a caller asking for 128000 on Opus 4.6 or Sonnet 4.6 silently got 32000 — a quarter of what those models document. The adaptive branch directly above already honours an explicit caller limit above 32k; this one overrode it. The constant dates from Claude 4.x models that really stopped near 32k. Opus 4.6 and Sonnet 4.6 document 128K synchronous output and Haiku 4.5 documents 64K, so the flat value is no longer a safe ceiling for the families that take this path: `usesAdaptiveThinking` sends sonnet >= 5, opus >= 4.7 and fable down the adaptive branch, leaving exactly opus-4-6, sonnet-4-6 and haiku-4-5 here, and all three exceed 32000. It now clamps to `configuredMaxOut`, the per-model value already resolved a few lines above for the omitted-limit default (`modelMaxOutputTokens` falling back to `defaultMaxOutputTokens`), so this reuses that lookup rather than adding a second one. REASONING_MAX_TOKENS_CEILING stays the fallback for a target whose ceiling is unknown, which keeps today's behaviour for an unconfigured provider. Measured before and after, with the provider carrying the seeded maxima: opus-4-6 asked 128000 -> 32000 before, 128000 after sonnet-4-6 asked 128000 -> 32000 before, 128000 after haiku-4-5 asked 128000 -> 32000 before, 64000 after (its real maximum) opus-4-6 asked 10000 -> 24576 both (budget+headroom, unchanged) no maxima asked 128000 -> 32000 both (fallback preserved) Anthropic's invariant `max_tokens > thinking.budget_tokens` holds in every case above; the test asserts it alongside the figures. Follow-up to #6329, where the per-model maxima landed and the reviewer noted this clamp as still outstanding. Verification: `bun run typecheck` clean; 73/73 in tests/adapters/anthropic/anthropic-reasoning.test.ts. The new case fails with "Expected: 128000, Received: 32000" without the source change. tests/adapters/anthropic reports the same 654 pass / 30 fail with and without it — those are pre-existing account-pause/pacing failures on dev, unrelated to max_tokens. Co-Authored-By: Claude Code --- src/adapters/anthropic.ts | 12 ++++++++- .../anthropic/anthropic-reasoning.test.ts | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 45247697495..8e274b203c0 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1050,7 +1050,17 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // always exceeds the budget within a model-safe ceiling, reserving room for visible output. const maxOut = parsed.options.maxOutputTokens ?? omittedMaxTokens; const wantBudget = reasoningBudget(effectiveReasoning); - const maxTokens = Math.min(REASONING_MAX_TOKENS_CEILING, Math.max(maxOut, wantBudget + OUTPUT_HEADROOM)); + // Clamp to what the TARGET can emit, not to a flat 32k. REASONING_MAX_TOKENS_CEILING + // dates from Claude 4.x models that really stopped near 32k; Opus 4.6 and Sonnet 4.6 + // document 128K and Haiku 4.5 64K, so the flat ceiling silently cut an explicit + // caller `max_tokens` to a quarter of what the model supports — the adaptive branch + // above honours the caller's figure, and this one did not. A provider that states the + // model's real maximum is more authoritative than the constant; it stays the fallback + // for a target whose ceiling is unknown. + const modelCeiling = typeof configuredMaxOut === "number" && configuredMaxOut > 0 + ? configuredMaxOut + : REASONING_MAX_TOKENS_CEILING; + const maxTokens = Math.min(modelCeiling, Math.max(maxOut, wantBudget + OUTPUT_HEADROOM)); const budget = Math.max(MIN_THINKING_BUDGET, Math.min(wantBudget, maxTokens - OUTPUT_FLOOR)); body.max_tokens = maxTokens; body.thinking = { type: "enabled", budget_tokens: budget }; diff --git a/tests/adapters/anthropic/anthropic-reasoning.test.ts b/tests/adapters/anthropic/anthropic-reasoning.test.ts index 05ca00192c5..1a978de19d2 100644 --- a/tests/adapters/anthropic/anthropic-reasoning.test.ts +++ b/tests/adapters/anthropic/anthropic-reasoning.test.ts @@ -287,6 +287,32 @@ describe("anthropic extended-thinking gate", () => { expect(b.max_tokens as number).toBe(64000); }); + // The adaptive branch above already honours an explicit limit above 32k. Budget thinking did + // not: it clamped to a flat REASONING_MAX_TOKENS_CEILING (32000) even for Opus 4.6 and Sonnet + // 4.6, which document 128K output, so a caller asking for 128000 silently got a quarter of it. + test("budget thinking clamps to the model's real maximum, not a flat 32k", async () => { + const seeded = { + ...provider, + defaultMaxOutputTokens: 64_000, + modelMaxOutputTokens: { "claude-opus-4-6": 128_000, "claude-sonnet-4-6": 128_000 }, + }; + for (const modelId of ["claude-opus-4-6", "claude-sonnet-4-6"]) { + for (const asked of [undefined, 128_000]) { + const b = await bodyOf(parsed("high", asked === undefined ? {} : { maxOutputTokens: asked }, modelId), seeded); + expect(b.max_tokens as number, modelId).toBe(128_000); + expect(b.max_tokens as number).toBeGreaterThan((b.thinking as { budget_tokens: number }).budget_tokens); + } + } + // Haiku 4.5 really is 64K, so it must be capped there rather than raised to 128000. + const haiku = await bodyOf(parsed("high", { maxOutputTokens: 128_000 }, "claude-haiku-4-5"), seeded); + expect(haiku.max_tokens as number).toBe(64_000); + // A small explicit limit is still only lifted to budget+headroom, exactly as before. + expect((await bodyOf(parsed("high", { maxOutputTokens: 10_000 }, "claude-opus-4-6"), seeded)).max_tokens).toBe(24_576); + // With no configured maxima the flat 32k ceiling remains the fallback. + const bare = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", apiKey: "sk-x", authMode: "apiKey" } as unknown as OcxProviderConfig; + expect((await bodyOf(parsed("high", { maxOutputTokens: 128_000 }, "claude-opus-4-6"), bare)).max_tokens).toBe(32_000); + }); + test("configured provider output budget replaces the 8192 default when the caller omits max_output_tokens", async () => { const budgeted = { ...provider, defaultMaxOutputTokens: 64_000, modelMaxOutputTokens: { "claude-fable-5": 32_000 } }; // No reasoning: the configured budget is the wire max_tokens. From 3f94e5c9ca8cdcc969f50738c0882d6e5b80f50f Mon Sep 17 00:00:00 2001 From: Vadym O Date: Thu, 1 Oct 2026 13:09:33 +0800 Subject: [PATCH 2/2] fix(anthropic): let the stated ceiling only raise, never lower, the thinking clamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit's finding on #6355: the first revision treated `configuredMaxOut` as a capability ceiling, but that value is two different things depending on the provider. On the canonical Anthropic entries it is a documented Messages API maximum. On any other provider it is a plain fallback budget for requests that omit `max_output_tokens`. Reading the second as the first meant an operator's deliberately cheap `defaultMaxOutputTokens: 8192` was interpreted as "this model cannot emit more", so an explicit 128000 request was clamped to 8192 with the thinking budget squeezed to 4096 — below the 32000/16384 this path produced before the change. Reproduced before fixing: defaultMaxOutputTokens 8192, explicit 128000, high effort before this commit: max_tokens 8192, budget_tokens 4096 base (pre-PR): max_tokens 32000, budget_tokens 16384 The clamp now takes the LARGER of the configured value and REASONING_MAX_TOKENS_CEILING, so the historical ceiling acts as a floor: a stated maximum can lift it where the vendor documents more, and a low fallback budget can never drag it below what the flat constant already allowed. seeded maxima opus-4-6 explicit 128000 -> 128000 (its documented maximum) seeded maxima haiku-4-5 explicit 128000 -> 64000 (its documented maximum) cheap 8192 opus-4-6 explicit 128000 -> 32000 (floor held, budget 16384) no maxima opus-4-6 explicit 128000 -> 32000 (unchanged) The fallback budget still decides an omitted request, which is what it is for, and that is asserted too so the two roles stay distinguished. Verification: `bun run typecheck` clean; 74/74 in anthropic-reasoning.test.ts, the new case covering the low-budget regression with the explicit assertion that budget_tokens stays 16384. tests/adapters/anthropic unchanged at the same pre-existing 30 failures. Co-Authored-By: Claude Code --- src/adapters/anthropic.ts | 23 ++++++++++++------- .../anthropic/anthropic-reasoning.test.ts | 18 +++++++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 8e274b203c0..399b51f969a 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1052,15 +1052,22 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti const wantBudget = reasoningBudget(effectiveReasoning); // Clamp to what the TARGET can emit, not to a flat 32k. REASONING_MAX_TOKENS_CEILING // dates from Claude 4.x models that really stopped near 32k; Opus 4.6 and Sonnet 4.6 - // document 128K and Haiku 4.5 64K, so the flat ceiling silently cut an explicit - // caller `max_tokens` to a quarter of what the model supports — the adaptive branch - // above honours the caller's figure, and this one did not. A provider that states the - // model's real maximum is more authoritative than the constant; it stays the fallback - // for a target whose ceiling is unknown. - const modelCeiling = typeof configuredMaxOut === "number" && configuredMaxOut > 0 - ? configuredMaxOut + // document 128K and Haiku 4.5 64K, so the flat ceiling cut an explicit caller + // `max_tokens` to a quarter of what the model supports — the adaptive branch above + // honours the caller's figure, and this one did not. + // + // `configuredMaxOut` only ever RAISES this ceiling. It is two different things at once: + // a capability statement on the canonical Anthropic entries (documented Messages maxima) + // and a plain fallback budget for omitted requests anywhere else. Letting it lower the + // ceiling would read an operator's cheap `defaultMaxOutputTokens: 8192` as "this model + // cannot emit more" and clamp an explicit 128000 request to 8192 — below what this path + // sent before and with the thinking budget squeezed to 4096. Taking the larger of the two + // keeps the historical ceiling as a floor, so a low fallback budget cannot regress a + // caller who asked for more. + const statedCeiling = typeof configuredMaxOut === "number" && configuredMaxOut > 0 + ? Math.max(configuredMaxOut, REASONING_MAX_TOKENS_CEILING) : REASONING_MAX_TOKENS_CEILING; - const maxTokens = Math.min(modelCeiling, Math.max(maxOut, wantBudget + OUTPUT_HEADROOM)); + const maxTokens = Math.min(statedCeiling, Math.max(maxOut, wantBudget + OUTPUT_HEADROOM)); const budget = Math.max(MIN_THINKING_BUDGET, Math.min(wantBudget, maxTokens - OUTPUT_FLOOR)); body.max_tokens = maxTokens; body.thinking = { type: "enabled", budget_tokens: budget }; diff --git a/tests/adapters/anthropic/anthropic-reasoning.test.ts b/tests/adapters/anthropic/anthropic-reasoning.test.ts index 1a978de19d2..171f010d5cc 100644 --- a/tests/adapters/anthropic/anthropic-reasoning.test.ts +++ b/tests/adapters/anthropic/anthropic-reasoning.test.ts @@ -313,6 +313,24 @@ describe("anthropic extended-thinking gate", () => { expect((await bodyOf(parsed("high", { maxOutputTokens: 128_000 }, "claude-opus-4-6"), bare)).max_tokens).toBe(32_000); }); + // `defaultMaxOutputTokens` is a fallback budget for omitted requests, not a statement that the + // model cannot emit more. Reading it as a capability ceiling would let a deliberately cheap + // budget clamp an explicit request BELOW what this path sent before, squeezing the thinking + // budget with it, so the configured value may only ever raise the ceiling. + test("a low fallback budget never lowers the ceiling for a larger explicit request", async () => { + const cheap = { ...provider, defaultMaxOutputTokens: 8192 } as unknown as OcxProviderConfig; + const b = await bodyOf(parsed("high", { maxOutputTokens: 128_000 }, "claude-opus-4-6"), cheap); + // Regression: this returned max_tokens 8192 / budget 4096, worse than the 32000/16384 the + // flat ceiling produced, because the fallback budget was treated as a capability. + expect(b.max_tokens as number).toBe(32_000); + expect((b.thinking as { budget_tokens: number }).budget_tokens).toBe(16_384); + // The budget still decides an OMITTED request, which is what it is actually for. + expect((await bodyOf(parsed("none", {}, "claude-opus-4-6"), cheap)).max_tokens).toBe(8192); + // A model whose stated maximum really is higher still gets it. + const seeded = { ...cheap, modelMaxOutputTokens: { "claude-opus-4-6": 128_000 } } as unknown as OcxProviderConfig; + expect((await bodyOf(parsed("high", { maxOutputTokens: 128_000 }, "claude-opus-4-6"), seeded)).max_tokens).toBe(128_000); + }); + test("configured provider output budget replaces the 8192 default when the caller omits max_output_tokens", async () => { const budgeted = { ...provider, defaultMaxOutputTokens: 64_000, modelMaxOutputTokens: { "claude-fable-5": 32_000 } }; // No reasoning: the configured budget is the wire max_tokens.