From e08ac6dada69a909a114abc5fa843cd69c9a4523 Mon Sep 17 00:00:00 2001 From: warelik Date: Fri, 28 Aug 2026 11:07:07 +0700 Subject: [PATCH] fix(tools): default Claude tool type when missing Strict Anthropic-compatible gateways (e.g. MiniMax) reject Claude-format requests with HTTP 400 when tools[].type is missing. Normalize each missing/falsy tools[].type to "custom" before dispatch when the final request format is Claude. Built-in tool types (computer_use, bash, web_search_*) are passed through untouched. --- open-sse/handlers/chatCore.js | 7 ++ open-sse/translator/concerns/toolCall.js | 14 +++ open-sse/utils/claudeToolTypeSelfCheck.mjs | 117 +++++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 open-sse/utils/claudeToolTypeSelfCheck.mjs diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index cb6ce961..e942bd75 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -28,6 +28,7 @@ import { compressWithPxpipe } from "../rtk/pxpipe.js"; import { getCapabilitiesForModel } from "../providers/capabilities.js"; import { stripUnsupportedModalities } from "../translator/concerns/modality.js"; import { prefetchRemoteImages } from "../translator/concerns/prefetch.js"; +import { defaultClaudeToolType } from "../translator/concerns/toolCall.js"; import { resolveSessionId } from "../utils/sessionManager.js"; /** @@ -235,6 +236,12 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred delete translatedBody.tools; } + // Claude tool schema requires `type` to be explicitly set; strict gateways (e.g., MiniMax) + // reject legacy payloads that omit it with HTTP 400. Default to "custom" when missing. + if (finalFormat === FORMATS.CLAUDE && Array.isArray(translatedBody.tools)) { + translatedBody.tools = defaultClaudeToolType(translatedBody.tools); + } + // Per-request opt-out: client can bypass all token savers via header const tokenSaverEnabled = clientRawRequest?.headers?.[TOKEN_SAVER_HEADER]?.toLowerCase() !== "off"; diff --git a/open-sse/translator/concerns/toolCall.js b/open-sse/translator/concerns/toolCall.js index 8de82f71..958764dd 100644 --- a/open-sse/translator/concerns/toolCall.js +++ b/open-sse/translator/concerns/toolCall.js @@ -151,3 +151,17 @@ export function fixMissingToolResponses(body) { return body; } +// Default `type: "custom"` on Claude-format tools that arrive without one. +// Anthropic's Claude tool schema requires `type` to be explicitly set; strict gateways +// (e.g., MiniMax Anthropic-compatible endpoint, error 2013) reject legacy payloads that +// omit it with HTTP 400. Tools that already carry a truthy `type` (e.g., `computer_use`, +// `bash`, `web_search_20250305`) are passed through untouched. +// +// Spread order matters: `{ ...tool, type: "custom" }` (spread first, override last) +// ensures that falsy `type` values (null, undefined, "") in the original tool don't +// overwrite the default. `{ type: "custom", ...tool }` would let `type: null` survive. +export function defaultClaudeToolType(tools) { + if (!Array.isArray(tools)) return tools; + return tools.map(tool => tool?.type ? tool : { ...tool, type: "custom" }); +} + diff --git a/open-sse/utils/claudeToolTypeSelfCheck.mjs b/open-sse/utils/claudeToolTypeSelfCheck.mjs new file mode 100644 index 00000000..5b3b2a6c --- /dev/null +++ b/open-sse/utils/claudeToolTypeSelfCheck.mjs @@ -0,0 +1,117 @@ +// Claude tool type default self-check. +// Run: node open-sse/utils/claudeToolTypeSelfCheck.mjs +// No framework, no deps. Uses assert. Mirrors toolPairingSelfCheck.mjs style. +import { defaultClaudeToolType } from "../translator/concerns/toolCall.js"; + +const results = []; +function run(name, fn) { + try { + fn(); + results.push({ name, ok: true }); + } catch (err) { + results.push({ name, ok: false, err: err.message }); + } +} +const assert = { + equal(a, b, msg) { if (a !== b) throw new Error(`${msg || ""} expected ${b}, got ${a}`); }, + ok(v, msg) { if (!v) throw new Error(msg || "expected truthy"); }, +}; + +// 1. Tool without `type` property → defaults to "custom" +run("Tool without type property defaults to custom", () => { + const tools = [{ name: "foo", description: "bar", input_schema: {} }]; + const out = defaultClaudeToolType(tools); + assert.equal(out[0].type, "custom", "type defaulted"); + assert.equal(out[0].name, "foo", "other fields preserved"); +}); + +// 2. Tool with type:null → defaults to "custom" (the spread-order bug case) +run("Tool with type:null defaults to custom", () => { + const tools = [{ name: "foo", type: null, input_schema: {} }]; + const out = defaultClaudeToolType(tools); + assert.equal(out[0].type, "custom", "null type overwritten to custom"); +}); + +// 3. Tool with type:undefined → defaults to "custom" +run("Tool with type:undefined defaults to custom", () => { + const tools = [{ name: "foo", type: undefined, input_schema: {} }]; + const out = defaultClaudeToolType(tools); + assert.equal(out[0].type, "custom", "undefined type overwritten to custom"); +}); + +// 4. Tool with type:"" (empty string) → defaults to "custom" +run("Tool with type:empty-string defaults to custom", () => { + const tools = [{ name: "foo", type: "", input_schema: {} }]; + const out = defaultClaudeToolType(tools); + assert.equal(out[0].type, "custom", "empty-string type overwritten to custom"); +}); + +// 5. Built-in tool with type:"computer_use" → passed through untouched +run("Built-in tool (computer_use) passed through", () => { + const tools = [{ type: "computer_use", name: "computer", display_width: 1024 }]; + const out = defaultClaudeToolType(tools); + assert.equal(out[0].type, "computer_use", "built-in type preserved"); + assert.equal(out[0], tools[0], "same reference — not cloned"); +}); + +// 6. Tool already with type:"custom" → passed through untouched +run("Tool already with type:custom passed through", () => { + const tools = [{ type: "custom", name: "foo", input_schema: {} }]; + const out = defaultClaudeToolType(tools); + assert.equal(out[0].type, "custom", "existing custom type preserved"); + assert.equal(out[0], tools[0], "same reference — not cloned"); +}); + +// 7. Mixed: built-in + function tool → only function tool gets default +run("Mixed: built-in kept, function tool defaulted", () => { + const tools = [ + { type: "computer_use", name: "computer", display_width: 1024 }, + { name: "search", description: "search the web", input_schema: {} }, + { type: "web_search_20250305", name: "web_search" }, + ]; + const out = defaultClaudeToolType(tools); + assert.equal(out[0].type, "computer_use", "built-in preserved"); + assert.equal(out[1].type, "custom", "function tool defaulted"); + assert.equal(out[2].type, "web_search_20250305", "web_search preserved"); +}); + +// 8. Non-array input → returned unchanged +run("Non-array input returned unchanged", () => { + assert.equal(defaultClaudeToolType(null), null, "null returned as-is"); + assert.equal(defaultClaudeToolType(undefined), undefined, "undefined returned as-is"); + assert.equal(defaultClaudeToolType("not array"), "not array", "string returned as-is"); +}); + +// 9. Empty array → empty array +run("Empty array returns empty array", () => { + const out = defaultClaudeToolType([]); + assert.equal(Array.isArray(out), true, "returns array"); + assert.equal(out.length, 0, "empty array"); +}); + +// 10. Original tools not mutated by reference (new objects for defaulted tools) +run("Original tools not mutated by reference", () => { + const original = { name: "foo", input_schema: {} }; + const tools = [original]; + defaultClaudeToolType(tools); + assert.equal(original.type, undefined, "original tool not mutated"); + assert.ok(!("type" in original), "type property not added to original"); +}); + +// 11. Array with null entry → defaults to { type: "custom" } (optional chaining guard) +// tool?.type returns undefined for null, and { ...null, type: "custom" } === { type: "custom" } +run("Array with null entry defaults to custom", () => { + const tools = [null]; + const out = defaultClaudeToolType(tools); + assert.equal(out[0].type, "custom", "null tool gets type custom"); + assert.equal(Object.keys(out[0]).length, 1, "no other keys from spread of null"); +}); + +// Summary +const passed = results.filter(r => r.ok).length; +const total = results.length; +for (const r of results) { + console.log(`${r.ok ? "ok" : "FAIL"} - ${r.name}${r.ok ? "" : ` :: ${r.err}`}`); +} +console.log(`\n${passed}/${total} checks passed`); +if (passed !== total) process.exit(1);