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:
Wahid Sadik
2026-08-31 04:39:50 +06:00
committed by GitHub
parent 9aa7c2459a
commit 4e4522c285
3 changed files with 152 additions and 3 deletions

View File

@@ -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;
}

View File

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

View 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");
});