fix(ai): demote root tool-schema combinators for Anthropic input_schema (#2882)
This commit is contained in:
@@ -29,6 +29,10 @@
|
||||
- Fixed OpenRouter Responses request shaping to preserve provider routing, variant suffixes, caller header overrides, and strict-tool fallback behavior while omitting only unsafe default max-token caps.
|
||||
- Fixed OpenAI Responses stateful chaining so a non-ZDR stale `previous_response_id` retry keeps `store: true`: the full-context retry stays chainable on the next turn and the consecutive stale-failure circuit breaker trips after the configured limit instead of alternating cold turns. Zero Data Retention rejections still disable chaining on the first strike.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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<string> | 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<string> | und
|
||||
function normalizeAnthropicToolSchemaNode(
|
||||
schema: unknown,
|
||||
cache: WeakMap<Record<string, unknown>, Record<string, unknown>>,
|
||||
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 = {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user