fix(thinking): only inject redacted_thinking replay block when tool_use present and thinking enabled (#5945) (#5953)

Integrated into release/v3.8.44.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-02 17:34:17 -03:00
committed by GitHub
parent 8d2df914f0
commit 2b0da37c19
3 changed files with 262 additions and 121 deletions

View File

@@ -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<string, number> = {
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<string, number> = {
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;

View File

@@ -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", () => {

View File

@@ -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",