fix(translator): drop unsigned thinking blocks instead of fabricating a Claude signature (#12386)

A thinking content part arriving with no signature — typical after a cross-provider hop where reasoning_content was converted into a thinking block — was stamped with DEFAULT_THINKING_CLAUDE_SIGNATURE. prepareClaudeRequest treats any non-empty signature on the latest assistant turn as genuine and preserves it verbatim, so the fabricated one reached Anthropic and the replay failed with "Invalid signature". A missing signature is now treated the same as an empty one, aligned with the stricter check claudeHelper.ts already used: the block is dropped rather than fabricated. Real signatures are still preserved verbatim and redacted_thinking is unchanged.

Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green.

Thanks @pacocartones.
This commit is contained in:
Paco Cartones
2026-09-02 08:14:18 +02:00
committed by GitHub
parent 70f33e323c
commit 01d97beb8b
5 changed files with 242 additions and 16 deletions

View File

@@ -0,0 +1 @@
- **fix(translator):** Drop replayed `thinking` blocks that carry no signature (the shape produced from cross-provider `reasoning_content`) instead of stamping the default Claude signature on them, which Anthropic rejected with `400 Invalid signature in thinking block` on the next turn served by an Anthropic rung ([#12105](https://github.com/diegosouzapw/OmniRoute/issues/12105)) — thanks @atescivitci-cmd

View File

@@ -622,13 +622,15 @@ function getContentBlocksFromMessage(
// turn introduced a `signature:""` thinking block, every subsequent Anthropic leg
// attempt 400'd and the router silently fell back to codex forever.
//
// Fix: strip thinking blocks whose signature is the empty string — that explicit
// empty value is the hallmark of a synthesized block from a non-Anthropic provider.
// Thinking blocks with `signature: undefined` (field absent) are legitimate Claude-
// format messages and fall through to the DEFAULT_THINKING_CLAUDE_SIGNATURE fallback
// as before.
if (part.type === "thinking" && part.signature === "") {
continue; // drop — synthesized by non-Anthropic provider, no valid signature
// Fix: strip thinking blocks that carry no signature at all. `signature: ""` is the
// shape codex/gpt-5.x emit; a MISSING field is what the response translator produces
// from cross-provider `reasoning_content` (#12105). Neither can be replayed to
// Anthropic, and fabricating DEFAULT_THINKING_CLAUDE_SIGNATURE is worse than dropping:
// prepareClaudeRequest treats any non-empty signature on the latest assistant turn as
// genuine and forwards the block verbatim, so the fake signature 400s upstream. This
// mirrors the stricter "non-empty string" check already used in claudeHelper.ts.
if (part.type === "thinking" && !part.signature) {
continue; // drop — no replayable signature (empty or absent)
}
if (part.type === "redacted_thinking" && part.data === "") {
continue; // drop — same: empty data from non-Anthropic provider

View File

@@ -90,10 +90,11 @@ test("#6953: thinking block with valid signature is preserved verbatim", () => {
assert.equal(thinkingBlocks[0].signature, realSig, "valid signature must be preserved verbatim");
});
test("#6953: thinking block with undefined signature (Claude-format) is preserved with fallback", () => {
// Claude-format messages may have thinking blocks without a signature field at all.
// These are legitimate and must NOT be stripped — only signature:"" (empty string)
// indicates a non-Anthropic synthesized block.
test("#6953/#12105: thinking block with undefined signature is stripped like the empty-string case", () => {
// A thinking block without a signature field is what the response translator emits for
// cross-provider reasoning_content (#12105). It carries no replayable signature either, so
// it must be dropped rather than stamped with the fabricated default — Anthropic rejects
// that fabricated signature with HTTP 400 exactly like the empty-string case.
const result = openaiToClaudeRequest(
"claude-opus-4-8",
{
@@ -118,11 +119,11 @@ test("#6953: thinking block with undefined signature (Claude-format) is preserve
const thinkingBlocks = assistant.content.filter((b) => b && b.type === "thinking");
assert.equal(
thinkingBlocks.length,
1,
"thinking block with undefined signature must be preserved"
0,
"thinking block with undefined signature must be stripped, not fabricated"
);
assert.equal(thinkingBlocks[0].thinking, "I already have this", "thinking content must match");
assert.ok(thinkingBlocks[0].signature, "fallback signature must be applied");
const textBlocks = assistant.content.filter((b) => b && b.type === "text");
assert.equal(textBlocks.length, 1, "text block must be preserved");
});
test("#6953: redacted_thinking with empty data is stripped", () => {

View File

@@ -0,0 +1,167 @@
/**
* TDD regression for #12105 — cross-provider `reasoning_content` becomes an unsigned
* `thinking` block, then "Invalid signature" on replay to Claude.
*
* The response translator (response/openai-to-claude.ts) builds a `thinking` block from
* `reasoning_content` and never attaches a `signature` field. The client stores that
* block verbatim and replays it on the next turn. When that turn is served by an
* Anthropic-native rung, `openaiToClaudeRequest` only treated `signature: ""` as
* synthesized (#6953); a block with the field ABSENT fell through to the
* DEFAULT_THINKING_CLAUDE_SIGNATURE fallback. Anthropic validates `thinking`
* signatures cryptographically and rejects the fabricated one with HTTP 400.
*
* `prepareClaudeRequest` cannot repair this afterwards: its latest-assistant guard
* classifies any non-empty signature string as genuine and preserves the block
* verbatim (Anthropic 400s on modified latest-turn blocks), so the fabricated
* signature reaches the upstream unchanged.
*
* Fix: treat a missing signature the same as an empty one — drop the block. Older
* turns and tool_use precursors are already handled by prepareClaudeRequest
* (redacted_thinking rewrite / precursor injection), which never fabricates a
* `thinking` signature.
*/
import test from "node:test";
import assert from "node:assert/strict";
const { openaiToClaudeRequest } =
await import("../../open-sse/translator/request/openai-to-claude.ts");
const { prepareClaudeRequest } = await import("../../open-sse/translator/helpers/claudeHelper.ts");
const { DEFAULT_THINKING_CLAUDE_SIGNATURE } =
await import("../../open-sse/config/defaultThinkingSignature.ts");
test("#12105: thinking block with NO signature field is dropped, not stamped with the default signature", () => {
const result = openaiToClaudeRequest(
"claude-opus-4-8",
{
messages: [
{ role: "user", content: "hello" },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "cross-provider reasoning" },
{ type: "text", text: "response" },
],
},
{ role: "user", content: "next turn" },
],
},
false
);
const assistant = result.messages.find((m) => m.role === "assistant");
assert.ok(assistant, "expected assistant message");
const fabricated = assistant.content.find(
(b) => b && b.type === "thinking" && b.signature === DEFAULT_THINKING_CLAUDE_SIGNATURE
);
assert.equal(
fabricated,
undefined,
"must NOT emit a `thinking` block carrying the fabricated default signature"
);
assert.equal(
assistant.content.filter((b) => b && b.type === "thinking").length,
0,
'unsigned thinking block must be dropped, exactly like the signature:"" case'
);
assert.deepEqual(
assistant.content.map((b) => b.type),
["text"],
"text block must survive"
);
});
test("#12105: unsigned thinking block on the latest assistant turn with tool_use does not leak a fabricated signature through prepareClaudeRequest", () => {
// Mirrors the reported combo scenario: the previous turn was served by a
// non-Anthropic rung (unsigned thinking + tool_use), and this turn routes to
// an Anthropic-native rung with thinking enabled.
const translated = openaiToClaudeRequest(
"claude-opus-4-8",
{
thinking: { type: "enabled", budget_tokens: 4096 },
messages: [
{ role: "user", content: "write a function" },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "**Reviewing the request**" },
{
type: "tool_use",
id: "toolu_01abc",
name: "write_file",
input: { path: "main.rs", content: "fn main() {}" },
},
],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_01abc", content: "ok" }],
},
],
},
false
);
const outbound = prepareClaudeRequest(translated, "claude");
const assistant = outbound.messages.find((m) => m.role === "assistant");
assert.ok(assistant, "expected assistant message");
const fabricated = assistant.content.find(
(b) => b && b.type === "thinking" && b.signature === DEFAULT_THINKING_CLAUDE_SIGNATURE
);
assert.equal(
fabricated,
undefined,
"a `thinking` block with the fabricated signature must never reach the Anthropic upstream"
);
assert.equal(
assistant.content.find((b) => b && b.type === "thinking"),
undefined,
"no `thinking`-typed block may survive on the latest assistant turn"
);
// Anthropic's schema still needs a thinking-ish precursor before tool_use when
// thinking is enabled; prepareClaudeRequest supplies the signature-less
// redacted_thinking placeholder (accepted without signature validation).
assert.equal(
assistant.content[0].type,
"redacted_thinking",
"precursor must be redacted_thinking"
);
assert.equal(
assistant.content[0].signature,
undefined,
"redacted_thinking must carry no signature"
);
assert.ok(
assistant.content.some((b) => b.type === "tool_use"),
"tool_use block must be preserved"
);
});
test("#12105: thinking block with a real signature is still preserved verbatim", () => {
const realSig = "ErUBCkYI...real-anthropic-signature...==";
const result = openaiToClaudeRequest(
"claude-opus-4-8",
{
messages: [
{ role: "user", content: "hello" },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "real reasoning", signature: realSig },
{ type: "text", text: "response" },
],
},
{ role: "user", content: "ok" },
],
},
false
);
const assistant = result.messages.find((m) => m.role === "assistant");
assert.ok(assistant);
const thinking = assistant.content.filter((b) => b && b.type === "thinking");
assert.equal(thinking.length, 1, "signed thinking block must be preserved");
assert.equal(thinking[0].signature, realSig, "real signature must be preserved verbatim");
});

View File

@@ -812,7 +812,9 @@ test("translateRequest does NOT inject duplicate thinking for Claude-format mess
{
role: "assistant",
content: [
{ type: "thinking", thinking: "I already have this" },
// Signed: a thinking block without a signature is dropped by the request
// translator (#12105), which would leave nothing for this test to protect.
{ type: "thinking", thinking: "I already have this", signature: "sig_existing" },
{ type: "tool_use", id: "toolu_existing", name: "read", input: {} },
],
},
@@ -838,3 +840,56 @@ test("translateRequest does NOT inject duplicate thinking for Claude-format mess
clearReasoningCacheAll();
});
test("translateRequest replays cached reasoning when the client's Claude-format thinking block has no signature", () => {
// #12105: an unsigned thinking block cannot be replayed to Claude, so the request
// translator drops it instead of stamping a fabricated signature. For Kimi Coding the
// tool_use turn still needs a thinking precursor, and the reasoning cache (keyed by the
// tool_use id) is the authentic source — it must be re-hydrated exactly once.
clearReasoningCacheAll();
cacheReasoningByKey("toolu_unsigned", "kimi-coding-apikey", "k3-256k", "cached thinking");
const result = translateRequest(
FORMATS.OPENAI,
FORMATS.CLAUDE,
"k3-256k",
{
messages: [
{ role: "user", content: "hi" },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "unsigned client thinking" },
{ type: "tool_use", id: "toolu_unsigned", name: "read", input: {} },
],
},
{ role: "tool", tool_call_id: "toolu_unsigned", content: "data" },
],
},
false,
null,
"kimi-coding-apikey"
);
const assistantMsg = result.messages.find((m) => m.role === "assistant");
const thinkingBlocks =
Array.isArray(assistantMsg.content) &&
assistantMsg.content.filter((b) => b?.type === "thinking");
assert.equal(thinkingBlocks?.length, 1, "should have exactly one thinking block (no duplicate)");
assert.equal(
thinkingBlocks[0].thinking,
"cached thinking",
"cached reasoning should be replayed"
);
assert.equal(
thinkingBlocks[0].signature,
undefined,
"replayed thinking must not carry a fabricated signature"
);
const thinkingIdx = assistantMsg.content.indexOf(thinkingBlocks[0]);
const toolUseIdx = assistantMsg.content.findIndex((b) => b?.type === "tool_use");
assert.ok(thinkingIdx < toolUseIdx, "thinking block should be before tool_use");
assert.equal(getReasoningCacheServiceStats().replays, 1);
clearReasoningCacheAll();
});