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:
145
tests/unit/deepseek-claude-tools.test.js
Normal file
145
tests/unit/deepseek-claude-tools.test.js
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user