fix(claude): cap-aware thinking budget fit for max_tokens (#2327)

Integrated into release/v3.8.0 — fixes HTTP 400 max_tokens > 128000 from Anthropic when using high-effort thinking with Opus 4.7
This commit is contained in:
thepigdestroyer
2026-05-17 23:37:40 +01:00
committed by GitHub
parent 0f15797e01
commit 696e16b361
2 changed files with 132 additions and 12 deletions

View File

@@ -5,12 +5,93 @@ import { supportsXHighEffort } from "../../config/providerModels.ts";
import { adjustMaxTokens } from "../helpers/maxTokensHelper.ts";
import { sanitizeToolId } from "../helpers/schemaCoercion.ts";
import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts";
import { capMaxOutputTokens } from "../../../src/lib/modelCapabilities.ts";
// Prefix for Claude OAuth tool names to avoid conflicts
// Can be disabled per-request via body._disableToolPrefix = true
export const CLAUDE_OAUTH_TOOL_PREFIX = "proxy_";
const CLAUDE_TOOL_CHOICE_REQUIRED = "an" + "y";
// Anthropic constraints for the thinking + max_tokens contract:
// - thinking.budget_tokens must be >= 1024 when thinking is enabled
// - max_tokens must be > thinking.budget_tokens (covers thinking + response)
// - max_tokens must be <= model output cap (e.g. 128000 for Opus 4.7)
const MIN_CLAUDE_THINKING_BUDGET = 1024;
const MIN_RESPONSE_ROOM = 1024;
const FALLBACK_OUTPUT_CAP = 128000;
function safeCapMaxOutputTokens(model: string): number {
try {
const cap = capMaxOutputTokens(model);
return typeof cap === "number" && cap > 0 ? cap : FALLBACK_OUTPUT_CAP;
} catch {
return FALLBACK_OUTPUT_CAP;
}
}
/**
* Fit Claude thinking budget within the model's max output cap.
*
* Replaces the previous unconditional `max_tokens = budget + 8192` inflation,
* which could exceed the model output cap (e.g. Opus 4.7's 128000 ceiling) and
* trigger HTTP 400 from Anthropic ("max_tokens > 128000").
*
* Strategy (preserves caller intent up to the model cap):
* - Preserve caller's max_tokens as response room (floored to MIN_RESPONSE_ROOM)
* - Target max_tokens = responseRoom + requestedBudget, capped at modelCap
* - fittedBudget = max_tokens - responseRoom (the thinking budget actually used)
* - If the cap squeezes fittedBudget below the Anthropic minimum, retry with
* responseRoom shrunk to MIN_RESPONSE_ROOM; if still below MIN, disable
* thinking entirely (cap too tight for any reasoning).
*
* Worked example (real-world Opus 4.7 case that previously 400'd):
* caller max_tokens = 32000, reasoning_effort=high → budget = 131072,
* model cap = 128000.
* responseRoom = max(32000, 1024) = 32000
* target = min(32000 + 131072, 128000) = 128000
* fittedBudget = 128000 - 32000 = 96000 (>= 1024, OK)
* → max_tokens=128000, budget_tokens=96000 (vs. the old buggy 139264 / 131072).
*/
export function fitThinkingToMaxTokens(
model: string,
callerMaxTokens: number,
thinking: Record<string, unknown> | undefined
): { maxTokens: number; thinking: Record<string, unknown> | undefined } {
const modelCap = safeCapMaxOutputTokens(model);
const requestedBudget = Number(thinking?.budget_tokens) || 0;
// No budgeted thinking — just cap max_tokens to the model output ceiling.
if (!thinking || requestedBudget <= 0) {
return {
maxTokens: Math.min(Math.max(callerMaxTokens, 1), modelCap),
thinking,
};
}
let responseRoom = Math.max(callerMaxTokens, MIN_RESPONSE_ROOM);
let target = Math.min(responseRoom + requestedBudget, modelCap);
let fittedBudget = target - responseRoom;
// If the cap squeezed thinking below Anthropic's floor, try shrinking
// response room to MIN_RESPONSE_ROOM to recover budget.
if (fittedBudget < MIN_CLAUDE_THINKING_BUDGET && responseRoom > MIN_RESPONSE_ROOM) {
responseRoom = MIN_RESPONSE_ROOM;
target = Math.min(responseRoom + requestedBudget, modelCap);
fittedBudget = target - responseRoom;
}
// Cap too tight for any thinking — disable rather than send an invalid request.
if (fittedBudget < MIN_CLAUDE_THINKING_BUDGET) {
return { maxTokens: modelCap, thinking: undefined };
}
const adjustedThinking: Record<string, unknown> = { ...thinking };
if (fittedBudget < requestedBudget) {
adjustedThinking.budget_tokens = fittedBudget;
}
return { maxTokens: target, thinking: adjustedThinking };
}
type ClaudeContentBlock = Record<string, unknown>;
type ClaudeMessage = {
role: string;
@@ -374,18 +455,21 @@ export function openaiToClaudeRequest(model, body, stream) {
type: "enabled",
budget_tokens: budget,
};
// Claude requires max_tokens > budget_tokens
if (result.max_tokens <= budget) {
result.max_tokens = budget + 8192;
}
}
}
}
// Ensure max_tokens > budget_tokens for all thinking configurations (#627)
const budgetTokens = Number(result.thinking?.budget_tokens) || 0;
if (budgetTokens > 0 && result.max_tokens <= budgetTokens) {
result.max_tokens = budgetTokens + 8192;
// Fit thinking budget within the model's output cap and ensure
// max_tokens > budget_tokens for all thinking configurations (#627).
// Replaces the previous unconditional `budget + 8192` inflation, which
// could exceed model caps (e.g. Opus 4.7's 128000 ceiling) and trigger
// HTTP 400 from Anthropic.
const fitted = fitThinkingToMaxTokens(model, Number(result.max_tokens) || 0, result.thinking);
result.max_tokens = fitted.maxTokens;
if (fitted.thinking === undefined) {
delete result.thinking;
} else {
result.thinking = fitted.thinking;
}
// Attach toolNameMap to result for response translation

View File

@@ -237,6 +237,9 @@ test("OpenAI -> Claude maps tool_choice and injects response_format instructions
});
test("OpenAI -> Claude turns reasoning settings into thinking budgets and expands max tokens", () => {
// `claude-4-sonnet` is a fixture that doesn't match any spec → default cap = 8192.
// fitThinkingToMaxTokens floors response room at MIN_RESPONSE_ROOM (1024)
// and targets max_tokens = responseRoom + budget capped at modelCap.
const effortResult = openaiToClaudeRequest(
"claude-4-sonnet",
{
@@ -248,7 +251,8 @@ test("OpenAI -> Claude turns reasoning settings into thinking budgets and expand
);
assert.deepEqual(effortResult.thinking, { type: "enabled", budget_tokens: 1024 });
assert.equal(effortResult.max_tokens, 9216);
// responseRoom=max(10,1024)=1024; target=min(1024+1024, 8192)=2048
assert.equal(effortResult.max_tokens, 2048);
const explicitThinkingResult = openaiToClaudeRequest(
"claude-4-sonnet",
@@ -265,7 +269,8 @@ test("OpenAI -> Claude turns reasoning settings into thinking budgets and expand
budget_tokens: 2000,
max_tokens: 3000,
});
assert.equal(explicitThinkingResult.max_tokens, 10192);
// responseRoom=max(1000,1024)=1024; target=min(1024+2000, 8192)=3024
assert.equal(explicitThinkingResult.max_tokens, 3024);
});
test("OpenAI -> Claude preserves xhigh only for Claude models that expose it", () => {
@@ -290,9 +295,40 @@ test("OpenAI -> Claude preserves xhigh only for Claude models that expose it", (
assert.deepEqual(preserved.thinking, { type: "adaptive" });
assert.deepEqual(preserved.output_config, { effort: "xhigh" });
assert.deepEqual(downgraded.thinking, { type: "enabled", budget_tokens: 131072 });
// standardModel (claude-opus-4-6) has output cap 128000.
// Requested budget 131072 is cap-fitted: target=min(1024+131072, 128000)=128000;
// fittedBudget=128000-1024=126976. budget shrinks to fit within model cap
// rather than producing invalid max_tokens=139264 that Anthropic rejects with 400.
assert.deepEqual(downgraded.thinking, { type: "enabled", budget_tokens: 126976 });
assert.equal(downgraded.output_config, undefined);
assert.equal(downgraded.max_tokens, 139264);
assert.equal(downgraded.max_tokens, 128000);
});
test("OpenAI -> Claude fits thinking budget within Opus 4.7 output cap (regression)", () => {
// Real-world OpenCode scenario: caller asks for max_tokens=32000 with high effort.
// High effort maps to budget=131072. The previous naive
// `budget + 8192 = 139264` exceeded Opus 4.7's 128000 output cap and caused
// HTTP 400 "max_tokens > 128000".
// fitThinkingToMaxTokens must preserve caller's 32000 response room and
// shrink budget to (128000 - 32000) = 96000.
const result = openaiToClaudeRequest(
"claude-opus-4-7",
{
messages: [{ role: "user", content: "Reason about something hard" }],
max_tokens: 32000,
reasoning_effort: "high",
},
false
);
assert.equal(result.max_tokens, 128000, "max_tokens must equal model cap, not 139264");
assert.ok(result.thinking, "thinking should remain enabled");
assert.equal((result.thinking as { type: string }).type, "enabled");
assert.equal(
(result.thinking as { budget_tokens: number }).budget_tokens,
96000,
"budget must shrink to (cap - caller max_tokens) to preserve response room"
);
});
test("OpenAI -> Claude can disable OAuth prefixes and Antigravity strips Claude-only prompting", () => {