fix(translator): normalize tools to Anthropic-native shape for non-Anthropic providers

Strip `type` field and fold `function.{name,description,parameters}` into
top-level {name, description, input_schema} before forwarding to Claude-format
endpoints. MiniMax (and other Anthropic-compatible providers) reject tools
carrying a `type` field with error code 2013 ("invalid tool type").

Co-authored-by: decolua <decoluadt@example.com>
Inspired-by: https://github.com/decolua/9router/commit/45240c19
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-22 12:21:07 -03:00
parent 2a90a9e589
commit 1b530a8219
2 changed files with 146 additions and 0 deletions

View File

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

View File

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