fix(chatcore): default Claude tool type to "custom" when missing (#5662)

Integrated into release/v3.8.43. Port from 9router#2196.

Co-authored-by: warelik <warelik@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-30 14:57:29 -03:00
committed by GitHub
parent cf25de44d4
commit 66374642f9
3 changed files with 99 additions and 0 deletions

View File

@@ -62,6 +62,7 @@ import { checkHeapPressureGuard } from "../utils/heapPressure.ts";
import { normalizeHeaders } from "../utils/headers.ts";
import { resolveChatCoreRequestFormat } from "./chatCore/requestFormat.ts";
import { resolveChatCoreTargetFormat } from "./chatCore/targetFormat.ts";
import { defaultClaudeToolType } from "./chatCore/claudeToolDefaults.ts";
import { injectSystemPrompt, injectCustomSystemPrompt } from "../services/systemPrompt.ts";
import { translateRequest, needsTranslation } from "../translator/index.ts";
import { FORMATS } from "../translator/formats.ts";
@@ -1876,6 +1877,16 @@ export async function handleChatCore({
}
}
// Claude: strict Anthropic-compatible gateways (e.g. MiniMax) reject tool
// definitions that omit the required `type` discriminator with HTTP 400. Default
// a missing `type` to "custom" before dispatch, mirroring Anthropic's own
// inference, so legacy Claude-format tool payloads survive strict gateways (#2195).
if (targetFormat === FORMATS.CLAUDE && Array.isArray(translatedBody.tools)) {
translatedBody.tools = defaultClaudeToolType(
translatedBody.tools
) as typeof translatedBody.tools;
}
// Extract toolNameMap for response translation (Claude OAuth)
const translatedToolNameMap = translatedBody._toolNameMap;
const nativeClaudeToolNameMap = isClaudePassthrough

View File

@@ -0,0 +1,24 @@
// Claude (Anthropic Messages) tool-definition normalization for outbound requests.
type UnknownRecord = Record<string, unknown>;
/**
* Claude's tool schema requires every tool to carry an explicit `type` discriminator
* (e.g. "custom", "computer_20241022", "bash_20241022"). Anthropic's own API infers
* "custom" when it's omitted, but strict Anthropic-compatible gateways (e.g. MiniMax)
* enforce the documented schema and reject payloads whose tools lack `type` with
* HTTP 400. Default a missing `type` to "custom" so legacy Claude-format tool
* definitions survive strict gateways, while leaving any tool that already declares a
* type (incl. built-in tool types) untouched. (port from 9router#2195)
*
* Non-array input is returned unchanged; defaulted entries are new objects so the
* caller's original tool objects are not mutated.
*/
export function defaultClaudeToolType(tools: unknown): unknown {
if (!Array.isArray(tools)) return tools;
return tools.map((tool) =>
tool && typeof tool === "object" && !Array.isArray(tool) && (tool as UnknownRecord).type
? tool
: { type: "custom", ...(tool as UnknownRecord) }
);
}

View File

@@ -0,0 +1,64 @@
import test from "node:test";
import assert from "node:assert/strict";
// Port of 9router#2196 (fixes #2195): Claude's tool schema requires each tool to
// carry an explicit `type` discriminator. Anthropic's first-party API infers
// "custom" when omitted, but strict Anthropic-compatible gateways (e.g. MiniMax)
// reject the payload with HTTP 400. defaultClaudeToolType() backfills the missing
// `type` so legacy Claude-format tool definitions survive strict gateways.
const { defaultClaudeToolType } = await import(
"../../open-sse/handlers/chatCore/claudeToolDefaults.ts"
);
test("backfills type:'custom' on a Claude tool missing the type field", () => {
const tools = [
{ name: "get_weather", description: "Get weather", input_schema: { type: "object" } },
];
const out = defaultClaudeToolType(tools) as Array<Record<string, unknown>>;
assert.equal(out[0].type, "custom");
// Other fields are preserved untouched.
assert.equal(out[0].name, "get_weather");
assert.equal(out[0].description, "Get weather");
assert.deepEqual(out[0].input_schema, { type: "object" });
});
test("leaves tools that already declare a type untouched", () => {
const tools = [
{ type: "custom", name: "a", input_schema: {} },
{ type: "computer_20241022", name: "computer" },
{ type: "bash_20241022", name: "bash" },
];
const out = defaultClaudeToolType(tools) as Array<Record<string, unknown>>;
assert.deepEqual(
out.map((t) => t.type),
["custom", "computer_20241022", "bash_20241022"]
);
// Non-custom built-in tool types must be preserved, not overwritten.
assert.equal(out[1].type, "computer_20241022");
});
test("normalizes a mixed array — only the type-less entries get defaulted", () => {
const tools = [
{ type: "computer_20241022", name: "computer" },
{ name: "get_weather", input_schema: {} },
];
const out = defaultClaudeToolType(tools) as Array<Record<string, unknown>>;
assert.equal(out[0].type, "computer_20241022");
assert.equal(out[1].type, "custom");
});
test("returns non-array input unchanged (no tools / undefined)", () => {
assert.equal(defaultClaudeToolType(undefined), undefined);
assert.equal(defaultClaudeToolType(null), null);
const obj = { not: "an array" };
assert.equal(defaultClaudeToolType(obj), obj);
});
test("does not mutate the original tool objects (returns new entries for defaulted tools)", () => {
const original = { name: "x", input_schema: {} };
const tools = [original];
const out = defaultClaudeToolType(tools) as Array<Record<string, unknown>>;
assert.equal(original.type, undefined, "original tool must stay untouched");
assert.equal(out[0].type, "custom");
});