mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
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 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
decolua
co-authored by
Claude
Cursor
parent
5041494e1c
commit
46e6c01a01
@@ -192,10 +192,27 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne
|
|||||||
delete body.output_config;
|
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) {
|
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;
|
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
|
// 1. System: remove all cache_control, add only to last block with ttl 1h
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../config/runtimeConf
|
|||||||
/**
|
/**
|
||||||
* Adjust max_tokens based on request context
|
* Adjust max_tokens based on request context
|
||||||
* @param {object} body - Request body
|
* @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
|
* @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;
|
let maxTokens = body.max_tokens || DEFAULT_MAX_TOKENS;
|
||||||
|
|
||||||
// Auto-increase for tool calling to prevent truncated arguments (min never above max)
|
// 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)
|
// Ensure max_tokens > thinking.budget_tokens (Claude API requirement)
|
||||||
// Claude API requires strictly greater, so add buffer instead of using DEFAULT_MAX_TOKENS
|
// Claude API requires strictly greater, so add buffer instead of using the
|
||||||
// which could equal budget_tokens when budget_tokens >= 64000
|
// ceiling which could equal budget_tokens when budget_tokens >= ceiling
|
||||||
if (body.thinking?.budget_tokens && maxTokens <= body.thinking.budget_tokens) {
|
if (body.thinking?.budget_tokens && maxTokens <= body.thinking.budget_tokens) {
|
||||||
maxTokens = body.thinking.budget_tokens + 1024;
|
maxTokens = body.thinking.budget_tokens + 1024;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never exceed the global ceiling
|
// Never exceed the ceiling
|
||||||
if (maxTokens > DEFAULT_MAX_TOKENS) maxTokens = DEFAULT_MAX_TOKENS;
|
if (maxTokens > ceiling) maxTokens = ceiling;
|
||||||
|
|
||||||
return maxTokens;
|
return maxTokens;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { safeParseJSON } from "../concerns/json.js";
|
|||||||
import { parseDataUri } from "../concerns/image.js";
|
import { parseDataUri } from "../concerns/image.js";
|
||||||
import { extractTextContent } from "../formats/gemini.js";
|
import { extractTextContent } from "../formats/gemini.js";
|
||||||
import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.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).
|
// Empty prefix matches real Claude Code behavior (no tool name prefix).
|
||||||
// Previously "proxy_" was used but this is a detectable fingerprint difference.
|
// 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) {
|
export function openaiToClaudeRequest(model, body, stream) {
|
||||||
// Tool name mapping for Claude OAuth (capitalizedName → originalName)
|
// Tool name mapping for Claude OAuth (capitalizedName → originalName)
|
||||||
const toolNameMap = new Map();
|
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 = {
|
const result = {
|
||||||
model: model,
|
model: model,
|
||||||
max_tokens: adjustMaxTokens(body),
|
max_tokens: adjustMaxTokens(body, modelCeiling),
|
||||||
stream: stream
|
stream: stream
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,101 @@ describe("OpenAI → Claude context mapping", () => {
|
|||||||
expect(JSON.stringify(out), "remote image dropped").toContain("pic.png");
|
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", () => {
|
it("DeepSeek Claude transport adds a thinking placeholder before tool_use in thinking mode", () => {
|
||||||
const out = prepareClaudeRequest({
|
const out = prepareClaudeRequest({
|
||||||
model: "deepseek-v4-pro",
|
model: "deepseek-v4-pro",
|
||||||
|
|||||||
Reference in New Issue
Block a user