diff --git a/open-sse/translator/helpers/claudeHelper.ts b/open-sse/translator/helpers/claudeHelper.ts index 43b1a73132..6d8c8edb81 100644 --- a/open-sse/translator/helpers/claudeHelper.ts +++ b/open-sse/translator/helpers/claudeHelper.ts @@ -302,6 +302,33 @@ export function prepareClaudeRequest( } } + // Tools: for non-Anthropic providers (MiniMax and other Anthropic-compatible + // Claude-shape endpoints) strip Anthropic-only built-in tools (e.g. + // web_search_20250305) and normalize OpenAI-wire-shape tools to the + // Anthropic-native shape — fold `function.{name,description,parameters}` + // into top-level `{name, description, input_schema}` and drop the stray + // `type` field. Without this MiniMax rejects with code 2013 ("invalid + // tool type"). Port of upstream decolua/9router@45240c19. + if (body.tools && Array.isArray(body.tools) && provider !== "claude") { + body.tools = body.tools + .filter((tool) => !tool.type || tool.type === "function") + .map((tool) => { + const t = tool as ClaudeTool & { + function?: { name?: string; description?: string; parameters?: unknown }; + type?: string; + }; + if (t.function) { + return { + name: t.function.name, + description: t.function.description, + input_schema: t.function.parameters, + } as ClaudeTool; + } + const { type: _type, ...rest } = t; + return rest as ClaudeTool; + }); + } + // Also filter top-level tool declarations with empty names if (body.tools && Array.isArray(body.tools)) { body.tools = body.tools.filter((tool) => tool.name && tool.name?.trim()); diff --git a/tests/unit/translator-tools-anthropic-shape.test.ts b/tests/unit/translator-tools-anthropic-shape.test.ts new file mode 100644 index 0000000000..824fe1d1db --- /dev/null +++ b/tests/unit/translator-tools-anthropic-shape.test.ts @@ -0,0 +1,119 @@ +// Port of upstream decolua/9router@45240c19. +// Anthropic-compatible Claude-shape providers (MiniMax and friends) reject +// tools that carry a `type` field with error code 2013 ("invalid tool type"). +// When a client sends Anthropic-format requests but the tools are still in +// OpenAI wire shape — `{ type: "function", function: { name, description, +// parameters } }` — prepareClaudeRequest() must normalize them to the +// Anthropic-native shape `{ name, description, input_schema }` before +// forwarding to non-Anthropic providers. For Anthropic itself ("claude") +// the shape is left untouched so first-party server-side tools like +// web_search_20250305 keep flowing. +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { prepareClaudeRequest } from "../../open-sse/translator/helpers/claudeHelper.ts"; + +describe("prepareClaudeRequest tool-shape normalization for non-Anthropic providers", () => { + const buildBody = () => ({ + model: "MiniMax-M2.7", + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + max_tokens: 256, + tools: [ + // OpenAI wire shape inside an Anthropic-format request. + { + type: "function", + function: { + name: "get_weather", + description: "Get the weather", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }, + }, + // Anthropic-native shape (no `type` field at all) — must pass through unchanged. + { + name: "lookup_user", + description: "Find a user", + input_schema: { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + }, + }, + // Built-in Anthropic server-side tool that non-Anthropic providers + // must NOT receive at all. + { + type: "web_search_20250305", + name: "web_search", + }, + ], + }); + + test("folds function.{name,description,parameters} and drops `type` for minimax", () => { + const body = buildBody(); + const result = prepareClaudeRequest(body as any, "minimax"); + assert.ok(Array.isArray(result.tools)); + // Built-in web_search_20250305 must be stripped for non-Anthropic providers. + assert.equal(result.tools!.length, 2); + + const weather = result.tools!.find((t: any) => t.name === "get_weather") as any; + assert.ok(weather, "get_weather tool must survive normalization"); + assert.equal(weather.type, undefined, "`type` must be stripped"); + assert.equal(weather.function, undefined, "`function` wrapper must be folded"); + assert.equal(weather.description, "Get the weather"); + assert.deepEqual(weather.input_schema, { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }); + + const lookup = result.tools!.find((t: any) => t.name === "lookup_user") as any; + assert.ok(lookup, "lookup_user tool must survive normalization"); + assert.equal(lookup.type, undefined, "stray `type` must be stripped"); + assert.deepEqual(lookup.input_schema, { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + }); + }); + + test("does NOT rewrite tool shape for first-party claude (keeps built-ins)", () => { + const body = { + model: "claude-sonnet-4-5", + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + max_tokens: 256, + tools: [ + // Anthropic-native shape. + { + name: "lookup_user", + description: "Find a user", + input_schema: { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + }, + }, + // Built-in Anthropic server-side tool — MUST be preserved for first-party claude. + { + type: "web_search_20250305", + name: "web_search", + }, + ], + }; + const result = prepareClaudeRequest(body as any, "claude"); + assert.ok(Array.isArray(result.tools)); + // Built-in web_search_20250305 must NOT be stripped for Anthropic-native upstream. + const webSearch = result.tools!.find((t: any) => t.type === "web_search_20250305"); + assert.ok(webSearch, "web_search_20250305 must be preserved for claude"); + assert.equal(webSearch!.type, "web_search_20250305"); + // Anthropic-shape tool is untouched. + const lookup = result.tools!.find((t: any) => t.name === "lookup_user") as any; + assert.ok(lookup); + assert.deepEqual(lookup.input_schema, { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + }); + }); +});