From 46e6c01a01350962996abf50143eec9d1c39bc6d Mon Sep 17 00:00:00 2001 From: thienpv Date: Sun, 5 Jul 2026 17:32:25 +0700 Subject: [PATCH] fix(claude): reconcile max_tokens vs thinking budget and lift per-model ceiling (#2381) On the translated OpenAI->Claude path, adjustMaxTokens capped max_tokens before applyThinking set thinking.budget_tokens, so max-effort budget (128000) could exceed a 64k-clamped max_tokens -> Anthropic 400. prepareClaudeRequest now reconciles after the budget is known: prefer raising max_tokens, only shrink budget when it meets/exceeds the ceiling. Also lift the global 64000 cap: the ceiling is now the model's real maxOutput, so high-output models (fable/mythos, opus-4.8/sonnet-4.6) get their full budget. adjustMaxTokens gains an optional ceiling arg (default unchanged, callers untouched); openai-to-claude passes the model maxOutput. Native Claude Code passthrough is unaffected. Co-Authored-By: Claude Co-authored-by: Cursor --- open-sse/translator/formats/claude.js | 21 +++- open-sse/translator/formats/maxTokens.js | 14 ++- .../translator/request/openai-to-claude.js | 7 +- .../translator/bugs-toClaude-context.test.js | 95 +++++++++++++++++++ 4 files changed, 129 insertions(+), 8 deletions(-) diff --git a/open-sse/translator/formats/claude.js b/open-sse/translator/formats/claude.js index ec6e6c47..adbd3ce8 100644 --- a/open-sse/translator/formats/claude.js +++ b/open-sse/translator/formats/claude.js @@ -192,10 +192,27 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne delete body.output_config; } - // Clamp max_tokens to the model output ceiling (never above DEFAULT_MAX_TOKENS) + // Clamp max_tokens to the model's real output ceiling. Models whose caps + // declare a higher maxOutput (e.g. Opus 4.8 / Sonnet 4.6 = 128000) are allowed + // up to it, so max-effort thinking gets full budget; others fall back to the + // conservative 64000 default. if (body.max_tokens) { - const ceiling = Math.min(getCapabilitiesForModel(provider, body.model).maxOutput, DEFAULT_MAX_TOKENS); + const ceiling = getCapabilitiesForModel(provider, body.model).maxOutput || DEFAULT_MAX_TOKENS; if (body.max_tokens > ceiling) body.max_tokens = ceiling; + + // Reconcile against thinking budget. applyThinking (thinkingUnified.js) runs + // AFTER adjustMaxTokens capped max_tokens, and the claude-budget format maps + // max effort → budget_tokens 128000 — larger than the clamped max_tokens. + // Anthropic requires max_tokens strictly greater than budget_tokens (else 400). + // Prefer raising max_tokens to preserve the requested thinking depth; if the + // budget alone meets/exceeds the ceiling, cap output and shrink the budget so + // some tokens remain for the answer. + if (body.thinking?.type === "enabled" && body.thinking.budget_tokens && body.thinking.budget_tokens >= body.max_tokens) { + body.max_tokens = Math.min(body.thinking.budget_tokens + 1024, ceiling); + if (body.thinking.budget_tokens >= body.max_tokens) { + body.thinking.budget_tokens = Math.max(1024, body.max_tokens - 1024); + } + } } // 1. System: remove all cache_control, add only to last block with ttl 1h diff --git a/open-sse/translator/formats/maxTokens.js b/open-sse/translator/formats/maxTokens.js index 0e5b36f2..4d2cd209 100644 --- a/open-sse/translator/formats/maxTokens.js +++ b/open-sse/translator/formats/maxTokens.js @@ -3,9 +3,13 @@ import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../config/runtimeConf /** * Adjust max_tokens based on request context * @param {object} body - Request body + * @param {number} [ceiling=DEFAULT_MAX_TOKENS] - Upper bound for max_tokens. + * Callers with model context (e.g. openai-to-claude) pass the model's real + * maxOutput so high-output models (Opus 4.8 = 128000) aren't pre-clamped to + * the conservative 64000 default before the model-aware step sees them. * @returns {number} Adjusted max_tokens */ -export function adjustMaxTokens(body) { +export function adjustMaxTokens(body, ceiling = DEFAULT_MAX_TOKENS) { let maxTokens = body.max_tokens || DEFAULT_MAX_TOKENS; // Auto-increase for tool calling to prevent truncated arguments (min never above max) @@ -16,14 +20,14 @@ export function adjustMaxTokens(body) { } // Ensure max_tokens > thinking.budget_tokens (Claude API requirement) - // Claude API requires strictly greater, so add buffer instead of using DEFAULT_MAX_TOKENS - // which could equal budget_tokens when budget_tokens >= 64000 + // Claude API requires strictly greater, so add buffer instead of using the + // ceiling which could equal budget_tokens when budget_tokens >= ceiling if (body.thinking?.budget_tokens && maxTokens <= body.thinking.budget_tokens) { maxTokens = body.thinking.budget_tokens + 1024; } - // Never exceed the global ceiling - if (maxTokens > DEFAULT_MAX_TOKENS) maxTokens = DEFAULT_MAX_TOKENS; + // Never exceed the ceiling + if (maxTokens > ceiling) maxTokens = ceiling; return maxTokens; } diff --git a/open-sse/translator/request/openai-to-claude.js b/open-sse/translator/request/openai-to-claude.js index bc73149b..5c7d7613 100644 --- a/open-sse/translator/request/openai-to-claude.js +++ b/open-sse/translator/request/openai-to-claude.js @@ -6,6 +6,7 @@ import { safeParseJSON } from "../concerns/json.js"; import { parseDataUri } from "../concerns/image.js"; import { extractTextContent } from "../formats/gemini.js"; import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js"; +import { getCapabilitiesForModel } from "../../providers/capabilities.js"; // Empty prefix matches real Claude Code behavior (no tool name prefix). // Previously "proxy_" was used but this is a detectable fingerprint difference. @@ -15,9 +16,13 @@ const CLAUDE_OAUTH_TOOL_PREFIX = ""; export function openaiToClaudeRequest(model, body, stream) { // Tool name mapping for Claude OAuth (capitalizedName → originalName) const toolNameMap = new Map(); + // Cap max_tokens at the model's real output ceiling (e.g. Opus 4.8 = 128000), + // not the conservative 64000 default — otherwise a high-output model is + // pre-clamped here before prepareClaudeRequest's model-aware step runs. + const modelCeiling = getCapabilitiesForModel(null, model).maxOutput || undefined; const result = { model: model, - max_tokens: adjustMaxTokens(body), + max_tokens: adjustMaxTokens(body, modelCeiling), stream: stream }; diff --git a/tests/translator/bugs-toClaude-context.test.js b/tests/translator/bugs-toClaude-context.test.js index 60981e8e..96b704e8 100644 --- a/tests/translator/bugs-toClaude-context.test.js +++ b/tests/translator/bugs-toClaude-context.test.js @@ -67,6 +67,101 @@ describe("OpenAI → Claude context mapping", () => { expect(JSON.stringify(out), "remote image dropped").toContain("pic.png"); }); + // prepareClaudeRequest reconciles max_tokens vs thinking.budget_tokens. + // applyThinking runs after adjustMaxTokens caps max_tokens, so a claude-budget + // model at "max" effort (budget 128000) can exceed the clamped max_tokens and + // trip Anthropic's "max_tokens > budget_tokens" rule (400). See claude.js. + describe("max_tokens vs thinking.budget_tokens reconciliation", () => { + // 64k-ceiling model (maxOutput 64000) + max-effort budget 128000: budget alone + // exceeds the ceiling → cap max_tokens at 64000 and shrink budget below it. + it("max effort budget on a 64k model → budget < max_tokens ≤ 64000", () => { + const out = prepareClaudeRequest({ + model: "claude-opus-4-20250514", + max_tokens: 64000, + thinking: { type: "enabled", budget_tokens: 128000 }, + messages: [{ role: "user", content: "q" }], + }, "anthropic"); + expect(out.max_tokens).toBe(64000); + expect(out.thinking.budget_tokens).toBeLessThan(out.max_tokens); + expect(out.thinking.budget_tokens).toBeGreaterThan(0); + }); + + // Budget fits under the ceiling but exceeds a small client max_tokens → + // raise max_tokens to fit, preserving the requested thinking depth. + it("xhigh budget with a low client max_tokens → raise max_tokens, preserve budget", () => { + const out = prepareClaudeRequest({ + model: "claude-opus-4-20250514", + max_tokens: 16000, + thinking: { type: "enabled", budget_tokens: 32768 }, + messages: [{ role: "user", content: "q" }], + }, "anthropic"); + expect(out.thinking.budget_tokens).toBe(32768); + expect(out.max_tokens).toBe(33792); // 32768 + 1024, under the 64000 ceiling + }); + + // Budget already below max_tokens → nothing to reconcile. + it("high budget under max_tokens → both unchanged", () => { + const out = prepareClaudeRequest({ + model: "claude-opus-4-20250514", + max_tokens: 64000, + thinking: { type: "enabled", budget_tokens: 24576 }, + messages: [{ role: "user", content: "q" }], + }, "anthropic"); + expect(out.max_tokens).toBe(64000); + expect(out.thinking.budget_tokens).toBe(24576); + }); + + // Non-budget thinking shapes (adaptive / disabled) carry no budget_tokens → + // the reconciliation must never touch them. + it("adaptive thinking (no budget_tokens) is left untouched", () => { + const out = prepareClaudeRequest({ + model: "claude-opus-4-20250514", + max_tokens: 64000, + thinking: { type: "adaptive" }, + messages: [{ role: "user", content: "q" }], + }, "anthropic"); + expect(out.max_tokens).toBe(64000); + expect(out.thinking).toEqual({ type: "adaptive" }); + }); + + // Lifted ceiling: a claude-budget model whose caps declare maxOutput 128000 + // (e.g. fable) may use the full budget at max effort instead of being pinned + // to the conservative 64000 default. + it("max effort budget on a 128k model → max_tokens up to 128000, budget preserved just under", () => { + const out = prepareClaudeRequest({ + model: "claude-fable-5", + max_tokens: 64000, + thinking: { type: "enabled", budget_tokens: 128000 }, + messages: [{ role: "user", content: "q" }], + }, "anthropic"); + expect(out.max_tokens).toBe(128000); + expect(out.thinking.budget_tokens).toBe(126976); // 128000 - 1024 + expect(out.thinking.budget_tokens).toBeLessThan(out.max_tokens); + }); + + // Regression: a default 64k-ceiling model still clamps an over-large client + // max_tokens down to 64000 (the lift is per-model, not global). + it("over-large client max_tokens on a 64k model is still clamped to 64000", () => { + const out = prepareClaudeRequest({ + model: "claude-opus-4-20250514", + max_tokens: 120000, + messages: [{ role: "user", content: "q" }], + }, "anthropic"); + expect(out.max_tokens).toBe(64000); + }); + + // Lifted ceiling for a 128k model: a large client max_tokens is now allowed + // through instead of being clamped to 64000. + it("large client max_tokens on a 128k model is allowed up to maxOutput", () => { + const out = prepareClaudeRequest({ + model: "claude-fable-5", + max_tokens: 100000, + messages: [{ role: "user", content: "q" }], + }, "anthropic"); + expect(out.max_tokens).toBe(100000); + }); + }); + it("DeepSeek Claude transport adds a thinking placeholder before tool_use in thinking mode", () => { const out = prepareClaudeRequest({ model: "deepseek-v4-pro",