fix(translator): flatten root-level anyOf/oneOf/allOf in Claude tool schemas (#13561)

Anthropic's Messages API rejects a tool whose `input_schema` carries a
composition keyword at the root with:

  tools.N.custom.input_schema: input_schema does not support oneOf,
  allOf, or anyOf at the top level

The refusal happens before inference, so a single MCP/agent tool carrying
a root-level union fails every request that ships the catalog, and combo
failover cannot recover from it.

Both conversion paths that build a Claude `input_schema` from a client
payload now flatten such a root union into a plain object schema:
object-compatible branches contribute their `properties` (root wins on a
name collision), the root is pinned to `type: "object"`, and only `allOf`
contributes `required` — `anyOf`/`oneOf` branch requirements are
alternatives and promoting them would refuse calls the original schema
accepts. Nested unions are untouched and clean schemas pass through
unchanged.

Closes #13552
This commit is contained in:
sprintberlin
2026-09-18 16:31:43 +02:00
committed by GitHub
parent d86662d582
commit 2a89a3bba7
5 changed files with 430 additions and 4 deletions

View File

@@ -0,0 +1 @@
- **fix(translator):** Claude tool `input_schema` with a root-level `anyOf` / `oneOf` / `allOf` is flattened into a plain object schema instead of being forwarded verbatim. Anthropic refuses such a tool before inference (`tools.N.custom.input_schema: input_schema does not support oneOf, allOf, or anyOf at the top level`), so a single MCP/agent tool carrying one made every request fail with no combo failover possible ([#13552](https://github.com/diegosouzapw/OmniRoute/issues/13552))

View File

@@ -9,6 +9,7 @@ import {
} from "../config/claudeCodeCompatibleIdentity.ts";
import { supportsClaudeMaxEffort, supportsXHighEffort } from "../config/providerModels.ts";
import { prepareClaudeRequest } from "../translator/helpers/claudeHelper.ts";
import { normalizeClaudeToolInputSchema } from "../translator/helpers/schemaCoercion.ts";
import { signRequestBody } from "./claudeCodeCCH.ts";
import { resolveClaudeCodeCompatibleAnthropicBeta } from "./claudeCodeCompatibleBeta.ts";
import { remapToolNamesInRequest } from "./claudeCodeToolRemapper.ts";
@@ -757,10 +758,13 @@ function convertClaudeCodeCompatibleTool(tool: unknown) {
const rawSchema = readRecord(toolData.parameters) ||
readRecord(toolData.input_schema) || { type: "object", properties: {}, required: [] };
const inputSchema =
const withProperties =
rawSchema.type === "object" && !readRecord(rawSchema.properties)
? { ...rawSchema, properties: {} }
: rawSchema;
// Flatten a root-level anyOf/oneOf/allOf: Anthropic refuses it outright with
// "input_schema does not support oneOf, allOf, or anyOf at the top level" (#13552).
const inputSchema = normalizeClaudeToolInputSchema(withProperties);
const converted: Record<string, unknown> = {
name,

View File

@@ -629,13 +629,111 @@ export function stripInvalidSchemaConstructs(schema: unknown): unknown {
return result;
}
/**
* JSON Schema composition keywords Anthropic refuses at the *root* of a tool
* `input_schema`. Nested occurrences (inside `properties`, `items`, `$defs`, …)
* are valid and must be preserved.
*/
const CLAUDE_ROOT_UNION_KEYWORDS = ["anyOf", "oneOf", "allOf"] as const;
/** Whether a union branch may contribute object properties to the flattened root. */
function claudeUnionBranchCanBeObject(branch: JsonRecord): boolean {
const type = branch.type;
if (type === undefined) return true;
if (typeof type === "string") return type === "object";
if (Array.isArray(type)) return type.includes("object");
return false;
}
/** Append the string entries of `branchRequired` that are not recorded yet. */
function mergeClaudeRequired(target: string[], seen: Set<string>, branchRequired: unknown): void {
if (!Array.isArray(branchRequired)) return;
for (const name of branchRequired) {
if (typeof name !== "string" || seen.has(name)) continue;
seen.add(name);
target.push(name);
}
}
/**
* Whether a tool schema carries a root-level `anyOf` / `oneOf` / `allOf`.
*
* @param schema - Candidate tool `input_schema` / `parameters` value.
* @returns `true` when Anthropic would reject the schema's root shape.
*/
export function hasRootLevelSchemaUnion(schema: unknown): boolean {
if (!isPlainObject(schema)) return false;
return CLAUDE_ROOT_UNION_KEYWORDS.some((keyword) => hasOwn(schema, keyword));
}
/**
* Flatten a root-level `anyOf` / `oneOf` / `allOf` into a plain object schema.
*
* Anthropic's Messages API rejects a tool whose `input_schema` root carries a
* composition keyword with
* `tools.N.custom.input_schema: input_schema does not support oneOf, allOf, or
* anyOf at the top level` (#13552). The request is refused *before* inference,
* so a single such tool from an MCP/agent client fails every request that
* carries the catalog — combo failover cannot recover from it either.
*
* The flattening mirrors the union handling CLIProxyAPI applies on the same
* wire hop: object-compatible branches contribute their `properties` (first
* branch wins on a name collision), the root is pinned to `type: "object"`, and
* only `allOf` — whose branches all apply at once — contributes `required`.
* `anyOf` / `oneOf` branch requirements are alternatives, so promoting them
* would refuse calls the original schema accepts.
*
* Schemas without a root union are returned untouched, and nested unions are
* never rewritten.
*
* @param schema - Tool `input_schema` as received from the client.
* @returns An Anthropic-compatible schema, or the input when nothing to do.
*/
export function normalizeClaudeToolInputSchema(schema: unknown): unknown {
if (!hasRootLevelSchemaUnion(schema)) return schema;
const source = schema as JsonRecord;
const result: JsonRecord = { ...source };
const properties: JsonRecord = isPlainObject(source.properties) ? { ...source.properties } : {};
const required: string[] = [];
const requiredSeen = new Set<string>();
mergeClaudeRequired(required, requiredSeen, source.required);
for (const keyword of CLAUDE_ROOT_UNION_KEYWORDS) {
if (!hasOwn(result, keyword)) continue;
const branches = result[keyword];
// Dropped even when malformed: the keyword itself is what Anthropic refuses.
delete result[keyword];
if (!Array.isArray(branches)) continue;
for (const branch of branches) {
if (!isPlainObject(branch) || !claudeUnionBranchCanBeObject(branch)) continue;
if (isPlainObject(branch.properties)) {
for (const [name, propertySchema] of Object.entries(branch.properties)) {
if (!hasOwn(properties, name)) properties[name] = propertySchema;
}
}
if (keyword === "allOf") mergeClaudeRequired(required, requiredSeen, branch.required);
}
}
result.type = "object";
result.properties = properties;
if (required.length > 0) result.required = required;
return result;
}
export function sanitizeClaudeToolSchema(schema: unknown): unknown {
// stripInvalidSchemaConstructs now also coerces numeric-string constraints, so
// it is the single pass for the Claude path. We deliberately do NOT compose
// coerceSchemaNumericFields: it strips the valid `default` keyword (Fix #1782,
// a translator concern) which on the native / passthrough surface would
// silently alter tool schemas that were previously forwarded verbatim.
return stripInvalidSchemaConstructs(schema);
//
// The root-union flattening runs last so it sees the already-repaired shape
// (e.g. an index-keyed `anyOf` object coerced back into an array).
return normalizeClaudeToolInputSchema(stripInvalidSchemaConstructs(schema));
}
export function sanitizeClaudeToolSchemas(tools: unknown): unknown {

View File

@@ -3,7 +3,7 @@ import { FORMATS } from "../formats.ts";
// CLAUDE_SYSTEM_PROMPT import removed — no longer injected unconditionally (#1966/#2130)
import { supportsClaudeMaxEffort, supportsXHighEffort } from "../../config/providerModels.ts";
import { adjustMaxTokens } from "../helpers/maxTokensHelper.ts";
import { sanitizeToolId } from "../helpers/schemaCoercion.ts";
import { normalizeClaudeToolInputSchema, sanitizeToolId } from "../helpers/schemaCoercion.ts";
import { safeParseJSON } from "../helpers/jsonUtil.ts";
import {
applyKimiCodingThinking,
@@ -437,10 +437,13 @@ export function openaiToClaudeRequest(model, body, stream, credentials = null) {
// MCP tools (e.g. pencil, computer_use) may omit properties on object-type schemas.
const rawSchema: Record<string, unknown> = toolData.parameters ||
toolData.input_schema || { type: "object", properties: {}, required: [] };
const normalizedSchema =
const withProperties =
rawSchema.type === "object" && !rawSchema.properties
? { ...rawSchema, properties: {} }
: rawSchema;
// Flatten a root-level anyOf/oneOf/allOf: Anthropic refuses it outright with
// "input_schema does not support oneOf, allOf, or anyOf at the top level" (#13552).
const normalizedSchema = normalizeClaudeToolInputSchema(withProperties);
return {
name: toolName,

View File

@@ -0,0 +1,320 @@
/**
* Root-level `anyOf` / `oneOf` / `allOf` in a Claude tool `input_schema` (#13552).
*
* Anthropic's Messages API refuses such a tool before inference with
* `tools.N.custom.input_schema: input_schema does not support oneOf, allOf, or
* anyOf at the top level`, so one offending tool in a client's catalog fails
* every request that carries it — combo failover cannot recover from a request
* that never reaches a model.
*
* These tests pin the flattening itself plus both conversion paths that build a
* Claude `input_schema` from a client payload: the OpenAI→Claude translator and
* the Claude-Code-compatible bridge.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
hasRootLevelSchemaUnion,
normalizeClaudeToolInputSchema,
sanitizeClaudeToolSchema,
} from "../../open-sse/translator/helpers/schemaCoercion.ts";
import { openaiToClaudeRequest } from "../../open-sse/translator/request/openai-to-claude.ts";
import { buildClaudeCodeCompatibleRequest } from "../../open-sse/services/claudeCodeCompatible.ts";
type AnyRecord = Record<string, unknown>;
const ROOT_UNION_KEYWORDS = ["anyOf", "oneOf", "allOf"] as const;
const assertNoRootUnion = (schema: unknown, label: string): void => {
for (const keyword of ROOT_UNION_KEYWORDS) {
assert.equal(
Object.prototype.hasOwnProperty.call(schema as AnyRecord, keyword),
false,
`${label} still carries a root-level ${keyword}`
);
}
assert.equal((schema as AnyRecord).type, "object", `${label} root type is not "object"`);
};
describe("hasRootLevelSchemaUnion", () => {
it("detects each composition keyword at the root", () => {
for (const keyword of ROOT_UNION_KEYWORDS) {
assert.equal(hasRootLevelSchemaUnion({ type: "object", [keyword]: [] }), true, keyword);
}
});
it("ignores a union nested inside a property", () => {
const schema = {
type: "object",
properties: { pin: { anyOf: [{ type: "boolean" }, { type: "object" }] } },
};
assert.equal(hasRootLevelSchemaUnion(schema), false);
});
it("ignores non-object schemas", () => {
assert.equal(hasRootLevelSchemaUnion(null), false);
assert.equal(hasRootLevelSchemaUnion("[MaxDepth]"), false);
assert.equal(hasRootLevelSchemaUnion([{ anyOf: [] }]), false);
});
});
describe("normalizeClaudeToolInputSchema", () => {
it("flattens a root anyOf that carries the whole schema", () => {
const result = normalizeClaudeToolInputSchema({
anyOf: [
{ type: "object", properties: { a: { type: "string" } } },
{ type: "object", properties: { b: { type: "integer" } } },
],
}) as AnyRecord;
assertNoRootUnion(result, "flattened anyOf");
assert.deepEqual(result.properties, {
a: { type: "string" },
b: { type: "integer" },
});
});
it("keeps a nested union while removing the root one", () => {
const result = normalizeClaudeToolInputSchema({
type: "object",
properties: { nested: { oneOf: [{ type: "string" }, { type: "number" }] } },
oneOf: [
{ properties: { a: { type: "string" } }, required: ["a"] },
{ properties: { b: { type: "string" } }, required: ["b"] },
],
}) as AnyRecord;
assertNoRootUnion(result, "flattened oneOf");
const properties = result.properties as AnyRecord;
assert.deepEqual(properties.nested, { oneOf: [{ type: "string" }, { type: "number" }] });
assert.deepEqual(properties.a, { type: "string" });
assert.deepEqual(properties.b, { type: "string" });
});
it("does not promote alternative branch requirements of anyOf/oneOf", () => {
// `a` and `b` are alternatives: requiring both would refuse calls the
// original schema accepts.
const result = normalizeClaudeToolInputSchema({
type: "object",
properties: { a: { type: "string" }, b: { type: "string" } },
anyOf: [{ required: ["a"] }, { required: ["b"] }],
}) as AnyRecord;
assertNoRootUnion(result, "anyOf requirements");
assert.equal("required" in result, false);
});
it("merges properties and requirements of a root allOf", () => {
// Every allOf branch applies at once, so its requirements are cumulative.
const result = normalizeClaudeToolInputSchema({
type: "object",
properties: { base: { type: "boolean" } },
required: ["base"],
allOf: [
{ type: "object", properties: { a: { type: "string" } }, required: ["a"] },
{ properties: { b: { type: "integer" } }, required: ["a", "b"] },
],
}) as AnyRecord;
assertNoRootUnion(result, "flattened allOf");
assert.deepEqual(Object.keys(result.properties as AnyRecord).sort(), ["a", "b", "base"]);
assert.deepEqual(result.required, ["base", "a", "b"]);
});
it("keeps the root object's own property when a branch redeclares it", () => {
const result = normalizeClaudeToolInputSchema({
type: "object",
properties: { shared: { type: "string", description: "root wins" } },
allOf: [{ properties: { shared: { type: "integer" } } }],
}) as AnyRecord;
assert.deepEqual((result.properties as AnyRecord).shared, {
type: "string",
description: "root wins",
});
});
it("skips branches that cannot contribute object properties", () => {
const result = normalizeClaudeToolInputSchema({
type: "object",
properties: { keep: { type: "string" } },
anyOf: [
{ type: "string" },
{ type: "null" },
{ type: ["object", "null"], properties: { fromUnionType: { type: "boolean" } } },
],
}) as AnyRecord;
assertNoRootUnion(result, "mixed branches");
assert.deepEqual(Object.keys(result.properties as AnyRecord).sort(), ["fromUnionType", "keep"]);
});
it("drops a malformed root union instead of forwarding it", () => {
const result = normalizeClaudeToolInputSchema({
type: "object",
properties: { a: { type: "string" } },
allOf: "not-an-array",
}) as AnyRecord;
assertNoRootUnion(result, "malformed allOf");
assert.deepEqual(result.properties, { a: { type: "string" } });
});
it("preserves unrelated keywords and leaves a clean schema untouched", () => {
const clean = {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
additionalProperties: false,
};
assert.equal(normalizeClaudeToolInputSchema(clean), clean);
const result = normalizeClaudeToolInputSchema({
title: "Search",
additionalProperties: false,
$defs: { Ref: { type: "string" } },
oneOf: [{ type: "object", properties: { q: { type: "string" } } }],
}) as AnyRecord;
assertNoRootUnion(result, "keyword preservation");
assert.equal(result.title, "Search");
assert.equal(result.additionalProperties, false);
assert.deepEqual(result.$defs, { Ref: { type: "string" } });
});
it("does not mutate the schema it was given", () => {
const input = {
type: "object",
properties: { a: { type: "string" } },
anyOf: [{ type: "object", properties: { b: { type: "string" } } }],
};
const snapshot = structuredClone(input);
normalizeClaudeToolInputSchema(input);
assert.deepEqual(input, snapshot);
});
});
describe("sanitizeClaudeToolSchema (native OAuth / passthrough surface)", () => {
it("flattens a root union after repairing invalid constructs", () => {
const result = sanitizeClaudeToolSchema({
type: "object",
properties: { a: { type: "string", enum: { "0": "x", "1": "y" } } },
anyOf: [{ type: "object", properties: { b: { type: "string" } } }],
}) as AnyRecord;
assertNoRootUnion(result, "sanitized schema");
const properties = result.properties as AnyRecord;
assert.deepEqual((properties.a as AnyRecord).enum, ["x", "y"]);
assert.deepEqual(properties.b, { type: "string" });
});
it("flattens an index-keyed root union object", () => {
// stripInvalidSchemaConstructs coerces the index-keyed object back into an
// array first, so the flattening still sees real branches.
const result = sanitizeClaudeToolSchema({
type: "object",
oneOf: { "0": { type: "object", properties: { a: { type: "string" } } } },
}) as AnyRecord;
assertNoRootUnion(result, "index-keyed oneOf");
assert.deepEqual(result.properties, { a: { type: "string" } });
});
});
describe("openaiToClaudeRequest — tool input_schema", () => {
const toolWithRootUnion = {
type: "function",
function: {
name: "kolonie_operator_agent",
description: "Repro of the reported failure",
parameters: {
type: "object",
properties: { act: { type: "string" } },
required: ["act"],
anyOf: [{ type: "object", properties: { delegationId: { type: "string" } } }],
},
},
};
const baseBody = {
messages: [{ role: "user", content: "Say OK" }],
max_tokens: 32,
};
it("flattens a root union before the request reaches Anthropic", () => {
const result = openaiToClaudeRequest(
"claude-opus-5",
{ ...baseBody, tools: [toolWithRootUnion] },
false
) as AnyRecord;
const tool = (result.tools as AnyRecord[])[0];
const schema = tool.input_schema as AnyRecord;
assertNoRootUnion(schema, "translated tool");
assert.deepEqual(Object.keys(schema.properties as AnyRecord).sort(), ["act", "delegationId"]);
assert.deepEqual(schema.required, ["act"]);
});
it("leaves a nested union and an ordinary schema untouched", () => {
const result = openaiToClaudeRequest(
"claude-opus-5",
{
...baseBody,
tools: [
{
type: "function",
function: {
name: "ordinary",
parameters: {
type: "object",
properties: { pin: { anyOf: [{ type: "boolean" }, { type: "object" }] } },
required: ["pin"],
},
},
},
],
},
false
) as AnyRecord;
const schema = (result.tools as AnyRecord[])[0].input_schema as AnyRecord;
assert.deepEqual(schema.properties, {
pin: { anyOf: [{ type: "boolean" }, { type: "object" }] },
});
assert.deepEqual(schema.required, ["pin"]);
});
});
describe("buildClaudeCodeCompatibleRequest — tool input_schema", () => {
it("flattens a root union on the Claude-Code-compatible bridge", () => {
const body = buildClaudeCodeCompatibleRequest({
model: "claude-opus-5",
normalizedBody: {
model: "claude-opus-5",
messages: [{ role: "user", content: "Say OK" }],
max_tokens: 32,
tools: [
{
type: "function",
function: {
name: "bridge_tool",
parameters: {
type: "object",
properties: { base: { type: "string" } },
required: ["base"],
allOf: [{ properties: { extra: { type: "string" } }, required: ["extra"] }],
},
},
},
],
},
}) as AnyRecord;
const schema = (body.tools as AnyRecord[])[0].input_schema as AnyRecord;
assertNoRootUnion(schema, "bridge tool");
assert.deepEqual(Object.keys(schema.properties as AnyRecord).sort(), ["base", "extra"]);
assert.deepEqual(schema.required, ["base", "extra"]);
});
});