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.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
88
tests/unit/claude-foreign-server-tool-use.test.js
Normal file
88
tests/unit/claude-foreign-server-tool-use.test.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user