Skip to content

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

Merged
diegosouzapw merged 1 commit into
cheaper-inference:release/v3.8.51from
sprintberlin:fix/claude-tool-input-schema-root-combinators
Sep 18, 2026
Merged

diegosouzapw merged 1 commit into
cheaper-inference:release/v3.8.51from
sprintberlin:fix/claude-tool-input-schema-root-combinators

Conversation

@sprintberlin

Copy link
Copy Markdown
Contributor

Summary

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

[400]: 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 that catalog — and combo failover cannot recover, because no model is ever reached. Observed in production against claude/claude-opus-5 with OpenCode and other MCP-driven clients.

Both conversion paths that build a Claude input_schema from 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 translator
  • open-sse/services/claudeCodeCompatible.ts — convertClaudeCodeCompatibleTool() on the anthropic-compatible-cc-* bridge

The 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

Aspect Behavior
Branch properties Object-compatible branches contribute their properties; the root's own property wins on a name collision
Root type Pinned to type: "object"
allOf requirements Merged into root required — every branch applies at once, so they are cumulative
anyOf / oneOf requirements Not promoted — the branches are alternatives, and requiring them would refuse calls the original schema accepts
Nested unions Untouched — only the root position is what Anthropic refuses
Clean schemas Returned by identity, no rewrite
Malformed union (e.g. allOf: "x") Keyword dropped; it is the keyword itself Anthropic refuses

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. The anyOf/oneOf requirement rule above matches its behavior.

Related Issues

Validation

  • Change type: provider / translator
  • Focused tests and category gates from the golden path
  • npm run lint (see note below)
  • Reconciled with the current active release base (release/v3.8.51); focused checks rerun afterward
  • Production-code changes include a new automated test in this PR

Hard Rule #18 — TDD: the new test file reproduces the reported failure shape first (root-level anyOf on a tool reaching the Claude translator) and then asserts the flattened, Anthropic-accepted result.

# New regression suite — 17 tests, all pass
node --import tsx/esm --test tests/unit/claude-tool-schema-root-unions.test.ts
# → tests 17 | pass 17 | fail 0

# Existing suites covering the touched modules — no regressions
node --import tsx/esm --test \
  tests/unit/claude-oauth-tool-cloak.test.ts \
  tests/unit/schema-coercion.test.ts \
  tests/unit/claude-code-compatible-helpers.test.ts
# → tests 67 | pass 67 | fail 0

npx prettier --check <changed files>   # all match Prettier style
npx eslint <changed files>             # see note

ESLint note: npx eslint reports 2 pre-existing errors in the touched files (isDeepSeekReasoningModel unused in schemaCoercion.ts, toolNameMap unused in openai-to-claude.ts). Both reproduce identically on an unmodified release/v3.8.51 checkout 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 inputs
    • normalizeClaudeToolInputSchema — anyOf carrying the whole schema; nested union preserved while root removed; anyOf/oneOf alternatives not promoted to required; allOf property + requirement merge; root property wins collisions; non-object branches skipped; malformed union dropped; unrelated keywords (title, additionalProperties, $defs) preserved; clean schema identity; input not mutated
    • sanitizeClaudeToolSchema — flattening composes after invalid-construct repair, including an index-keyed union object
    • openaiToClaudeRequest — root union flattened end-to-end on the translated tool; nested union and ordinary schema untouched
    • buildClaudeCodeCompatibleRequest — root union flattened on the CC bridge

Coverage Notes

The change touches open-sse/ only. Every new branch in normalizeClaudeToolInputSchema is 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

  • No behavior change for schemas without a root union — normalizeClaudeToolInputSchema returns the input by identity, so existing tool payloads are forwarded byte-identically.
  • anyOf/oneOf requirements 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".
  • The sanitizer ordering matters: in sanitizeClaudeToolSchema the flattening runs after stripInvalidSchemaConstructs, 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".
  • No migrations, no feature flags, no config surface. Changelog fragment added at changelog.d/fixes/13552-claude-tool-schema-root-unions.md.

…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
@diegosouzapw

Copy link
Copy Markdown
Collaborator

This is a clean, well-scoped fix — ran the full regression suite at your head
and all 17 tests pass, including the two real conversion-path tests (not just
the pure function), and I confirmed sanitizeClaudeToolSchemas is indeed wired
into both executors/base.ts and executors/cliproxyapi.ts as described. The
anyOf/oneOf-vs-allOf requirement semantics table in your PR body matches the
implementation exactly. Changelog fragment is already in place. No blocking
items from this review — ready to merge as-is.

@diegosouzapw

Copy link
Copy Markdown
Collaborator

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.

@diegosouzapw
diegosouzapw merged commit 2a89a3b into cheaper-inference:release/v3.8.51 Sep 18, 2026
8 of 16 checks passed
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(claude): top-level anyOf/oneOf/allOf in tool input_schema rejected by Anthropic — sanitize or fail fast with tool name

2 participants