Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(claude):** drop assistant `thinking` / `redacted_thinking` blocks that carry no signature from the history before it is replayed to the genuine Anthropic upstream (Claude passthrough), so reasoning relayed from an OpenAI-compatible leg no longer triggers `Invalid signature in thinking block` on the next turn — signed blocks are forwarded untouched ([#12917](https://github.com/diegosouzapw/OmniRoute/pull/12917)) — thanks @atescivitci-cmd
61 changes: 60 additions & 1 deletion open-sse/handlers/chatCore/passthroughHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,19 +152,78 @@ export function shouldUseNativeOpenAICompatibleResponsesPassthrough({
* responses. The redaction is therefore both unnecessary and the cause of the
* regression, so the blocks are now returned verbatim. The `signature` parameter
* is kept for call-site compatibility.
*
* The one exception is a block that carries no signature at all (see
* {@link dropUnsignedPassthroughThinkingBlocks}): it was never issued by Anthropic,
* so it is dropped instead of forwarded.
*/
export function redactPassthroughThinkingSignatures(
messages: unknown,
_signature: string
): unknown {
return messages;
// Signed blocks stay verbatim; only blocks Anthropic never issued are dropped (#12917).
return dropUnsignedPassthroughThinkingBlocks(messages);
}

type MessageLike = {
role?: unknown;
content?: unknown;
};

/**
* True for an assistant `thinking` / `redacted_thinking` block that carries no
* signature (`thinking`) or no payload (`redacted_thinking`). Such a block was
* never issued by Anthropic — typically an OpenAI-compatible leg's reasoning that
* the response translator relayed as `thinking` — so a real Anthropic upstream can
* only answer 400 "Invalid signature in thinking block" when it is replayed.
*/
function isUnsignedThinkingBlock(block: unknown): boolean {
if (!block || typeof block !== "object") return false;
const { type, signature, data } = block as {
type?: unknown;
signature?: unknown;
data?: unknown;
};
const hasText = (value: unknown) => typeof value === "string" && value.trim().length > 0;
if (type === "thinking") return !hasText(signature);
if (type === "redacted_thinking") return !hasText(data);
return false;
}

/**
* Drop assistant thinking blocks that no Anthropic upstream could have signed
* (see {@link isUnsignedThinkingBlock}) before the history is replayed to a
* genuine Anthropic endpoint. Signed blocks are never touched — Anthropic rejects
* any modification of a valid one — and an assistant turn left with no content is
* removed (consecutive same-role turns are accepted by the Messages API).
*
* This is the proactive complement of the exact-error one-shot recovery
* (`executeWithAnthropicThinkingSignatureRecovery`), which cannot help when the
* unsigned block sits in the still-open tool-use cycle. Returns the original
* reference when nothing needs dropping; never mutates its input.
*/
export function dropUnsignedPassthroughThinkingBlocks(messages: unknown): unknown {
if (!Array.isArray(messages)) return messages;

let changed = false;
const kept: unknown[] = [];
for (const message of messages as MessageLike[]) {
if (
!message ||
message.role !== "assistant" ||
!Array.isArray(message.content) ||
!message.content.some(isUnsignedThinkingBlock)
) {
kept.push(message);
continue;
}
changed = true;
const content = message.content.filter((block) => !isUnsignedThinkingBlock(block));
if (content.length > 0) kept.push({ ...message, content });
Comment on lines +220 to +222
}
return changed ? kept : messages;
}

type ThinkingSignatureError = {
provider?: string | null;
status?: number | null;
Expand Down
172 changes: 172 additions & 0 deletions tests/unit/claude-passthrough-unsigned-thinking-12917.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// @ts-nocheck
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";

const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-unsigned-thinking-"));
process.env.DATA_DIR = TEST_DATA_DIR;

const core = await import("../../src/lib/db/core.ts");
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
const { dropUnsignedPassthroughThinkingBlocks } =
await import("../../open-sse/handlers/chatCore/passthroughHelpers.ts");

const originalFetch = globalThis.fetch;

function noopLog() {
return { debug() {}, info() {}, warn() {}, error() {} };
}

async function flushAsyncSideEffects() {
for (let i = 0; i < 5; i++) await new Promise((resolve) => setImmediate(resolve));
}

test.afterEach(async () => {
globalThis.fetch = originalFetch;
await flushAsyncSideEffects();
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
});

test.after(() => {
globalThis.fetch = originalFetch;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});

function makeMessages() {
return [
{ role: "user", content: [{ type: "text", text: "q1" }] },
{
// relayed from an OpenAI-compatible leg: reasoning surfaced as an UNSIGNED thinking block
role: "assistant",
content: [
{ type: "thinking", thinking: "unsigned relay" },
{ type: "text", text: "a1" },
],
},
{ role: "user", content: [{ type: "text", text: "q2" }] },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "empty sig", signature: "" },
{ type: "redacted_thinking" },
{ type: "thinking", thinking: "genuine", signature: "GENUINE_SIG" },
{ type: "redacted_thinking", data: "GENUINE_DATA" },
{ type: "text", text: "a2" },
],
},
{ role: "user", content: [{ type: "text", text: "q3" }] },
];
}

test("helper drops thinking/redacted_thinking blocks without a signature and keeps the rest", () => {
const messages = makeMessages();
const before = JSON.stringify(messages);

const out = dropUnsignedPassthroughThinkingBlocks(messages);

assert.equal(JSON.stringify(messages), before, "input is not mutated");
assert.deepEqual(out[1].content, [{ type: "text", text: "a1" }]);
assert.deepEqual(out[3].content, [
{ type: "thinking", thinking: "genuine", signature: "GENUINE_SIG" },
{ type: "redacted_thinking", data: "GENUINE_DATA" },
{ type: "text", text: "a2" },
]);
assert.equal(out[0], messages[0], "untouched messages keep their reference");
});

test("helper returns the same reference when every thinking block is signed", () => {
const messages = [
{ role: "user", content: "hi" },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "r", signature: "SIG" },
{ type: "tool_use", id: "toolu_1", name: "Bash", input: {} },
],
},
];
assert.equal(dropUnsignedPassthroughThinkingBlocks(messages), messages);
assert.equal(dropUnsignedPassthroughThinkingBlocks(undefined), undefined);
assert.equal(dropUnsignedPassthroughThinkingBlocks(null), null);
});

