diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 552ab9403..0c2906fe1 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Anthropic Messages tool schema normalization demoting root `anyOf`/`allOf` and all `oneOf` constraints into descriptions instead of forwarding provider-rejected keywords in MCP tool `input_schema`. + ## [16.0.5] - 2026-06-17 ### Added diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index a75bbb259..0c25c3065 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -3265,8 +3265,9 @@ export function convertAnthropicMessages( /** * JSON Schema whitelist for Anthropic tool `input_schema` nodes. * - * Mirrors the Anthropic Python SDK's `lib/_parse/_transform.py::transform_schema`: - * we keep only structural/metadata keywords Anthropic's validator honors, and demote + * Tracks the Anthropic Python SDK's `lib/_parse/_transform.py::transform_schema`, + * with live Messages API guardrails for keywords the SDK preserves but the API rejects. + * We keep only structural/metadata keywords Anthropic's validator honors, and demote * anything else into the node's `description` as `\n\n{key: value, ...}` so the model * still sees the constraint as a natural-language hint. * @@ -3281,7 +3282,6 @@ const ANTHROPIC_TOOL_SCHEMA_UNIVERSAL_KEEP = new Set([ "definitions", "type", "anyOf", - "oneOf", "allOf", "enum", "const", @@ -3377,13 +3377,16 @@ function anthropicPerTypeKeep(scalarType: string | undefined): Set | und * Applies the full whitelist semantics from the Anthropic Python SDK's * `lib/_parse/_transform.py::transform_schema`: * - * 1. Universal keys (`$ref`, `$defs`, `type`, `anyOf`/`oneOf`/`allOf`, `enum`, `const`, - * `description`, `title`, `default`, `nullable`) are preserved on every node. + * 1. Universal keys (`$ref`, `$defs`, `type`, `anyOf`, `allOf`, `enum`, `const`, + * `description`, `title`, `default`, `nullable`) are preserved on every node, with + * one position-dependent exception: the combinator keys. Root `anyOf`/`allOf` are + * spilled (recent Anthropic Messages validators reject combinators at the tool + * `input_schema` root) but kept when nested; `oneOf` is spilled at every position + * (it is not in the documented supported subset). * 2. Per-type keys are kept additively (object → `properties`/`required`/`additionalProperties`, * array → `items`/`prefixItems` plus `minItems` only when 0 or 1, string → `format` * only when in the supported value set). * 3. Everything else is demoted into the node's `description` as `\n\n{key: value, ...}` - * so the model still sees the constraint as a natural-language hint. * * Object nodes default to `additionalProperties: false`, but explicit open-map * declarations (`additionalProperties: true` or a schema literal — Zod's @@ -3394,6 +3397,7 @@ function anthropicPerTypeKeep(scalarType: string | undefined): Set | und function normalizeAnthropicToolSchemaNode( schema: unknown, cache: WeakMap, Record>, + isRoot = false, ): unknown { if (Array.isArray(schema)) return schema.map(entry => normalizeAnthropicToolSchemaNode(entry, cache)); if (!isRecord(schema)) return schema; @@ -3411,7 +3415,8 @@ function normalizeAnthropicToolSchemaNode( for (const key in schema) { if (!Object.hasOwn(schema, key)) continue; const value = schema[key]; - if (ANTHROPIC_TOOL_SCHEMA_UNIVERSAL_KEEP.has(key) || perTypeKeep?.has(key)) { + const isRootCombinator = isRoot && COMBINATOR_KEYS.includes(key as (typeof COMBINATOR_KEYS)[number]); + if (!isRootCombinator && (ANTHROPIC_TOOL_SCHEMA_UNIVERSAL_KEEP.has(key) || perTypeKeep?.has(key))) { result[key] = value; } else { spill.push([key, value]); @@ -3486,7 +3491,7 @@ function normalizeAnthropicToolSchemaNode( } export function normalizeAnthropicToolSchema(schema: unknown): unknown { - return normalizeAnthropicToolSchemaNode(schema, new WeakMap()); + return normalizeAnthropicToolSchemaNode(schema, new WeakMap(), true); } type AnthropicToolSchemaPlan = { diff --git a/packages/ai/test/anthropic-tool-schema.test.ts b/packages/ai/test/anthropic-tool-schema.test.ts index e52e3225f..3837c6915 100644 --- a/packages/ai/test/anthropic-tool-schema.test.ts +++ b/packages/ai/test/anthropic-tool-schema.test.ts @@ -196,12 +196,14 @@ describe("normalizeAnthropicToolSchema — SDK whitelist", () => { * `anthropic-sdk-python/tests/lib/_parse/test_transform.py`. We adapt assertions * to the function name `normalizeAnthropicToolSchema` and keep the same shapes. * - * Two deliberate divergences from the SDK (NOT bugs): + * Three deliberate divergences from the SDK (NOT bugs): * - `default` is preserved on every node (SDK demotes it into description). * Anthropic's API accepts `default`; preserving keeps Zod/OpenAPI fidelity. * - `$ref` does NOT short-circuit sibling keys (SDK drops everything else). * We keep `$defs`/`description` next to a `$ref` because callers feed us * deref-friendly schemas where siblings carry real semantics. + * - top-level `anyOf` / `allOf` and all `oneOf` occurrences are demoted into + * `description`; live Messages rejects root combinators and does not document `oneOf`. * Tests below that overlap with SDK cases asserting those behaviors are * adjusted to our contract; the divergence is called out inline. */ @@ -213,12 +215,19 @@ describe("normalizeAnthropicToolSchema — parity with anthropic-sdk-python tran }); // Mirrors: anthropic-sdk-python/tests/lib/_parse/test_transform.py::test_anyof_schema - it("recurses into anyOf variants and spills per-variant constraints", () => { + it("recurses into nested anyOf variants and spills per-variant constraints", () => { const out = normalizeAnthropicToolSchema({ - anyOf: [{ type: "string" }, { type: "integer", minimum: 1 }], + type: "object", + properties: { + value: { anyOf: [{ type: "string" }, { type: "integer", minimum: 1 }] }, + }, }); expect(out).toEqual({ - anyOf: [{ type: "string" }, { type: "integer", description: "{minimum: 1}" }], + type: "object", + properties: { + value: { anyOf: [{ type: "string" }, { type: "integer", description: "{minimum: 1}" }] }, + }, + additionalProperties: false, }); }); @@ -228,23 +237,61 @@ describe("normalizeAnthropicToolSchema — parity with anthropic-sdk-python tran expect(out).toEqual({ type: "string", enum: ["foo", "bar"] }); }); - // Mirrors: anthropic-sdk-python/tests/lib/_parse/test_transform.py::test_allof - it("recurses into allOf variants and defaults additionalProperties on each object branch", () => { + it("spills top-level anyOf variants into description for the Messages API root-combinator boundary", () => { const out = normalizeAnthropicToolSchema({ + type: "object", + properties: { id: { type: "string" } }, + anyOf: [{ required: ["id"] }, { required: ["name"] }], + }); + expect(out).toEqual({ + type: "object", + properties: { id: { type: "string" } }, + additionalProperties: false, + description: '{anyOf: [{"required":["id"]},{"required":["name"]}]}', + }); + }); + + // Divergence: SDK preserves allOf at the schema root, but the live Anthropic Messages + // validator rejects root combinators in tool input_schema. Spill it so the model + // still sees the constraints. + it("spills top-level allOf variants into description instead of sending an unsupported root keyword", () => { + const out = normalizeAnthropicToolSchema({ + type: "object", + properties: { id: { type: "string" } }, allOf: [ { type: "object", properties: { name: { type: "string" } } }, { type: "object", properties: { age: { type: "integer", minimum: 0 } } }, ], }); expect(out).toEqual({ - allOf: [ - { type: "object", properties: { name: { type: "string" } }, additionalProperties: false }, - { - type: "object", - properties: { age: { type: "integer", description: "{minimum: 0}" } }, - additionalProperties: false, - }, - ], + type: "object", + properties: { id: { type: "string" } }, + additionalProperties: false, + description: + '{allOf: [{"type":"object","properties":{"name":{"type":"string"}}},{"type":"object","properties":{"age":{"type":"integer","minimum":0}}}]}', + }); + }); + + it("spills oneOf variants at root and nested positions for the same Messages API compatibility boundary", () => { + const root = normalizeAnthropicToolSchema({ + oneOf: [{ type: "string" }, { type: "integer", minimum: 1 }], + }); + expect(root).toEqual({ + description: '{oneOf: [{"type":"string"},{"type":"integer","minimum":1}]}', + }); + + const nested = normalizeAnthropicToolSchema({ + type: "object", + properties: { + value: { oneOf: [{ type: "string" }, { type: "integer", minimum: 1 }] }, + }, + }); + expect(nested).toEqual({ + type: "object", + properties: { + value: { description: '{oneOf: [{"type":"string"},{"type":"integer","minimum":1}]}' }, + }, + additionalProperties: false, }); });