From 66374642f9bf0bf9e0f9b4bcd8ce03d8cacfafdc Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:57:29 -0300 Subject: [PATCH] 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 --- open-sse/handlers/chatCore.ts | 11 ++++ .../handlers/chatCore/claudeToolDefaults.ts | 24 +++++++ .../claude-tool-type-default-2195.test.ts | 64 +++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 open-sse/handlers/chatCore/claudeToolDefaults.ts create mode 100644 tests/unit/claude-tool-type-default-2195.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 8f9a810cf3..2091c1d150 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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 diff --git a/open-sse/handlers/chatCore/claudeToolDefaults.ts b/open-sse/handlers/chatCore/claudeToolDefaults.ts new file mode 100644 index 0000000000..10cd368387 --- /dev/null +++ b/open-sse/handlers/chatCore/claudeToolDefaults.ts @@ -0,0 +1,24 @@ +// Claude (Anthropic Messages) tool-definition normalization for outbound requests. + +type UnknownRecord = Record; + +/** + * 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) } + ); +} diff --git a/tests/unit/claude-tool-type-default-2195.test.ts b/tests/unit/claude-tool-type-default-2195.test.ts new file mode 100644 index 0000000000..927f049b88 --- /dev/null +++ b/tests/unit/claude-tool-type-default-2195.test.ts @@ -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>; + 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>; + 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>; + 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>; + assert.equal(original.type, undefined, "original tool must stay untouched"); + assert.equal(out[0].type, "custom"); +});