From 4e4522c285e1a2ea6877c46d149f49a2fa5cf22d Mon Sep 17 00:00:00 2001 From: Wahid Sadik Date: Mon, 31 Aug 2026 04:39:50 +0600 Subject: [PATCH] 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! --- open-sse/handlers/chatCore.ts | 10 +- .../handlers/chatCore/claudeToolDefaults.ts | 38 +++++++ .../unit/agentrouter-custom-tool-type.test.ts | 107 ++++++++++++++++++ 3 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 tests/unit/agentrouter-custom-tool-type.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index bbb75cc4da..658b4084da 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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; } diff --git a/open-sse/handlers/chatCore/claudeToolDefaults.ts b/open-sse/handlers/chatCore/claudeToolDefaults.ts index bf758b6a5a..724f5a0f76 100644 --- a/open-sse/handlers/chatCore/claudeToolDefaults.ts +++ b/open-sse/handlers/chatCore/claudeToolDefaults.ts @@ -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); +} diff --git a/tests/unit/agentrouter-custom-tool-type.test.ts b/tests/unit/agentrouter-custom-tool-type.test.ts new file mode 100644 index 0000000000..4a1248f336 --- /dev/null +++ b/tests/unit/agentrouter-custom-tool-type.test.ts @@ -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 + >; + 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 + >; + 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 + >; + 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 + >; + 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 + >; + 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 + >; + 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).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"); +});