From ed1bd0c528220189fe6d15c4c71dfa4a95d92ee4 Mon Sep 17 00:00:00 2001 From: Federico Liva Date: Wed, 2 Sep 2026 20:06:30 +0700 Subject: [PATCH] fix(claude): drop server_tool_use blocks carrying a foreign id Anthropic validates server_tool_use.id against ^srvtoolu_[a-zA-Z0-9_]+$ and 400s the whole request when one does not match. A combo that falls back to a provider with its own built-in tools (z.ai/glm emits OpenAI-style call_ ids for analyze_image) leaves such blocks in the history, so every later Claude turn fails. Extend normalizeClaudePassthrough to drop those blocks (reusing the existing loop), drop the paired tool_result / web_search_tool_result referencing a dropped id, and drop empty text blocks plus messages left with no content. Well-formed srvtoolu_ blocks and regular tool_use ids are untouched. --- open-sse/translator/formats/claude.js | 47 ++++++++++ open-sse/translator/schema/blocks.js | 2 + .../claude-foreign-server-tool-use.test.js | 88 +++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 tests/unit/claude-foreign-server-tool-use.test.js diff --git a/open-sse/translator/formats/claude.js b/open-sse/translator/formats/claude.js index 81e72a44..5679de9f 100644 --- a/open-sse/translator/formats/claude.js +++ b/open-sse/translator/formats/claude.js @@ -108,11 +108,24 @@ function buildThinkingPlaceholder(provider) { return block; } +// Anthropic validates server_tool_use ids against this pattern and rejects the +// whole request with a 400 when one does not match. A combo that falls back to a +// provider with its own built-in tools (z.ai/glm emits OpenAI-style `call_` ids for +// its analyze_image tool) leaves such blocks in the history, so every later Claude +// turn carries a poisoned id. +const CLAUDE_SERVER_TOOL_USE_ID = /^srvtoolu_[a-zA-Z0-9_]+$/; + +function hasForeignServerToolUseId(block) { + return block?.type === CLAUDE_BLOCK.SERVER_TOOL_USE + && !CLAUDE_SERVER_TOOL_USE_ID.test(String(block.id ?? "")); +} + // Normalize a native Claude passthrough body to match Anthropic Messages API spec. // Newer Cowork/Claude Code clients emit beta-only shapes that OAuth endpoints reject: // 1. thinking.type "adaptive" → unsupported on Haiku // 2. output_config.effort → unsupported on Haiku // 3. role "system" messages (mid-conversation-system beta) → only top-level system is allowed +// 4. server_tool_use blocks carrying a foreign (non-srvtoolu_) id → rejected outright export function normalizeClaudePassthrough(body, model = "") { if (!body || typeof body !== "object") return body; @@ -164,6 +177,7 @@ export function normalizeClaudePassthrough(body, model = "") { // 3. Drop thinking blocks whose signature is not Claude's (combo mixes models, // so foreign signatures leak into history and Anthropic rejects them). const thinkingEnabled = body.thinking?.type === "enabled"; + const droppedServerToolUseIds = new Set(); if (Array.isArray(body.messages)) { for (const msg of body.messages) { if (msg.role !== ROLE.ASSISTANT || !Array.isArray(msg.content)) continue; @@ -178,6 +192,10 @@ export function normalizeClaudePassthrough(body, model = "") { } continue; } + if (hasForeignServerToolUseId(block)) { + if (block.id != null) droppedServerToolUseIds.add(String(block.id)); + continue; + } if (block.type === CLAUDE_BLOCK.TOOL_USE) hasToolUse = true; kept.push(block); } @@ -188,6 +206,35 @@ export function normalizeClaudePassthrough(body, model = "") { } } + // A dropped server_tool_use leaves its result behind; Anthropic rejects a + // tool_result that references an id no block declares, so both halves must go. + if (droppedServerToolUseIds.size > 0 && Array.isArray(body.messages)) { + for (const msg of body.messages) { + if (!Array.isArray(msg.content)) continue; + const kept = msg.content.filter(block => !( + (block?.type === CLAUDE_BLOCK.TOOL_RESULT || block?.type === CLAUDE_BLOCK.WEB_SEARCH_TOOL_RESULT) + && droppedServerToolUseIds.has(String(block.tool_use_id ?? "")) + )); + if (kept.length !== msg.content.length) { + msg.content = kept; + } + } + } + + // 5. Drop empty text blocks and any message left with no content at all. + // Anthropic rejects `messages.N.content` blocks with empty text (400 + // "text content blocks must be non-empty"); a message whose blocks were all + // stripped above must be dropped, not padded with an empty placeholder. + if (Array.isArray(body.messages)) { + body.messages = body.messages.filter(msg => { + if (typeof msg.content === "string") return msg.content.trim().length > 0; + if (!Array.isArray(msg.content)) return true; + msg.content = msg.content.filter(block => + !(block?.type === CLAUDE_BLOCK.TEXT && !String(block.text ?? "").trim())); + return msg.content.length > 0; + }); + } + return body; } diff --git a/open-sse/translator/schema/blocks.js b/open-sse/translator/schema/blocks.js index 958122ce..91c71c4a 100644 --- a/open-sse/translator/schema/blocks.js +++ b/open-sse/translator/schema/blocks.js @@ -20,6 +20,8 @@ export const CLAUDE_BLOCK = { TOOL_RESULT: "tool_result", THINKING: "thinking", REDACTED_THINKING: "redacted_thinking", + SERVER_TOOL_USE: "server_tool_use", + WEB_SEARCH_TOOL_RESULT: "web_search_tool_result", }; // OpenAI Responses API item types. diff --git a/tests/unit/claude-foreign-server-tool-use.test.js b/tests/unit/claude-foreign-server-tool-use.test.js new file mode 100644 index 00000000..62a3dc9f --- /dev/null +++ b/tests/unit/claude-foreign-server-tool-use.test.js @@ -0,0 +1,88 @@ +// A combo that mixes providers leaks foreign block shapes into the Claude history. +// Anthropic validates server_tool_use ids against ^srvtoolu_[a-zA-Z0-9_]+$ and 400s +// the whole request when a provider (e.g. z.ai/glm) emits OpenAI-style call_ ids. +import { describe, it, expect } from "vitest"; +import { normalizeClaudePassthrough } from "../../open-sse/translator/formats/claude.js"; + +const glmServerToolUse = () => ({ + role: "assistant", + content: [ + { type: "text", text: "searching" }, + { type: "server_tool_use", id: "call_50b82aba1b754d82a4408a53", name: "analyze_image", input: {} }, + ], +}); + +describe("normalizeClaudePassthrough — foreign server_tool_use ids", () => { + it("drops a server_tool_use block whose id is not an srvtoolu_ id", () => { + const out = normalizeClaudePassthrough({ messages: [glmServerToolUse()] }); + expect(out.messages[0].content).toEqual([{ type: "text", text: "searching" }]); + }); + + it("drops the paired tool_result so no orphan reference is left behind", () => { + const out = normalizeClaudePassthrough({ + messages: [ + glmServerToolUse(), + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "call_50b82aba1b754d82a4408a53", content: "boom" }, + { type: "text", text: "keep me" }, + ], + }, + ], + }); + expect(out.messages[1].content).toEqual([{ type: "text", text: "keep me" }]); + }); + + it("keeps a well-formed Anthropic server_tool_use block", () => { + const block = { type: "server_tool_use", id: "srvtoolu_01EUi6RNgHntbStfCjgLyLzz", name: "web_search", input: {} }; + const out = normalizeClaudePassthrough({ messages: [{ role: "assistant", content: [block] }] }); + expect(out.messages[0].content).toEqual([block]); + }); + + it("keeps regular tool_use blocks, whatever their id looks like", () => { + const block = { type: "tool_use", id: "call_942248714fef4a9abb8e8eff", name: "Bash", input: { command: "ls" } }; + const out = normalizeClaudePassthrough({ messages: [{ role: "assistant", content: [block] }] }); + expect(out.messages[0].content).toEqual([block]); + }); + + it("drops a message whose blocks were all stripped instead of padding it with empty text", () => { + const out = normalizeClaudePassthrough({ + messages: [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { role: "assistant", content: [{ type: "server_tool_use", id: "call_x", name: "analyze_image", input: {} }] }, + { role: "user", content: [{ type: "text", text: "bye" }] }, + ], + }); + expect(out.messages).toHaveLength(2); + expect(out.messages.map(m => m.role)).toEqual(["user", "user"]); + }); + + it("strips empty text blocks a client put in the history (Anthropic 400s them)", () => { + const out = normalizeClaudePassthrough({ + messages: [{ role: "assistant", content: [{ type: "text", text: "real" }, { type: "text", text: "" }] }], + }); + expect(out.messages[0].content).toEqual([{ type: "text", text: "real" }]); + }); + + it("drops a message whose content is a single empty text block", () => { + const out = normalizeClaudePassthrough({ + messages: [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { role: "assistant", content: [{ type: "text", text: "" }] }, + ], + }); + expect(out.messages).toHaveLength(1); + }); + + it("drops a message whose string content is empty", () => { + const out = normalizeClaudePassthrough({ + messages: [ + { role: "user", content: "hello" }, + { role: "assistant", content: "" }, + ], + }); + expect(out.messages).toHaveLength(1); + expect(out.messages[0].content).toBe("hello"); + }); +});