fix(ai): demote root tool-schema combinators for Anthropic input_schema (#2882)

This commit is contained in:
can1357
2026-06-18 02:46:48 +02:00
3 changed files with 78 additions and 22 deletions
+4
View File
@@ -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
+13 -8
View File
@@ -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 = {
+61 -14
View File
@@ -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,
});
});