diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index d5e9c055ec..078d48e94a 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -165,6 +165,97 @@ export function openaiToClaudeRequest(model, body, stream) { result.stop_sequences = Array.isArray(body.stop) ? body.stop : [body.stop]; } + // Thinking configuration + // NOTE: computed BEFORE message-block conversion (below) so that + // `getContentBlocksFromMessage` knows whether the outbound request actually has + // extended thinking enabled — required to correctly gate the `redacted_thinking` + // replay-placeholder injection (#5945). This block has no dependency on + // `result.messages`/`toolNameMap`, so moving it earlier is safe. + if (body.thinking) { + result.thinking = { + type: body.thinking.type || "enabled", + ...(body.thinking.budget_tokens && { budget_tokens: body.thinking.budget_tokens }), + ...(body.thinking.max_tokens && { max_tokens: body.thinking.max_tokens }), + }; + } else if (body.reasoning_effort) { + // Convert OpenAI reasoning_effort to Claude thinking format (#627) + // Clients like OpenCode send reasoning_effort via @ai-sdk/openai-compatible + const requestedEffort = String(body.reasoning_effort).toLowerCase(); + const normalizedEffort = + requestedEffort === "max" && !supportsClaudeMaxEffort(model) + ? "high" + : requestedEffort === "xhigh" && !supportsXHighEffort("claude", model) + ? "high" + : requestedEffort; + if (isAdaptiveThinkingOnly(model)) { + // Opus 4.7+/Fable 5 removed manual extended thinking: a fixed `budget_tokens` + // (or `type:"enabled"`) is a hard 400. Steer EVERY level via adaptive + + // output_config.effort instead of the budget buckets below. Unrecognized levels + // leave thinking unset so the model keeps its adaptive default rather than 400ing + // on an invalid effort value. + if (ADAPTIVE_EFFORT_LEVELS.has(normalizedEffort)) { + result.thinking = { + type: "adaptive", + }; + result.output_config = { + ...(result.output_config || {}), + effort: normalizedEffort, + }; + } + } else if (normalizedEffort === "max" || normalizedEffort === "xhigh") { + result.thinking = { + type: "adaptive", + }; + result.output_config = { + ...(result.output_config || {}), + effort: normalizedEffort, + }; + } else { + const effortBudgetMap: Record = { + low: 1024, + medium: 10240, + high: 131072, + max: 131072, + }; + const budget = effortBudgetMap[normalizedEffort]; + if (budget !== undefined && budget > 0) { + result.thinking = { + type: "enabled", + budget_tokens: budget, + }; + } + } + } + + // 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 = applyCopilotSummarizedThinkingDisplay(fitted.thinking, body); + } + + delete result[COPILOT_REASONING_SUMMARY_MARKER]; + + // Final guard: Claude rejects `temperature` whenever extended thinking is + // enabled. If `result.thinking` was set above from `body.thinking` or + // `body.reasoning_effort` (manual budget or adaptive effort), drop temperature + // defensively. The model-name strip earlier already covers Claude OAuth's + // forced-thinking case (claude-opus-4.x / claude-sonnet-4.x). + if (result.thinking && result.temperature !== undefined) { + delete result.temperature; + } + + // Whether the OUTBOUND request actually has extended thinking enabled. Anthropic's + // schema only requires a precursor thinking/redacted_thinking block before a tool_use + // block when thinking mode is active for THIS request — never unconditionally (#5945). + const thinkingEnabledForRequest = Boolean(result.thinking) && result.thinking.type !== "disabled"; + // Messages const systemParts = []; @@ -199,7 +290,12 @@ export function openaiToClaudeRequest(model, body, stream) { for (const msg of nonSystemMessages) { const newRole = msg.role === "user" || msg.role === "tool" ? "user" : "assistant"; - const blocks = getContentBlocksFromMessage(msg, toolNameMap, disableToolPrefix); + const blocks = getContentBlocksFromMessage( + msg, + toolNameMap, + disableToolPrefix, + thinkingEnabledForRequest + ); const hasToolUse = blocks.some((b) => b.type === "tool_use"); const hasToolResult = blocks.some((b) => b.type === "tool_result"); @@ -387,87 +483,6 @@ export function openaiToClaudeRequest(model, body, stream) { : [{ type: "text", text: String(body.system) }]; } - // Thinking configuration - if (body.thinking) { - result.thinking = { - type: body.thinking.type || "enabled", - ...(body.thinking.budget_tokens && { budget_tokens: body.thinking.budget_tokens }), - ...(body.thinking.max_tokens && { max_tokens: body.thinking.max_tokens }), - }; - } else if (body.reasoning_effort) { - // Convert OpenAI reasoning_effort to Claude thinking format (#627) - // Clients like OpenCode send reasoning_effort via @ai-sdk/openai-compatible - const requestedEffort = String(body.reasoning_effort).toLowerCase(); - const normalizedEffort = - requestedEffort === "max" && !supportsClaudeMaxEffort(model) - ? "high" - : requestedEffort === "xhigh" && !supportsXHighEffort("claude", model) - ? "high" - : requestedEffort; - if (isAdaptiveThinkingOnly(model)) { - // Opus 4.7+/Fable 5 removed manual extended thinking: a fixed `budget_tokens` - // (or `type:"enabled"`) is a hard 400. Steer EVERY level via adaptive + - // output_config.effort instead of the budget buckets below. Unrecognized levels - // leave thinking unset so the model keeps its adaptive default rather than 400ing - // on an invalid effort value. - if (ADAPTIVE_EFFORT_LEVELS.has(normalizedEffort)) { - result.thinking = { - type: "adaptive", - }; - result.output_config = { - ...(result.output_config || {}), - effort: normalizedEffort, - }; - } - } else if (normalizedEffort === "max" || normalizedEffort === "xhigh") { - result.thinking = { - type: "adaptive", - }; - result.output_config = { - ...(result.output_config || {}), - effort: normalizedEffort, - }; - } else { - const effortBudgetMap: Record = { - low: 1024, - medium: 10240, - high: 131072, - max: 131072, - }; - const budget = effortBudgetMap[normalizedEffort]; - if (budget !== undefined && budget > 0) { - result.thinking = { - type: "enabled", - budget_tokens: budget, - }; - } - } - } - - // 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 = applyCopilotSummarizedThinkingDisplay(fitted.thinking, body); - } - - delete result[COPILOT_REASONING_SUMMARY_MARKER]; - - // Final guard: Claude rejects `temperature` whenever extended thinking is - // enabled. If `result.thinking` was set above from `body.thinking` or - // `body.reasoning_effort` (manual budget or adaptive effort), drop temperature - // defensively. The model-name strip earlier already covers Claude OAuth's - // forced-thinking case (claude-opus-4.x / claude-sonnet-4.x). - if (result.thinking && result.temperature !== undefined) { - delete result.temperature; - } - // Attach toolNameMap to result for response translation if (toolNameMap.size > 0) { result._toolNameMap = toolNameMap; @@ -488,7 +503,12 @@ export function openaiToClaudeRequest(model, body, stream) { } // Get content blocks from single message -function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPrefix = false) { +function getContentBlocksFromMessage( + msg, + toolNameMap = new Map(), + disableToolPrefix = false, + thinkingEnabledForRequest = false +) { const blocks = []; if (msg.role === "tool") { @@ -555,22 +575,6 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr } } } else if (msg.role === "assistant") { - // Add reasoning_content as a replay placeholder (OpenAI extended thinking format). - // #5312 RC-D: reasoning_content carries NO real Claude signature. Emitting a - // `thinking` block with the fabricated DEFAULT signature makes Anthropic reject the - // replay with 400 "Invalid signature in thinking block" — and claudeHelper's - // latest-assistant guard (prepareClaudeRequest) preserves it verbatim, so the fake - // signature leaks upstream. Emit a signature-less redacted_thinking block instead - // (the same shape prepareClaudeRequest produces for Anthropic-native replay); - // Anthropic accepts it without signature validation and non-Anthropic Claude-shape - // upstreams re-hydrate the real text downstream from reasoningCache. - if (msg.reasoning_content) { - blocks.push({ - type: "redacted_thinking", - data: DEFAULT_THINKING_CLAUDE_SIGNATURE, - }); - } - if (Array.isArray(msg.content)) { for (const part of msg.content) { if (part.type === "text" && part.text) { @@ -619,6 +623,37 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr } } } + + // Add reasoning_content as a replay placeholder (OpenAI extended thinking format) — + // ONLY when Anthropic's schema actually requires a precursor thinking block: the + // outbound request has extended thinking enabled AND this assistant turn contains a + // tool_use block (Anthropic rejects a tool_use turn without a preceding + // thinking/redacted_thinking block when thinking is active). #5312 RC-D: + // reasoning_content carries NO real Claude signature. Emitting a `thinking` block + // with the fabricated DEFAULT signature makes Anthropic reject the replay with 400 + // "Invalid signature in thinking block" — and claudeHelper's latest-assistant guard + // (prepareClaudeRequest) preserves it verbatim, so the fake signature leaks + // upstream. Emit a signature-less redacted_thinking block instead (the same shape + // prepareClaudeRequest produces for Anthropic-native replay, gated the same way at + // claudeHelper.ts `thinkingEnabled && !hasThinking && hasToolUse`); Anthropic + // accepts it without signature validation and non-Anthropic Claude-shape upstreams + // re-hydrate the real text downstream from reasoningCache. + // #5945: injecting this unconditionally — for ANY assistant turn carrying + // reasoning_content, regardless of tool_use or thinking state — fabricates a content + // block the client never sent. Some upstream clients (reported: Claude Sonnet 5 via + // the "Pi" harness) detect the extra block and refuse the turn as prompt injection. + // Drop reasoning_content silently when it is not required by the schema, mirroring + // how other echo-only fields are dropped (see OPENAI_INCOMPATIBLE_ECHO_FIELDS). + const hasThinkingBlock = blocks.some( + (b) => b.type === "thinking" || b.type === "redacted_thinking" + ); + const hasToolUseBlock = blocks.some((b) => b.type === "tool_use"); + if (msg.reasoning_content && thinkingEnabledForRequest && hasToolUseBlock && !hasThinkingBlock) { + blocks.unshift({ + type: "redacted_thinking", + data: DEFAULT_THINKING_CLAUDE_SIGNATURE, + }); + } } return blocks; diff --git a/tests/unit/openai-to-claude-redacted-replay-5312.test.ts b/tests/unit/openai-to-claude-redacted-replay-5312.test.ts index 43764ae00c..42c434898e 100644 --- a/tests/unit/openai-to-claude-redacted-replay-5312.test.ts +++ b/tests/unit/openai-to-claude-redacted-replay-5312.test.ts @@ -1,14 +1,37 @@ /** - * TDD regression for #5312 (FIX D / RC-D): openai-to-claude reconstructed a Claude - * `thinking` block from signature-less `reasoning_content` and stamped it with the - * fabricated DEFAULT_THINKING_CLAUDE_SIGNATURE. Anthropic validates signatures and - * rejects the fake one with 400 "Invalid signature in thinking block" — and - * claudeHelper's latest-assistant guard preserves the block verbatim, so the fake - * signature leaks upstream. + * TDD regression for #5312 (FIX D / RC-D) and #5945. * - * Fix: emit a signature-less `redacted_thinking` placeholder (matching what - * prepareClaudeRequest produces downstream). A REAL part.signature must always be - * preserved verbatim — never overwritten with the default. + * #5312 (FIX D / RC-D): openai-to-claude reconstructed a Claude `thinking` block from + * signature-less `reasoning_content` and stamped it with the fabricated + * DEFAULT_THINKING_CLAUDE_SIGNATURE. Anthropic validates signatures and rejects the + * fake one with 400 "Invalid signature in thinking block" — and claudeHelper's + * latest-assistant guard preserves the block verbatim, so the fake signature leaks + * upstream. + * + * Fix (#5312): when a precursor thinking block IS required by Anthropic's schema + * (assistant turn has tool_use AND the outbound request has extended thinking + * enabled), emit a signature-less `redacted_thinking` placeholder (matching what + * prepareClaudeRequest produces downstream) instead of a fabricated-signature + * `thinking` block. A REAL part.signature must always be preserved verbatim — never + * overwritten with the default. + * + * #5945 (over-correction of #5312): the original #5312 fix injected the + * redacted_thinking placeholder UNCONDITIONALLY whenever ANY assistant history + * message carried non-empty `reasoning_content` — regardless of whether the current + * outbound request has thinking enabled, and regardless of whether that assistant + * turn even contains a `tool_use` block (the only case Anthropic's schema actually + * requires a preceding thinking/redacted_thinking block). This fabricated a content + * block the client never sent; reported by dev-cj: Claude Sonnet 5 via the "Pi" + * harness detected the extra block and refused the turn as prompt injection. + * + * Fix (#5945): gate the injection on BOTH (a) the assistant turn containing a + * tool_use block and (b) the outbound request having extended thinking enabled. + * Otherwise `reasoning_content` is dropped silently — it carries no useful signal + * for a plain-text replay turn, and the client never asked for it to appear. + * + * These two fixes are not in tension: #5312 legitimately fixed a real Anthropic 400 + * for the case the redacted_thinking block IS required; #5945 narrows the trigger to + * exactly that case instead of firing for every reasoning_content-bearing message. */ import test from "node:test"; import assert from "node:assert/strict"; @@ -20,7 +43,7 @@ const { DEFAULT_THINKING_CLAUDE_SIGNATURE } = await import( "../../open-sse/config/defaultThinkingSignature.ts" ); -test("#5312 RC-D: signature-less reasoning_content yields no fabricated-signature thinking block", () => { +test("#5945: reasoning_content on a plain-text assistant turn (no tool_use, thinking not requested) yields NO redacted_thinking/thinking block", () => { const result = openaiToClaudeRequest( "claude-opus-4-8", { @@ -28,6 +51,83 @@ test("#5312 RC-D: signature-less reasoning_content yields no fabricated-signatur { role: "user", content: "hello" }, { role: "assistant", reasoning_content: "thinking about it", content: "hi there" }, ], + // no body.thinking / body.reasoning_effort — thinking is NOT enabled for this request. + }, + false + ); + + const assistant = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistant, "expected assistant message"); + + // No fabricated block at all — reasoning_content must be dropped silently, exactly + // like OPENAI_INCOMPATIBLE_ECHO_FIELDS drops other echo-only fields. + assert.equal( + assistant.content.find((b) => b && (b.type === "thinking" || b.type === "redacted_thinking")), + undefined, + "must NOT fabricate a thinking/redacted_thinking block the client never sent" + ); + assert.deepEqual( + assistant.content.map((b) => b.type), + ["text"], + "assistant content should contain only the real text block" + ); +}); + +test("#5945: reasoning_content + tool_use, but thinking NOT enabled on the outbound request, yields NO injection", () => { + const result = openaiToClaudeRequest( + "claude-opus-4-8", + { + messages: [ + { role: "user", content: "hello" }, + { + role: "assistant", + reasoning_content: "thinking about it", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "get_weather", arguments: "{}" }, + }, + ], + }, + ], + // no body.thinking / body.reasoning_effort — thinking is NOT enabled. + }, + false + ); + + const assistant = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistant, "expected assistant message"); + assert.equal( + assistant.content.find((b) => b && (b.type === "thinking" || b.type === "redacted_thinking")), + undefined, + "must NOT inject a precursor thinking block when the request itself has thinking disabled" + ); + assert.ok( + assistant.content.some((b) => b.type === "tool_use"), + "tool_use block must still be present" + ); +}); + +test("#5312: reasoning_content + tool_use + thinking ENABLED still gets a signature-less redacted_thinking precursor (the legitimate #5312 400-fix case)", () => { + const result = openaiToClaudeRequest( + "claude-opus-4-8", + { + thinking: { type: "enabled", budget_tokens: 4096 }, + messages: [ + { role: "user", content: "hello" }, + { + role: "assistant", + reasoning_content: "thinking about it", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "get_weather", arguments: "{}" }, + }, + ], + }, + ], }, false ); @@ -35,24 +135,25 @@ test("#5312 RC-D: signature-less reasoning_content yields no fabricated-signatur const assistant = result.messages.find((m) => m.role === "assistant"); assert.ok(assistant, "expected assistant message"); - // No block may carry the fabricated default signature. + // No block may carry the fabricated default signature on a `thinking`-typed block. const fake = assistant.content.find( - (b) => b && b.signature === DEFAULT_THINKING_CLAUDE_SIGNATURE + (b) => b && b.type === "thinking" && b.signature === DEFAULT_THINKING_CLAUDE_SIGNATURE ); - assert.equal(fake, undefined, "must NOT emit a thinking block with the fabricated signature"); + assert.equal(fake, undefined, "must NOT emit a `thinking` block with the fabricated signature"); - // No `thinking`-typed block at all from signature-less reasoning_content. + // It becomes a redacted_thinking placeholder (Anthropic accepts without sig check), + // and it must precede the tool_use block. + assert.equal(assistant.content[0].type, "redacted_thinking", "must be the precursor block"); + assert.equal(assistant.content[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE); assert.equal( - assistant.content.find((b) => b && b.type === "thinking"), + assistant.content[0].signature, undefined, - "signature-less reasoning_content must not produce a `thinking` block" + "redacted_thinking must not carry a signature" + ); + assert.ok( + assistant.content.some((b) => b.type === "tool_use"), + "tool_use block must still be present" ); - - // It becomes a redacted_thinking placeholder (Anthropic accepts without sig check). - const redacted = assistant.content.find((b) => b && b.type === "redacted_thinking"); - assert.ok(redacted, "expected a redacted_thinking placeholder"); - assert.equal(redacted.data, DEFAULT_THINKING_CLAUDE_SIGNATURE); - assert.equal(redacted.signature, undefined, "redacted_thinking must not carry a signature"); }); test("#5312 RC-D: a REAL thinking signature is preserved verbatim", () => { diff --git a/tests/unit/translator-openai-to-claude.test.ts b/tests/unit/translator-openai-to-claude.test.ts index 5754707216..70c3f1a4f2 100644 --- a/tests/unit/translator-openai-to-claude.test.ts +++ b/tests/unit/translator-openai-to-claude.test.ts @@ -114,6 +114,11 @@ test("OpenAI -> Claude converts multimodal content, tool declarations, tool call const result = openaiToClaudeRequest( "claude-4-sonnet", { + // #5945: the redacted_thinking precursor is only emitted when the outbound + // request actually has extended thinking enabled (Anthropic's schema + // requirement). Set it explicitly so this test keeps exercising that + // legitimate #5312 case alongside the multimodal/tool assertions below. + thinking: { type: "enabled", budget_tokens: 4096 }, messages: [ { role: "user",