mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user