mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 02:02:13 +03:00
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:
@@ -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());
|
||||
|
||||
119
tests/unit/translator-tools-anthropic-shape.test.ts
Normal file
119
tests/unit/translator-tools-anthropic-shape.test.ts
Normal 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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user