diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 50f2973bb7..8e7262c453 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -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 | undefined +): { maxTokens: number; thinking: Record | 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 = { ...thinking }; + if (fittedBudget < requestedBudget) { + adjustedThinking.budget_tokens = fittedBudget; + } + return { maxTokens: target, thinking: adjustedThinking }; +} + type ClaudeContentBlock = Record; 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 diff --git a/tests/unit/translator-openai-to-claude.test.ts b/tests/unit/translator-openai-to-claude.test.ts index 2c6b63a0f3..c7d77f69ab 100644 --- a/tests/unit/translator-openai-to-claude.test.ts +++ b/tests/unit/translator-openai-to-claude.test.ts @@ -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", () => {