fix(deepseek): keep Anthropic-only tool types when forwarding to /anthropic/v1/messages

DeepSeek's Anthropic-compatible endpoint accepts only the built-in
web_search_20250305 / web_search_20260209 tools and rejects client-defined
`custom` tools (MCP / Read / Bash) with HTTP 400 "unknown variant `custom`".
The generic non-Claude filter in prepareClaudeRequest dropped the offending
tools but also dropped the web_search_* ones DeepSeek does accept.

- Add an opt-in per-provider transport quirk `claudeSupportedToolTypes`; when
  declared it becomes a strict allow-list for Anthropic tool `type` values
- Stop stripping the `type` discriminator from surviving tools under that
  quirk, since DeepSeek needs it to route built-ins
- Declare the quirk on the deepseek transport with the two web_search_* types
- Providers without the quirk keep the previous filter and normalisation
  behaviour byte-for-byte; openai-format targets never reach this path
This commit is contained in:
kimono381
2026-09-10 22:26:15 +07:00
committed by decolua
parent 781c18d837
commit 45ec1d30bb
3 changed files with 181 additions and 1 deletions

View File

@@ -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: [

View File

@@ -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;
});

View File

@@ -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();
}
}
});
});