From 998bb3d97593ac668c74f506e54c48a74a4e9cd1 Mon Sep 17 00:00:00 2001 From: galiehneh Date: Thu, 10 Sep 2026 23:03:42 +0700 Subject: [PATCH] fix(tools): scope Claude tool type defaulting to gateways that need it (#3905) defaultClaudeToolType() stamped tools[].type = "custom" onto every Claude-format request carrying tools since e08ac6da. That satisfied MiniMax (error 2013) but broke Anthropic-compatible endpoints that only accept the legacy typeless tool shape. DeepSeek's endpoint (api.deepseek.com/anthropic/v1/messages) whitelists its tool `type` enum to the web_search_* variants and answers HTTP 400 "unknown variant `custom`", so every Claude Code request routed to a DeepSeek connection failed and surfaced as a persistent 503. Run the defaulting only when the target provider declares the new requireClaudeToolType quirk (MiniMax, MiniMax-CN). Add shouldDefaultClaudeToolType(provider, finalFormat, tools, PROVIDERS) in translator/concerns/toolCall.js so the gate is unit-testable, and cover MiniMax keeping the explicit type, DeepSeek/Anthropic staying typeless, non-Claude formats and tool-less requests never defaulting. Effectively a no-op for MiniMax and a restore of the pre-e08ac6da behaviour everywhere else. Another strict gateway now only needs the same one-line quirk instead of a global behavioural change. --- open-sse/handlers/chatCore.js | 8 +++-- open-sse/providers/registry/minimax-cn.js | 1 + open-sse/providers/registry/minimax.js | 1 + open-sse/translator/concerns/toolCall.js | 15 +++++++++ .../bugs-3905-deepseek-tool-type.test.js | 33 +++++++++++++++++++ 5 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 tests/translator/bugs-3905-deepseek-tool-type.test.js diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index d5dcf2d8..85c6fc4a 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -28,7 +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 { defaultClaudeToolType, shouldDefaultClaudeToolType } from "../translator/concerns/toolCall.js"; import { resolveSessionId } from "../utils/sessionManager.js"; /** @@ -243,7 +243,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // 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)) { + // Provider-scoped via quirks (shouldDefaultClaudeToolType): only gateways that declare + // requireClaudeToolType get the explicit type. Applying it unconditionally breaks + // Claude-format endpoints that only accept the legacy typeless tool shape — DeepSeek's + // Anthropic-compatible endpoint 400s with "unknown variant `custom`" (#3905). + if (shouldDefaultClaudeToolType(provider, finalFormat, translatedBody.tools, PROVIDERS)) { translatedBody.tools = defaultClaudeToolType(translatedBody.tools); } diff --git a/open-sse/providers/registry/minimax-cn.js b/open-sse/providers/registry/minimax-cn.js index 19aa3127..268a7344 100644 --- a/open-sse/providers/registry/minimax-cn.js +++ b/open-sse/providers/registry/minimax-cn.js @@ -22,6 +22,7 @@ export default { headers: { ...CLAUDE_API_HEADERS }, quirks: { dropOutputConfig: true, + requireClaudeToolType: true, }, reasoningInject: { scope: "all", diff --git a/open-sse/providers/registry/minimax.js b/open-sse/providers/registry/minimax.js index 47b82c89..66c00958 100644 --- a/open-sse/providers/registry/minimax.js +++ b/open-sse/providers/registry/minimax.js @@ -22,6 +22,7 @@ export default { headers: { ...CLAUDE_API_HEADERS }, quirks: { dropOutputConfig: true, + requireClaudeToolType: true, }, reasoningInject: { scope: "all", diff --git a/open-sse/translator/concerns/toolCall.js b/open-sse/translator/concerns/toolCall.js index 958764dd..251850c1 100644 --- a/open-sse/translator/concerns/toolCall.js +++ b/open-sse/translator/concerns/toolCall.js @@ -1,5 +1,7 @@ // Tool call helper functions for translator +import { FORMATS } from "../formats.js"; + // Anthropic tool_use.id must match: ^[a-zA-Z0-9_-]+$ const TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; @@ -165,3 +167,16 @@ export function defaultClaudeToolType(tools) { return tools.map(tool => tool?.type ? tool : { ...tool, type: "custom" }); } +// Whether Claude-format tools need explicit `type` defaulting before dispatch. +// Only gateways that declare the `requireClaudeToolType` quirk (MiniMax) reject typeless +// tools. Applying the default globally breaks Claude-format endpoints that only accept the +// legacy typeless tool shape — DeepSeek's Anthropic-compatible endpoint answers HTTP 400 +// "unknown variant `custom`" and every Claude Code request routed there fails (#3905). +export function shouldDefaultClaudeToolType(provider, finalFormat, tools, PROVIDERS) { + return ( + finalFormat === FORMATS.CLAUDE + && Array.isArray(tools) + && PROVIDERS?.[provider]?.quirks?.requireClaudeToolType === true + ); +} + diff --git a/tests/translator/bugs-3905-deepseek-tool-type.test.js b/tests/translator/bugs-3905-deepseek-tool-type.test.js new file mode 100644 index 00000000..fd561d0b --- /dev/null +++ b/tests/translator/bugs-3905-deepseek-tool-type.test.js @@ -0,0 +1,33 @@ +// Regression for #3905: defaultClaudeToolType() (type:"custom") must only run for +// gateways that declare the requireClaudeToolType quirk (MiniMax). Claude-format +// endpoints that only accept the legacy typeless tool shape — e.g. DeepSeek's +// Anthropic-compatible endpoint, which answers HTTP 400 "unknown variant `custom`" — +// must never receive tools[].type = "custom". +import { describe, it, expect } from "vitest"; +import { PROVIDERS } from "../../open-sse/providers/index.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; +import { shouldDefaultClaudeToolType } from "../../open-sse/translator/concerns/toolCall.js"; + +const tools = [{ name: "get_weather", description: "weather", input_schema: { type: "object" } }]; + +describe("Claude tool `type` defaulting is provider-scoped (#3905)", () => { + it("runs only for providers declaring requireClaudeToolType", () => { + expect(shouldDefaultClaudeToolType("minimax", FORMATS.CLAUDE, tools, PROVIDERS)).toBe(true); + expect(shouldDefaultClaudeToolType("minimax-cn", FORMATS.CLAUDE, tools, PROVIDERS)).toBe(true); + // Endpoints accepting only the legacy typeless shape must NOT get type:"custom". + expect(shouldDefaultClaudeToolType("deepseek", FORMATS.CLAUDE, tools, PROVIDERS)).toBe(false); + expect(shouldDefaultClaudeToolType("claude", FORMATS.CLAUDE, tools, PROVIDERS)).toBe(false); + }); + + it("never applies outside Claude-format requests or without tools", () => { + expect(shouldDefaultClaudeToolType("minimax", FORMATS.OPENAI, tools, PROVIDERS)).toBe(false); + expect(shouldDefaultClaudeToolType("minimax", FORMATS.CLAUDE, undefined, PROVIDERS)).toBe(false); + expect(shouldDefaultClaudeToolType("minimax", FORMATS.CLAUDE, null, PROVIDERS)).toBe(false); + }); + + it("declares the quirk only on the MiniMax providers (registry tripwire)", () => { + expect(PROVIDERS.minimax?.quirks?.requireClaudeToolType).toBe(true); + expect(PROVIDERS["minimax-cn"]?.quirks?.requireClaudeToolType).toBe(true); + expect(PROVIDERS.deepseek?.quirks?.requireClaudeToolType).toBeUndefined(); + }); +});