mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
fix(sse): strip type:'custom' from Claude tools on agentrouter dispatch (#12126)
Validado em worktree combinada com typecheck limpo, testes focados verdes, gates de file-size/complexity/cognitive-complexity/cycles OK. Obrigado!
This commit is contained in:
@@ -131,7 +131,7 @@ import { resolveChatCoreRequestFormat } from "./chatCore/requestFormat.ts";
|
||||
import { resolveChatCoreTargetFormat } from "./chatCore/targetFormat.ts";
|
||||
import { resolveOmniGlyphTransport } from "../services/compression/imageTransportPolicy.ts";
|
||||
import { stripStore, usesClaudeBridge } from "./chatCore/agentRouterProtocol.ts";
|
||||
import { defaultClaudeToolType } from "./chatCore/claudeToolDefaults.ts";
|
||||
import { normalizeClaudeToolsForDispatch } from "./chatCore/claudeToolDefaults.ts";
|
||||
import { injectSystemPrompt, injectCustomSystemPrompt } from "../services/systemPrompt.ts";
|
||||
import { translateRequest, needsTranslation } from "../translator/index.ts";
|
||||
import { FORMATS } from "../translator/formats.ts";
|
||||
@@ -2615,9 +2615,13 @@ export async function handleChatCore({
|
||||
// definitions that omit the required `type` discriminator with HTTP 400. Default
|
||||
// a missing `type` to "custom" before dispatch, mirroring Anthropic's own
|
||||
// inference, so legacy Claude-format tool payloads survive strict gateways (#2195).
|
||||
// AgentRouter is the opposite quirk: its Rust deserializer only accepts versioned
|
||||
// tool types and 400s on `type: "custom"` — there the discriminator is stripped
|
||||
// instead (see claudeToolDefaults.ts).
|
||||
if (targetFormat === FORMATS.CLAUDE && Array.isArray(translatedBody.tools)) {
|
||||
translatedBody.tools = defaultClaudeToolType(
|
||||
translatedBody.tools
|
||||
translatedBody.tools = normalizeClaudeToolsForDispatch(
|
||||
translatedBody.tools,
|
||||
provider
|
||||
) as typeof translatedBody.tools;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,3 +25,41 @@ export function defaultClaudeToolType(tools: unknown): unknown {
|
||||
return tool;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the `type: "custom"` discriminator from Claude-format tools, leaving every
|
||||
* other field (name, description, input_schema, …) untouched. AgentRouter's upstream
|
||||
* (New-API, Rust serde) only accepts versioned tool types (`web_search_20250305`,
|
||||
* `web_search_20260209`); plain tools must omit `type` entirely, so `type: "custom"` —
|
||||
* whether client-declared (Claude Code v2.1+) or backfilled by defaultClaudeToolType()
|
||||
* (#2195) — is a hard 400 "unknown variant `custom`" that crashes the client session.
|
||||
* Versioned/built-in types are preserved; typeless entries stay typeless. Non-object
|
||||
* entries pass through untouched (same rationale as defaultClaudeToolType).
|
||||
*/
|
||||
export function stripClaudeCustomToolType(tools: unknown): unknown {
|
||||
if (!Array.isArray(tools)) return tools;
|
||||
return tools.map((tool) => {
|
||||
if (
|
||||
tool &&
|
||||
typeof tool === "object" &&
|
||||
!Array.isArray(tool) &&
|
||||
(tool as UnknownRecord).type === "custom"
|
||||
) {
|
||||
const { type: _stripped, ...rest } = tool as UnknownRecord;
|
||||
return rest;
|
||||
}
|
||||
return tool;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-provider dispatch decision for Claude-format tool normalization. AgentRouter
|
||||
* rejects `type: "custom"` (see stripClaudeCustomToolType) while strict gateways like
|
||||
* MiniMax REQUIRE the explicit discriminator (#2195) — the two quirks are mutually
|
||||
* exclusive, so the normalization is provider-scoped, never global.
|
||||
*/
|
||||
export function normalizeClaudeToolsForDispatch(tools: unknown, provider: string): unknown {
|
||||
return provider === "agentrouter"
|
||||
? stripClaudeCustomToolType(tools)
|
||||
: defaultClaudeToolType(tools);
|
||||
}
|
||||
|
||||
107
tests/unit/agentrouter-custom-tool-type.test.ts
Normal file
107
tests/unit/agentrouter-custom-tool-type.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// AgentRouter's upstream (New-API, Rust serde) only accepts versioned Claude tool
|
||||
// types (web_search_20250305 / web_search_20260209); plain tools must omit `type`
|
||||
// entirely. A tool carrying `type: "custom"` — whether client-declared (Claude
|
||||
// Code v2.1+) or backfilled by defaultClaudeToolType() (#2195, MiniMax) — is a
|
||||
// hard 400 "unknown variant `custom`" that crashes the client session.
|
||||
// normalizeClaudeToolsForDispatch() routes per provider: agentrouter strips the
|
||||
// custom discriminator, every other Claude-format target keeps the #2195 default.
|
||||
|
||||
const { normalizeClaudeToolsForDispatch } = await import(
|
||||
"../../open-sse/handlers/chatCore/claudeToolDefaults.ts"
|
||||
);
|
||||
|
||||
test("agentrouter: strips an explicit type:'custom' discriminator, preserving all other fields", () => {
|
||||
const tools = [
|
||||
{
|
||||
type: "custom",
|
||||
name: "get_weather",
|
||||
description: "Get weather",
|
||||
input_schema: { type: "object", properties: {} },
|
||||
},
|
||||
];
|
||||
const out = normalizeClaudeToolsForDispatch(tools, "agentrouter") as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
assert.equal(out[0].type, undefined, "type:'custom' must be removed");
|
||||
assert.equal(out[0].name, "get_weather");
|
||||
assert.equal(out[0].description, "Get weather");
|
||||
assert.deepEqual(out[0].input_schema, { type: "object", properties: {} });
|
||||
});
|
||||
|
||||
test("agentrouter: does NOT default a missing type (typeless tools stay typeless)", () => {
|
||||
const tools = [{ name: "get_weather", description: "Get weather", input_schema: {} }];
|
||||
const out = normalizeClaudeToolsForDispatch(tools, "agentrouter") as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
assert.equal(out[0].type, undefined, "no type:'custom' may be backfilled for agentrouter");
|
||||
});
|
||||
|
||||
test("agentrouter: preserves versioned/built-in tool types (only 'custom' is stripped)", () => {
|
||||
const tools = [
|
||||
{ type: "web_search_20260209", name: "web_search" },
|
||||
{ type: "computer_20241022", name: "computer" },
|
||||
{ type: "custom", name: "plain" },
|
||||
{ name: "typeless" },
|
||||
];
|
||||
const out = normalizeClaudeToolsForDispatch(tools, "agentrouter") as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
assert.equal(out[0].type, "web_search_20260209");
|
||||
assert.equal(out[1].type, "computer_20241022");
|
||||
assert.equal(out[2].type, undefined, "custom is stripped");
|
||||
assert.equal(out[3].type, undefined, "typeless stays typeless");
|
||||
});
|
||||
|
||||
test("non-agentrouter providers keep the #2195 behavior: missing type defaults to 'custom'", () => {
|
||||
const tools = [{ name: "get_weather", input_schema: {} }];
|
||||
for (const provider of ["minimax", "anthropic", "some-gateway"]) {
|
||||
const out = normalizeClaudeToolsForDispatch(tools, provider) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
assert.equal(out[0].type, "custom", `${provider} must keep the MiniMax #2195 default`);
|
||||
}
|
||||
});
|
||||
|
||||
test("non-agentrouter providers leave an explicit type:'custom' untouched", () => {
|
||||
const tools = [{ type: "custom", name: "a", input_schema: {} }];
|
||||
const out = normalizeClaudeToolsForDispatch(tools, "minimax") as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
assert.equal(out[0].type, "custom");
|
||||
});
|
||||
|
||||
test("returns non-array input unchanged for any provider", () => {
|
||||
assert.equal(normalizeClaudeToolsForDispatch(undefined, "agentrouter"), undefined);
|
||||
assert.equal(normalizeClaudeToolsForDispatch(null, "agentrouter"), null);
|
||||
const obj = { not: "an array" };
|
||||
assert.equal(normalizeClaudeToolsForDispatch(obj, "agentrouter"), obj);
|
||||
assert.equal(normalizeClaudeToolsForDispatch(obj, "minimax"), obj);
|
||||
});
|
||||
|
||||
test("does not mutate the original tool objects", () => {
|
||||
const explicit = { type: "custom", name: "x", input_schema: {} };
|
||||
const typeless = { name: "y", input_schema: {} };
|
||||
const out = normalizeClaudeToolsForDispatch([explicit, typeless], "agentrouter") as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
assert.equal(explicit.type, "custom", "original explicit tool must stay untouched");
|
||||
assert.equal(typeless.type, undefined, "original typeless tool must stay untouched");
|
||||
assert.equal(out[0].type, undefined);
|
||||
});
|
||||
|
||||
test("passes non-object array entries through unchanged (no garbage wrapping)", () => {
|
||||
const tools = [
|
||||
{ type: "custom", name: "real_tool", input_schema: {} }, // object → stripped
|
||||
null,
|
||||
"weird",
|
||||
42,
|
||||
];
|
||||
const out = normalizeClaudeToolsForDispatch(tools, "agentrouter") as unknown[];
|
||||
assert.equal((out[0] as Record<string, unknown>).type, undefined, "real object gets stripped");
|
||||
assert.equal(out[1], null, "null passes through unchanged");
|
||||
assert.equal(out[2], "weird", "string passes through unchanged");
|
||||
assert.equal(out[3], 42, "number passes through unchanged");
|
||||
});
|
||||
Reference in New Issue
Block a user