fix(claudeHelper): emit data field on redacted_thinking, drop bogus signature (#2191)

Integrated into release/v3.8.0 after syncing the contributor branch and validating tests/unit/translator-claude-helper-thinking.test.ts locally.
This commit is contained in:
Anton
2026-05-13 00:49:50 +02:00
committed by GitHub
parent 340f68bbee
commit 5c11b57542
2 changed files with 32 additions and 19 deletions

View File

@@ -267,16 +267,28 @@ export function prepareClaudeRequest(
let hasToolUse = false;
let hasThinking = false;
// Convert thinking blocks to redacted_thinking and replace signature.
// When requests cross provider boundaries (e.g., combo fallback), the
// original thinking signature is invalid for the new provider, causing
// "Invalid signature in thinking block" 400 errors. redacted_thinking
// blocks are accepted without signature validation.
// Convert thinking blocks to redacted_thinking with synthetic `data`.
// When requests cross provider boundaries (e.g., combo fallback) or
// when client-stored signatures (Capy) replay back to Anthropic, the
// original `thinking.signature` no longer validates: "Invalid signature
// in thinking block" 400. redacted_thinking accepts without signature
// validation — but Anthropic REQUIRES a `data` field on it. Previous
// versions emitted `signature` on redacted_thinking (wrong field,
// belongs on regular `thinking`) and omitted `data`, causing:
// messages.N.content.0.redacted_thinking.data: Field required (400)
//
// Fix: emit only the correct fields per type.
// - redacted_thinking: { type, data }
// - thinking: { type, thinking, signature }
// We use DEFAULT_THINKING_CLAUDE_SIGNATURE as the `data` placeholder
// — it's a proven Anthropic-format base64 blob accepted as a valid
// redacted_thinking payload (replay context).
for (const block of content) {
if (block.type === "thinking" || block.type === "redacted_thinking") {
block.type = "redacted_thinking";
block.signature = DEFAULT_THINKING_CLAUDE_SIGNATURE;
block.data = DEFAULT_THINKING_CLAUDE_SIGNATURE;
delete block.thinking;
delete block.signature;
hasThinking = true;
}
if (block.type === "tool_use") hasToolUse = true;
@@ -285,14 +297,12 @@ export function prepareClaudeRequest(
// Add thinking block if thinking enabled + has tool_use but no thinking.
// Required for Anthropic-shape thinking-mode upstreams (claude, kimi,
// glm) when the assistant turn's content[] needs a precursor thinking
// block in front of any tool_use. Placeholder content (".") is enough
// to satisfy shape validation; the upstream still runs its own
// reasoning on the current turn.
// block in front of any tool_use. Use redacted_thinking shape (with
// `data`) to match what we emit when converting real thinking blocks.
if (thinkingEnabled && !hasThinking && hasToolUse) {
content.unshift({
type: "thinking",
thinking: ".",
signature: DEFAULT_THINKING_CLAUDE_SIGNATURE,
type: "redacted_thinking",
data: DEFAULT_THINKING_CLAUDE_SIGNATURE,
});
}
}

View File

@@ -33,9 +33,10 @@ test("prepareClaudeRequest: claude provider — injects thinking before tool_use
const result = prepareClaudeRequest(body as any, "claude");
const assistantContent = (result as any).messages[1].content;
assert.equal(assistantContent.length, 2, "thinking + tool_use");
assert.equal(assistantContent[0].type, "thinking");
assert.equal(assistantContent[0].thinking, ".");
assert.equal(assistantContent[0].signature, DEFAULT_THINKING_CLAUDE_SIGNATURE);
assert.equal(assistantContent[0].type, "redacted_thinking");
assert.equal(assistantContent[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
assert.equal(assistantContent[0].thinking, undefined);
assert.equal(assistantContent[0].signature, undefined);
assert.equal(assistantContent[1].type, "tool_use");
});
@@ -48,9 +49,10 @@ test("prepareClaudeRequest: kimi-coding provider — injects thinking before too
const result = prepareClaudeRequest(body as any, "kimi-coding");
const assistantContent = (result as any).messages[1].content;
assert.equal(assistantContent.length, 2, "thinking + tool_use");
assert.equal(assistantContent[0].type, "thinking");
assert.equal(assistantContent[0].thinking, ".");
assert.equal(assistantContent[0].signature, DEFAULT_THINKING_CLAUDE_SIGNATURE);
assert.equal(assistantContent[0].type, "redacted_thinking");
assert.equal(assistantContent[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
assert.equal(assistantContent[0].thinking, undefined);
assert.equal(assistantContent[0].signature, undefined);
});
test("prepareClaudeRequest: existing thinking block — redacted, signature replaced, no double-inject", () => {
@@ -75,8 +77,9 @@ test("prepareClaudeRequest: existing thinking block — redacted, signature repl
const assistantContent = (result as any).messages[1].content;
assert.equal(assistantContent.length, 2, "no double-inject — exactly 2 blocks");
assert.equal(assistantContent[0].type, "redacted_thinking", "thinking → redacted_thinking");
assert.equal(assistantContent[0].signature, DEFAULT_THINKING_CLAUDE_SIGNATURE);
assert.equal(assistantContent[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
assert.equal(assistantContent[0].thinking, undefined, "thinking field stripped");
assert.equal(assistantContent[0].signature, undefined, "signature field stripped");
assert.equal(assistantContent[1].type, "tool_use");
});