diff --git a/changelog.d/fixes/13552-claude-tool-schema-root-unions.md b/changelog.d/fixes/13552-claude-tool-schema-root-unions.md new file mode 100644 index 0000000000..02c1037371 --- /dev/null +++ b/changelog.d/fixes/13552-claude-tool-schema-root-unions.md @@ -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)) diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index 652faed634..117fa99876 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -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 = { name, diff --git a/open-sse/translator/helpers/schemaCoercion.ts b/open-sse/translator/helpers/schemaCoercion.ts index 08b2cbe7db..60f7cc9a3a 100644 --- a/open-sse/translator/helpers/schemaCoercion.ts +++ b/open-sse/translator/helpers/schemaCoercion.ts @@ -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, 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(); + 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 { diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index dd5d13f5c5..58467eb2d0 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -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 = 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, diff --git a/tests/unit/claude-tool-schema-root-unions.test.ts b/tests/unit/claude-tool-schema-root-unions.test.ts new file mode 100644 index 0000000000..d072de8790 --- /dev/null +++ b/tests/unit/claude-tool-schema-root-unions.test.ts @@ -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; + +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"]); + }); +});