test("helper drops an assistant message whose only content was unsigned thinking", () => {
const messages = [
{ role: "user", content: "a" },
{ role: "assistant", content: [{ type: "thinking", thinking: "only reasoning" }] },
{ role: "user", content: "b" },
];
const out = dropUnsignedPassthroughThinkingBlocks(messages);
assert.deepEqual(
out.map((m) => m.role),
["user", "user"]
);
});

test("claude passthrough never replays unsigned thinking history to api.anthropic.com", async () => {
let captured = null;

globalThis.fetch = async (url, init = {}) => {
captured = { url: String(url), body: JSON.parse(String(init.body || "{}")) };
return new Response(
JSON.stringify({
id: "msg_test",
type: "message",
role: "assistant",
model: "claude-opus-5",
content: [{ type: "text", text: "OK" }],
usage: { input_tokens: 4, output_tokens: 1 },
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
};

const body = {
model: "claude-opus-5",
max_tokens: 64,
system: [{ type: "text", text: "You are Claude." }],
messages: makeMessages(),
stream: false,
};

const result = await handleChatCore({
body: structuredClone(body),
modelInfo: { provider: "claude", model: "claude-opus-5", extendedContext: false },
credentials: { apiKey: "test-claude-key", providerSpecificData: {} },
log: noopLog(),
clientRawRequest: {
endpoint: "/v1/messages",
body: structuredClone(body),
headers: new Headers({
accept: "application/json",
"content-type": "application/json",
"user-agent": "claude-code/2.1.154",
}),
},
userAgent: "claude-code/2.1.154",
});

assert.ok(captured, "fetch was not called");
assert.ok(captured.url.startsWith("https://api.anthropic.com/v1/messages"));
assert.equal(result.success, true);

const sentBlocks = captured.body.messages
.filter((m) => m.role === "assistant")
.flatMap((m) => m.content)
.filter((b) => b.type === "thinking" || b.type === "redacted_thinking");
assert.deepEqual(sentBlocks, [
{ type: "thinking", thinking: "genuine", signature: "GENUINE_SIG" },
{ type: "redacted_thinking", data: "GENUINE_DATA" },
]);
assert.ok(
captured.body.messages.some(
(m) => Array.isArray(m.content) && m.content.some((b) => b.text === "a1")
),
"the visible answer text of the affected turn is preserved"
);
});