diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 3d33b7f900..94094155ba 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -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)[ "_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; + if (Array.isArray(tb?.messages)) { + const fixed = fixToolPairs(tb.messages as Record[]); + tb.messages = stripTrailingAssistantOrphanToolUse(fixed); + } + } let bodyString = JSON.stringify(transformedBody); const shouldFingerprint = diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index 2b67bc7092..832927aecc 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -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; + if (Array.isArray(b.messages)) { + const fixed = fixToolPairs(b.messages as Record[]); + b.messages = stripTrailingAssistantOrphanToolUse(fixed); + } + } + // Step 6: Obfuscation (optional, per-provider setting) if (enableObfuscation) { obfuscateInBody(body); diff --git a/open-sse/services/contextManager.ts b/open-sse/services/contextManager.ts index ec155b4f66..ccf3478653 100644 --- a/open-sse/services/contextManager.ts +++ b/open-sse/services/contextManager.ts @@ -297,7 +297,7 @@ function purifyHistory(messages: Record[], targetTokens: number * - OpenAI: "Invalid message format" * - Gemini: "Function response without function call" */ -function fixToolPairs(messages: Record[]) { +export function fixToolPairs(messages: Record[]) { // 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[]) { }) .filter(Boolean) as Record[]; } + +/** + * 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[] +): Record[] { + 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 = { ...last }; + + if (Array.isArray(newLast.tool_calls)) { + const filteredCalls = (newLast.tool_calls as Record[]).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[]).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; +} diff --git a/tests/unit/cc-bridge-tool-pair-guard.test.ts b/tests/unit/cc-bridge-tool-pair-guard.test.ts new file mode 100644 index 0000000000..6d169c9d67 --- /dev/null +++ b/tests/unit/cc-bridge-tool-pair-guard.test.ts @@ -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" + ); +});