diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 2180bef00e..d5e9c055ec 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -6,8 +6,8 @@ import { adjustMaxTokens } from "../helpers/maxTokensHelper.ts"; import { sanitizeToolId } from "../helpers/schemaCoercion.ts"; import { safeParseJSON } from "../helpers/jsonUtil.ts"; import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; -import { capMaxOutputTokens } from "../../../src/lib/modelCapabilities.ts"; import { isAdaptiveThinkingOnly } from "../../../src/shared/constants/modelSpecs.ts"; +import { fitThinkingToMaxTokens } from "./openai-to-claude/thinkingBudget.ts"; // Reasoning-effort levels Anthropic accepts on `output_config.effort`. Used to steer // adaptive-only Claude models (Opus 4.7+/Fable 5) without ever emitting a manual budget. @@ -36,93 +36,9 @@ function applyCopilotSummarizedThinkingDisplay( }; } -// 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; - -function safeCapMaxOutputTokens(model: string): number | null { - try { - const cap = capMaxOutputTokens(model); - return typeof cap === "number" && cap > 0 ? cap : null; - } catch { - return null; - } -} - -/** - * 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: - modelCap === null - ? Math.max(callerMaxTokens, 1) - : Math.min(Math.max(callerMaxTokens, 1), modelCap), - thinking, - }; - } - - let responseRoom = Math.max(callerMaxTokens, MIN_RESPONSE_ROOM); - let target = - modelCap === null - ? responseRoom + requestedBudget - : 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 = - modelCap === null - ? responseRoom + requestedBudget - : 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 ?? Math.max(callerMaxTokens, 1), thinking: undefined }; - } - - const adjustedThinking: Record = { ...thinking }; - if (fittedBudget < requestedBudget) { - adjustedThinking.budget_tokens = fittedBudget; - } - return { maxTokens: target, thinking: adjustedThinking }; -} +// Thinking-budget fitting extracted to a pure leaf; re-exported for external +// importers (tests). Host also uses fitThinkingToMaxTokens internally. +export { fitThinkingToMaxTokens } from "./openai-to-claude/thinkingBudget.ts"; type ClaudeContentBlock = Record; type ClaudeMessage = { diff --git a/open-sse/translator/request/openai-to-claude/thinkingBudget.ts b/open-sse/translator/request/openai-to-claude/thinkingBudget.ts new file mode 100644 index 0000000000..e78275570f --- /dev/null +++ b/open-sse/translator/request/openai-to-claude/thinkingBudget.ts @@ -0,0 +1,89 @@ +import { capMaxOutputTokens } from "../../../../src/lib/modelCapabilities.ts"; + +// 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; + +function safeCapMaxOutputTokens(model: string): number | null { + try { + const cap = capMaxOutputTokens(model); + return typeof cap === "number" && cap > 0 ? cap : null; + } catch { + return null; + } +} + +/** + * 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: + modelCap === null + ? Math.max(callerMaxTokens, 1) + : Math.min(Math.max(callerMaxTokens, 1), modelCap), + thinking, + }; + } + + let responseRoom = Math.max(callerMaxTokens, MIN_RESPONSE_ROOM); + let target = + modelCap === null + ? responseRoom + requestedBudget + : 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 = + modelCap === null + ? responseRoom + requestedBudget + : 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 ?? Math.max(callerMaxTokens, 1), thinking: undefined }; + } + + const adjustedThinking: Record = { ...thinking }; + if (fittedBudget < requestedBudget) { + adjustedThinking.budget_tokens = fittedBudget; + } + return { maxTokens: target, thinking: adjustedThinking }; +} diff --git a/tests/unit/openai-to-claude-thinking-budget-split.test.ts b/tests/unit/openai-to-claude-thinking-budget-split.test.ts new file mode 100644 index 0000000000..aa3076cd19 --- /dev/null +++ b/tests/unit/openai-to-claude-thinking-budget-split.test.ts @@ -0,0 +1,38 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +// Split-guard for the openai-to-claude thinking-budget extraction. +// `fitThinkingToMaxTokens` (+ its private helpers safeCapMaxOutputTokens / MIN_*) +// live in the pure leaf `openai-to-claude/thinkingBudget.ts`; the host re-exports +// the public symbol so external importers (tests) keep working unchanged. +const HERE = dirname(fileURLToPath(import.meta.url)); +const REQ = join(HERE, "../../open-sse/translator/request"); +const HOST = join(REQ, "openai-to-claude.ts"); +const LEAF = join(REQ, "openai-to-claude/thinkingBudget.ts"); + +test("leaf hosts fitThinkingToMaxTokens and does not import the host", () => { + const leaf = readFileSync(LEAF, "utf8"); + assert.match(leaf, /export function fitThinkingToMaxTokens\(/); + assert.match(leaf, /function safeCapMaxOutputTokens\(/); + assert.doesNotMatch(leaf, /from "\.\.\/openai-to-claude\.ts"/); +}); + +test("host re-exports fitThinkingToMaxTokens from the leaf", () => { + const host = readFileSync(HOST, "utf8"); + assert.match( + host, + /export \{ fitThinkingToMaxTokens \} from "\.\/openai-to-claude\/thinkingBudget\.ts"/ + ); +}); + +test("re-exported fitThinkingToMaxTokens is callable via the host module and behaves", async () => { + const mod = await import("../../open-sse/translator/request/openai-to-claude.ts"); + assert.equal(typeof mod.fitThinkingToMaxTokens, "function"); + // No budgeted thinking → max_tokens floored to >= 1, thinking passed through. + const out = mod.fitThinkingToMaxTokens("gpt-4o-mini", 0, undefined); + assert.equal(out.thinking, undefined); + assert.ok(out.maxTokens >= 1); +});