mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 06:42:19 +03:00
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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
167
tests/unit/openai-to-claude-undefined-signature-12105.test.ts
Normal file
167
tests/unit/openai-to-claude-undefined-signature-12105.test.ts
Normal 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");
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user