diff --git a/open-sse/providers/registry/deepseek.js b/open-sse/providers/registry/deepseek.js index bb8015b0..2c3a3e73 100644 --- a/open-sse/providers/registry/deepseek.js +++ b/open-sse/providers/registry/deepseek.js @@ -25,6 +25,21 @@ export default { reasoningInject: { scope: "all", }, + quirks: { + // DeepSeek's Anthropic-compatible endpoint + // (https://api.deepseek.com/anthropic/v1/messages) accepts ONLY the + // built-in web_search_* tools and rejects client-defined `custom` tools + // (MCP / Read / Bash / etc.) with HTTP 400 + // "tools[0]: unknown variant `custom`, expected + // `web_search_20250305` or `web_search_20260209`". + // + // Declaring this whitelist makes prepareClaudeRequest() forward only + // web_search_* tools and strip everything else before sending, so MCP / + // function tools are dropped instead of failing the whole request. + // DeepSeek's OpenAI-compatible transport is unaffected (targetFormat + // there is "openai", not "claude", so prepareClaudeRequest is not run). + claudeSupportedToolTypes: ["web_search_20250305", "web_search_20260209"], + }, }, // Multi-endpoint: pick the transport matching client sourceFormat to skip translation. transports: [ diff --git a/open-sse/translator/formats/claude.js b/open-sse/translator/formats/claude.js index 62a0531a..930c761a 100644 --- a/open-sse/translator/formats/claude.js +++ b/open-sse/translator/formats/claude.js @@ -461,8 +461,21 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne // Strip built-in tools (e.g. web_search_20250305) and normalize to Anthropic-native shape // (drop `type` field, fold `function.{name,description,parameters}`) for non-Anthropic providers if (provider !== "claude") { + // Provider-specific whitelist of Anthropic tool `type` values that the + // upstream actually accepts. When the provider declares it + // (e.g. DeepSeek — only web_search_*), keep only listed types; otherwise + // keep the prior behaviour of dropping every non-function tool, which is + // correct for OpenAI-compatible targets reached through this Claude-format + // pass (their tools get normalized below to function-style). + const supportedTypes = PROVIDERS[provider]?.quirks?.claudeSupportedToolTypes; + const hasWhitelist = Array.isArray(supportedTypes); body.tools = body.tools - .filter(tool => !tool.type || tool.type === "function") + .filter(tool => { + const t = tool?.type; + if (!t || t === "function") return true; + if (hasWhitelist) return supportedTypes.includes(t); + return false; + }) .map(tool => { if (tool.function) { return { @@ -471,6 +484,13 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne input_schema: tool.function.parameters, }; } + // When the provider declared a supportedToolTypes whitelist, keep + // the surviving tools' `type` field intact — the upstream + // Anthropic-compatible endpoint (e.g. DeepSeek) requires it to + // route built-ins like web_search_* correctly. Without a + // whitelist, preserve prior behaviour and strip `type` so the + // tool is normalized to plain Anthropic shape. + if (hasWhitelist) return tool; const { type, ...rest } = tool; return rest; }); diff --git a/tests/unit/deepseek-claude-tools.test.js b/tests/unit/deepseek-claude-tools.test.js new file mode 100644 index 00000000..bd05e208 --- /dev/null +++ b/tests/unit/deepseek-claude-tools.test.js @@ -0,0 +1,145 @@ +/** + * Regression test: prepareClaudeRequest() must strip client-defined `custom` + * tools when forwarding to a provider whose Anthropic-compatible endpoint + * does not accept them (DeepSeek — accepts only web_search_*). + * + * Background: + * When Claude Code talks to a DeepSeek route via /v1/messages, 9router + * forwards the request body as Claude-format to + * https://api.deepseek.com/anthropic/v1/messages. MCP / function tools + * arrive with `type: "custom"`. DeepSeek rejects them with HTTP 400 + * "tools[0]: unknown variant `custom`, expected `web_search_20250305` + * or `web_search_20260209`". The previous generic filter dropped them + * but also stripped the web_search_* tools that DeepSeek actually + * accepts. DeepSeek now exposes a `quirks.claudeSupportedToolTypes` + * whitelist and prepareClaudeRequest honours it. + */ + +import { describe, it, expect } from "vitest"; +import { prepareClaudeRequest } from "../../open-sse/translator/formats/claude.js"; +import { PROVIDERS } from "../../open-sse/providers/index.js"; + +function makeBody(tools) { + return { + model: "deepseek-v4-pro", + max_tokens: 1024, + messages: [{ role: "user", content: "hello" }], + tools, + }; +} + +describe("prepareClaudeRequest — provider: deepseek", () => { + it("declares the supportedTypes quirk on the provider transport", () => { + expect(PROVIDERS.deepseek).toBeDefined(); + expect(PROVIDERS.deepseek.quirks).toBeDefined(); + expect(PROVIDERS.deepseek.quirks.claudeSupportedToolTypes).toEqual([ + "web_search_20250305", + "web_search_20260209", + ]); + }); + + it("strips MCP / custom tools (the regression) before forwarding", () => { + const body = makeBody([ + { type: "custom", name: "Bash", input_schema: { type: "object" } }, + { type: "custom", name: "Read", input_schema: { type: "object" } }, + { type: "custom", name: "Glob", input_schema: { type: "object" } }, + ]); + + const out = prepareClaudeRequest(body, "deepseek"); + + expect(out.tools).toBeUndefined(); + expect(out.tool_choice).toBeUndefined(); + }); + + it("keeps web_search_20250305 and web_search_20260209", () => { + const out = prepareClaudeRequest( + makeBody([ + { type: "web_search_20250305", name: "web_search" }, + { type: "web_search_20260209", name: "web_search" }, + ]), + "deepseek" + ); + + expect(Array.isArray(out.tools)).toBe(true); + expect(out.tools).toHaveLength(2); + const types = out.tools.map(t => t.type).sort(); + expect(types).toEqual(["web_search_20250305", "web_search_20260209"]); + }); + + it("preserves the `type` field on web_search_* tools (DeepSeek requires it)", () => { + // The .map below the filter must NOT strip `type` when the provider + // declared a whitelist — DeepSeek would reject a tool object missing + // its discriminator field with the same unknown-variant error. + const out = prepareClaudeRequest( + makeBody([{ type: "web_search_20250305", name: "web_search" }]), + "deepseek" + ); + + expect(out.tools[0].type).toBe("web_search_20250305"); + expect(out.tools[0].name).toBe("web_search"); + }); + + it("drops `custom` but keeps `web_search_*` when both are present", () => { + const out = prepareClaudeRequest( + makeBody([ + { type: "custom", name: "Bash", input_schema: { type: "object" } }, + { type: "web_search_20250305", name: "web_search" }, + ]), + "deepseek" + ); + + expect(Array.isArray(out.tools)).toBe(true); + expect(out.tools).toHaveLength(1); + expect(out.tools[0].type).toBe("web_search_20250305"); + expect(out.tools[0].name).toBe("web_search"); + }); + + it("survives when body has no tools", () => { + const out = prepareClaudeRequest(makeBody(undefined), "deepseek"); + expect(out.tools).toBeUndefined(); + }); + + it("rejects future / unknown tool types instead of forwarding them", () => { + const out = prepareClaudeRequest( + makeBody([{ type: "future_tool_2099", name: "x" }]), + "deepseek" + ); + expect(out.tools).toBeUndefined(); + }); +}); + +describe("prepareClaudeRequest — backward compat: providers without the quirk", () => { + // Pick any non-Claude provider that has a Claude-format transport and has + // NOT been migrated to the new quirk. This protects GLM / Kimi / future + // Anthropic-compatible providers from unintended changes. + it("keeps prior behaviour (drop custom + web_search_*, normalize no-type tools)", () => { + const candidate = Object.entries(PROVIDERS).find( + ([id, p]) => + id !== "claude" && + p?.transports?.some(t => t.format === "claude") && + !p?.quirks?.claudeSupportedToolTypes + ); + + if (!candidate) { + // Every Claude-format provider has been migrated — nothing to verify. + return; + } + + const [providerId] = candidate; + + const out = prepareClaudeRequest( + makeBody([ + { type: "custom", name: "Bash", input_schema: { type: "object" } }, + { type: "web_search_20250305", name: "web_search" }, + { name: "no_type_tool", input_schema: { type: "object" } }, + ]), + providerId + ); + + if (out.tools !== undefined) { + for (const t of out.tools) { + expect(t.type).toBeUndefined(); + } + } + }); +}); \ No newline at end of file