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.
This commit is contained in:
@@ -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";
|
||||
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
|
||||
117
open-sse/utils/claudeToolTypeSelfCheck.mjs
Normal file
117
open-sse/utils/claudeToolTypeSelfCheck.mjs
Normal file
@@ -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);
|
||||
Reference in New Issue
Block a user