mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(claude): guard orphan tool_use/tool_result pairs before upstream send (#2312)
Integrated into release/v3.8.0
This commit is contained in:
@@ -14,6 +14,7 @@ import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestD
|
||||
import { remapToolNamesInRequest } from "../services/claudeCodeToolRemapper.ts";
|
||||
import { obfuscateInBody } from "../services/claudeCodeObfuscation.ts";
|
||||
import { applySystemTransformPipeline, PROVIDER_CLAUDE } from "../services/systemTransforms.ts";
|
||||
import { fixToolPairs, stripTrailingAssistantOrphanToolUse } from "../services/contextManager.ts";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
CLAUDE_CODE_VERSION,
|
||||
@@ -861,6 +862,21 @@ export class BaseExecutor {
|
||||
delete (transformedBody as Record<string, unknown>)[
|
||||
"_claudeCodeRequiresLowercaseToolNames"
|
||||
];
|
||||
// Guard against orphan tool_use / tool_result pairs. Clients can ship
|
||||
// truncated histories mid-tool-call which Anthropic rejects with
|
||||
// `messages.N: tool_use ids were found without tool_result blocks
|
||||
// immediately after: toolu_...`. fixToolPairs strips orphans, then
|
||||
// stripTrailingAssistantOrphanToolUse catches the case where the
|
||||
// request body itself ends on an unmatched assistant(tool_use) —
|
||||
// invalid for an upstream-send turn since the body must end on a
|
||||
// user message. Both are idempotent on clean histories.
|
||||
{
|
||||
const tb = transformedBody as Record<string, unknown>;
|
||||
if (Array.isArray(tb?.messages)) {
|
||||
const fixed = fixToolPairs(tb.messages as Record<string, unknown>[]);
|
||||
tb.messages = stripTrailingAssistantOrphanToolUse(fixed);
|
||||
}
|
||||
}
|
||||
let bodyString = JSON.stringify(transformedBody);
|
||||
|
||||
const shouldFingerprint =
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "./claudeCodeConstraints.ts";
|
||||
import { obfuscateInBody } from "./claudeCodeObfuscation.ts";
|
||||
import { applySystemTransformPipeline, PROVIDER_CC_BRIDGE } from "./systemTransforms.ts";
|
||||
import { fixToolPairs, stripTrailingAssistantOrphanToolUse } from "./contextManager.ts";
|
||||
|
||||
/**
|
||||
* `anthropic-compatible-cc-*` targets Anthropic relay gateways that only accept
|
||||
@@ -349,6 +350,24 @@ export async function buildAndSignClaudeCodeRequest(
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5c: Guard against orphan tool_use / tool_result blocks.
|
||||
// Anthropic rejects requests where a tool_use has no matching tool_result
|
||||
// in the next user message (e.g. `messages.N: tool_use ids were found
|
||||
// without tool_result blocks immediately after: toolu_...`). Clients can
|
||||
// ship truncated histories mid-tool-call; fixToolPairs strips orphans
|
||||
// (preserving final-message tool_use for in-flight rounds), then
|
||||
// stripTrailingAssistantOrphanToolUse catches the case where the request
|
||||
// body itself ends on an unmatched assistant(tool_use) — invalid for an
|
||||
// upstream-send turn since the body must end on a user message.
|
||||
// Both are idempotent on clean histories.
|
||||
{
|
||||
const b = body as Record<string, unknown>;
|
||||
if (Array.isArray(b.messages)) {
|
||||
const fixed = fixToolPairs(b.messages as Record<string, unknown>[]);
|
||||
b.messages = stripTrailingAssistantOrphanToolUse(fixed);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Obfuscation (optional, per-provider setting)
|
||||
if (enableObfuscation) {
|
||||
obfuscateInBody(body);
|
||||
|
||||
@@ -297,7 +297,7 @@ function purifyHistory(messages: Record<string, unknown>[], targetTokens: number
|
||||
* - OpenAI: "Invalid message format"
|
||||
* - Gemini: "Function response without function call"
|
||||
*/
|
||||
function fixToolPairs(messages: Record<string, unknown>[]) {
|
||||
export function fixToolPairs(messages: Record<string, unknown>[]) {
|
||||
// Pass 1: Collect all tool_result IDs from user/tool messages
|
||||
const toolResultIds = new Set();
|
||||
for (const msg of messages) {
|
||||
@@ -400,3 +400,71 @@ function fixToolPairs(messages: Record<string, unknown>[]) {
|
||||
})
|
||||
.filter(Boolean) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Upstream-send guard: after `fixToolPairs`, strip a trailing assistant
|
||||
* message whose only/remaining content is an orphan `tool_use` block.
|
||||
*
|
||||
* `fixToolPairs` intentionally preserves a final-message `tool_use` because
|
||||
* during context pruning the client is still waiting on the matching
|
||||
* `tool_result` — dropping it there would lose state. But on the
|
||||
* upstream-send path the request body must end on a user turn; a trailing
|
||||
* `assistant(tool_use)` triggers the same Anthropic 400 the guard is
|
||||
* trying to prevent:
|
||||
* messages.N: `tool_use` ids were found without `tool_result` blocks
|
||||
* immediately after: toolu_...
|
||||
*
|
||||
* Behavior:
|
||||
* - If the last message is `assistant` and contains any `tool_use` block,
|
||||
* those blocks are removed.
|
||||
* - If removal leaves the message with no content / tool_calls at all, the
|
||||
* message itself is dropped.
|
||||
* - Idempotent on clean histories (trailing user, trailing assistant with
|
||||
* only text/thinking, etc.).
|
||||
*/
|
||||
export function stripTrailingAssistantOrphanToolUse(
|
||||
messages: Record<string, unknown>[]
|
||||
): Record<string, unknown>[] {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return messages;
|
||||
|
||||
const lastIdx = messages.length - 1;
|
||||
const last = messages[lastIdx];
|
||||
if (!last || last.role !== "assistant") return messages;
|
||||
|
||||
let modified = false;
|
||||
const newLast: Record<string, unknown> = { ...last };
|
||||
|
||||
if (Array.isArray(newLast.tool_calls)) {
|
||||
const filteredCalls = (newLast.tool_calls as Record<string, unknown>[]).filter(
|
||||
() => false // remove all trailing tool_calls (none can be paired by definition)
|
||||
);
|
||||
if (filteredCalls.length !== (newLast.tool_calls as unknown[]).length) {
|
||||
newLast.tool_calls = filteredCalls;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(newLast.content)) {
|
||||
const filteredContent = (newLast.content as Record<string, unknown>[]).filter(
|
||||
(block) => block.type !== "tool_use"
|
||||
);
|
||||
if (filteredContent.length !== (newLast.content as unknown[]).length) {
|
||||
newLast.content = filteredContent;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!modified) return messages;
|
||||
|
||||
// If the last message is now empty, drop it.
|
||||
const hasContent =
|
||||
typeof newLast.content === "string"
|
||||
? (newLast.content as string).trim().length > 0
|
||||
: Array.isArray(newLast.content) && (newLast.content as unknown[]).length > 0;
|
||||
const hasToolCalls =
|
||||
Array.isArray(newLast.tool_calls) && (newLast.tool_calls as unknown[]).length > 0;
|
||||
|
||||
const result = messages.slice(0, lastIdx);
|
||||
if (hasContent || hasToolCalls) result.push(newLast);
|
||||
return result;
|
||||
}
|
||||
|
||||
193
tests/unit/cc-bridge-tool-pair-guard.test.ts
Normal file
193
tests/unit/cc-bridge-tool-pair-guard.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { buildAndSignClaudeCodeRequest } =
|
||||
await import("../../open-sse/services/claudeCodeCompatible.ts");
|
||||
const { fixToolPairs, stripTrailingAssistantOrphanToolUse } =
|
||||
await import("../../open-sse/services/contextManager.ts");
|
||||
|
||||
// Regression for the Anthropic 400:
|
||||
// `messages.N: tool_use ids were found without tool_result blocks
|
||||
// immediately after: toolu_...`
|
||||
// The CC bridge now invokes fixToolPairs in step 5c before serialization
|
||||
// so orphan tool_use blocks from mid-tool-call truncated histories are
|
||||
// stripped before reaching Anthropic.
|
||||
|
||||
test("fixToolPairs strips orphan tool_use blocks from non-final assistant messages", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "hi" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "calling" },
|
||||
{ type: "tool_use", id: "toolu_orphan", name: "Bash", input: {} },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "no tool result here" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "done" },
|
||||
{ type: "tool_use", id: "toolu_kept", name: "Bash", input: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_kept", content: "ok" }],
|
||||
},
|
||||
];
|
||||
|
||||
const fixed = fixToolPairs(messages as never);
|
||||
const text = JSON.stringify(fixed);
|
||||
assert.ok(!text.includes("toolu_orphan"), "orphan tool_use must be stripped");
|
||||
assert.ok(text.includes("toolu_kept"), "paired tool_use must survive");
|
||||
});
|
||||
|
||||
test("fixToolPairs is idempotent on clean histories", () => {
|
||||
const cleanMessages = [
|
||||
{ role: "user", content: "hi" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "calling" },
|
||||
{ type: "tool_use", id: "toolu_a", name: "Bash", input: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_a", content: "ok" }],
|
||||
},
|
||||
];
|
||||
|
||||
const once = fixToolPairs(cleanMessages as never);
|
||||
const twice = fixToolPairs(once);
|
||||
assert.deepEqual(once, twice, "idempotent on clean histories");
|
||||
});
|
||||
|
||||
test("buildAndSignClaudeCodeRequest invokes fixToolPairs via step 5c", async () => {
|
||||
// Pass messages via claudeBody (BuildRequestOptions accepts sourceBody/
|
||||
// normalizedBody/claudeBody — claudeBody is the path that preserves the
|
||||
// shape we expect for an Anthropic-format upstream).
|
||||
const result = await buildAndSignClaudeCodeRequest({
|
||||
model: "claude-opus-4-7",
|
||||
apiKey: "test-key",
|
||||
claudeBody: {
|
||||
model: "claude-opus-4-7",
|
||||
max_tokens: 32,
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "calling" },
|
||||
{ type: "tool_use", id: "toolu_orphan", name: "Bash", input: {} },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "no tool result here" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "done" },
|
||||
{ type: "tool_use", id: "toolu_kept", name: "Bash", input: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_kept", content: "ok" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const body = JSON.parse(result.bodyString);
|
||||
const text = JSON.stringify(body.messages);
|
||||
assert.ok(!text.includes("toolu_orphan"), "orphan tool_use must be stripped before send");
|
||||
assert.ok(text.includes("toolu_kept"), "paired tool_use must survive");
|
||||
});
|
||||
|
||||
test("stripTrailingAssistantOrphanToolUse strips trailing assistant tool_use blocks", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "hi" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "thinking" },
|
||||
{ type: "tool_use", id: "toolu_trailing", name: "Bash", input: {} },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const stripped = stripTrailingAssistantOrphanToolUse(messages as never);
|
||||
const text = JSON.stringify(stripped);
|
||||
assert.ok(!text.includes("toolu_trailing"), "trailing tool_use must be removed");
|
||||
// Text content survives — message kept, only tool_use blocks removed.
|
||||
assert.ok(text.includes("thinking"), "non-tool_use content must survive");
|
||||
});
|
||||
|
||||
test("stripTrailingAssistantOrphanToolUse drops final message if it becomes empty", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "hi" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "toolu_only", name: "Bash", input: {} }],
|
||||
},
|
||||
];
|
||||
|
||||
const stripped = stripTrailingAssistantOrphanToolUse(messages as never);
|
||||
assert.equal(stripped.length, 1, "now-empty assistant message dropped entirely");
|
||||
assert.equal(stripped[0].role, "user");
|
||||
});
|
||||
|
||||
test("stripTrailingAssistantOrphanToolUse is a no-op when last message is a user turn", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "hello" }] },
|
||||
{ role: "user", content: "follow up" },
|
||||
];
|
||||
|
||||
const stripped = stripTrailingAssistantOrphanToolUse(messages as never);
|
||||
assert.deepEqual(stripped, messages);
|
||||
});
|
||||
|
||||
test("stripTrailingAssistantOrphanToolUse is a no-op on text-only trailing assistant", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "answer" }] },
|
||||
];
|
||||
|
||||
const stripped = stripTrailingAssistantOrphanToolUse(messages as never);
|
||||
assert.deepEqual(stripped, messages);
|
||||
});
|
||||
|
||||
test("buildAndSignClaudeCodeRequest strips trailing assistant tool_use (gemini HIGH review)", async () => {
|
||||
// Anthropic rejects a body whose LAST message is assistant(tool_use)
|
||||
// with no matching tool_result in the next user message — by definition
|
||||
// there is no next message. fixToolPairs alone preserves the trailing
|
||||
// tool_use (intentional for context pruning), so the guard pipeline
|
||||
// pairs it with stripTrailingAssistantOrphanToolUse.
|
||||
const result = await buildAndSignClaudeCodeRequest({
|
||||
model: "claude-opus-4-7",
|
||||
apiKey: "test-key",
|
||||
claudeBody: {
|
||||
model: "claude-opus-4-7",
|
||||
max_tokens: 32,
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "calling" },
|
||||
{ type: "tool_use", id: "toolu_trailing", name: "Bash", input: {} },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const body = JSON.parse(result.bodyString);
|
||||
const text = JSON.stringify(body.messages);
|
||||
assert.ok(
|
||||
!text.includes("toolu_trailing"),
|
||||
"trailing assistant tool_use must be stripped before upstream send"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user