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.
|
||||
|
||||
Reference in New Issue
Block a user