fix(claude): guard orphan tool_use/tool_result pairs before upstream send (#2312)

Integrated into release/v3.8.0
This commit is contained in:
Mourad Maatoug
2026-05-16 22:39:47 +02:00
committed by GitHub
parent ebef1648be
commit 8eb721ec31
4 changed files with 297 additions and 1 deletions

View File

@@ -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 =

View File

@@ -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);

View File

@@ -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;
}

View 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"
);
});