Repository navigation
fix(translator): flatten root-level anyOf/oneOf/allOf in Claude tool schemas - #13561
Conversation
…schemas 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 cheaper-inference#13552
|
This is a clean, well-scoped fix — ran the full regression suite at your head |
|
Thanks @sprintberlin — merging via the release merge-train. Validated in local merge-train (.claude/worktrees/merge-train-20260918-111718-suite.log) on the devbox @ train tip 7bb373fba5e241964c0ffb17bd03a804700839bb, boarded with 55 sibling PRs: typecheck:core, file-size, complexity, cognitive-complexity, changelog-integrity green; 747/747 changed-area node:test cases + 476/476 vitest green (fast parity mode — the full suite ran today on the release tip via the base-red train and runs again on the 3b train). Merged --admin per merge-gates §7. |
2a89a3b
into
cheaper-inference:release/v3.8.51
…schemas (cheaper-inference#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 cheaper-inference#13552
Summary
Anthropic's Messages API rejects a tool whose
input_schemacarries a JSON Schema composition keyword at the root:The refusal happens before inference, so a single MCP/agent tool carrying a root-level union fails every request that ships that catalog — and combo failover cannot recover, because no model is ever reached. Observed in production against
claude/claude-opus-5with OpenCode and other MCP-driven clients.Both conversion paths that build a Claude
input_schemafrom a client payload now flatten such a root union into a plain object schema instead of forwarding it verbatim:open-sse/translator/request/openai-to-claude.ts— OpenAI-Chat → Claude translatoropen-sse/services/claudeCodeCompatible.ts—convertClaudeCodeCompatibleTool()on theanthropic-compatible-cc-*bridgeThe flattening also composes into
sanitizeClaudeToolSchema(), so the native Claude OAuth / passthrough surface (open-sse/executors/base.ts,open-sse/executors/cliproxyapi.ts) is covered by the same rule.Flattening semantics
properties; the root's own property wins on a name collisiontype: "object"allOfrequirementsrequired— every branch applies at once, so they are cumulativeanyOf/oneOfrequirementsallOf: "x")Prior art: CLIProxyAPI applies the equivalent normalization on the same wire hop (
internal/util/claude_schema.go→NormalizeClaudeToolInputSchema), which is why the same client toolsets succeed there. TheanyOf/oneOfrequirement rule above matches its behavior.Related Issues
Validation
npm run lint(see note below)release/v3.8.51); focused checks rerun afterwardHard Rule #18 — TDD: the new test file reproduces the reported failure shape first (root-level
anyOfon a tool reaching the Claude translator) and then asserts the flattened, Anthropic-accepted result.ESLint note:
npx eslintreports 2 pre-existing errors in the touched files (isDeepSeekReasoningModelunused inschemaCoercion.ts,toolNameMapunused inopenai-to-claude.ts). Both reproduce identically on an unmodifiedrelease/v3.8.51checkout and are untouched by this PR — no new violations were introduced.Tests Added Or Updated
tests/unit/claude-tool-schema-root-unions.test.ts(new, 17 tests) covering:hasRootLevelSchemaUnion— detection per keyword, nested union ignored, non-object inputsnormalizeClaudeToolInputSchema—anyOfcarrying the whole schema; nested union preserved while root removed;anyOf/oneOfalternatives not promoted torequired;allOfproperty + requirement merge; root property wins collisions; non-object branches skipped; malformed union dropped; unrelated keywords (title,additionalProperties,$defs) preserved; clean schema identity; input not mutatedsanitizeClaudeToolSchema— flattening composes after invalid-construct repair, including an index-keyed union objectopenaiToClaudeRequest— root union flattened end-to-end on the translated tool; nested union and ordinary schema untouchedbuildClaudeCodeCompatibleRequest— root union flattened on the CC bridgeCoverage Notes
The change touches
open-sse/only. Every new branch innormalizeClaudeToolInputSchemais exercised by the new suite: each of the three keywords, object/non-object/union-typed branches, collision handling, malformed input, the identity path, and both call sites. Coverage on the touched modules moves up, not down.Reviewer Notes
normalizeClaudeToolInputSchemareturns the input by identity, so existing tool payloads are forwarded byte-identically.anyOf/oneOfrequirements are deliberately dropped rather than merged. Merging them would make mutually exclusive branch fields simultaneously required and reject valid calls. Pinned by the test "does not promote alternative branch requirements of anyOf/oneOf".sanitizeClaudeToolSchemathe flattening runs afterstripInvalidSchemaConstructs, so an index-keyed union object is coerced back into a real array before the branches are read. Pinned by the test "flattens an index-keyed root union object".changelog.d/fixes/13552-claude-tool-schema-root-unions.md.