From 5c11b575429ca500b7d76bc6e2780a005c1ad203 Mon Sep 17 00:00:00 2001 From: Anton <39598727+NomenAK@users.noreply.github.com> Date: Wed, 13 May 2026 00:49:50 +0200 Subject: [PATCH] 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. --- open-sse/translator/helpers/claudeHelper.ts | 34 ++++++++++++------- .../translator-claude-helper-thinking.test.ts | 17 ++++++---- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/open-sse/translator/helpers/claudeHelper.ts b/open-sse/translator/helpers/claudeHelper.ts index 49f4f0d5c6..8d8f476535 100644 --- a/open-sse/translator/helpers/claudeHelper.ts +++ b/open-sse/translator/helpers/claudeHelper.ts @@ -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, }); } } diff --git a/tests/unit/translator-claude-helper-thinking.test.ts b/tests/unit/translator-claude-helper-thinking.test.ts index 5c2f460f45..f514cb67b8 100644 --- a/tests/unit/translator-claude-helper-thinking.test.ts +++ b/tests/unit/translator-claude-helper-thinking.test.ts @@ -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"); });