diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index f8bfaf21b..f9ce20e37 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -35,7 +35,7 @@ - Stopped runaway exact response cycles across model providers and kept persistent loops fail-closed after bounded guarded retries ([#8669](https://github.com/can1357/oh-my-pi/pull/8669) by [@pstarkgit](https://github.com/pstarkgit)). ### Fixed -- Fixed OpenAI Responses / Completions and xAI tool conversion 400ing the whole turn on MCP schemas whose root is an object plus a typeless exclusive-required `anyOf` (e.g. codebase-memory `check_index_coverage`). Those fragments now flatten in `toolWireSchema` so every other tool stays callable. +- Fixed OpenAI Responses / Completions and xAI tool conversion 400ing the whole turn on MCP schemas whose root is an object plus a typeless exclusive-required `anyOf` (e.g. codebase-memory `check_index_coverage`). Flatten only the **tool root** — nested unions (e.g. `task.outputSchema`) stay intact so Grok still sees valid `task`/`edit` schemas. ## [17.3.4] - 2026-08-14 diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index 0a08b6158..9cc224397 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -1472,7 +1472,11 @@ const OPENAI_RESPONSES_SCHEMA_VALUE_KEYS = new Set([ * would not survive). */ export function sanitizeSchemaForOpenAIResponses(schema: JsonObject): JsonObject { - return normalizeOpenAIResponsesSchemaNode(schema, new WeakMap()) as JsonObject; + const sanitized = normalizeOpenAIResponsesSchemaNode(schema, new WeakMap()) as JsonObject; + if (isJsonObject(sanitized)) { + flattenOpenAIResponsesObjectConstraintUnion(sanitized); + } + return sanitized; } /** @@ -1575,10 +1579,6 @@ function normalizeOpenAIResponsesSchemaNode(value: unknown, cache: WeakMap): Record { walk(schema); + // xAI 400s only when the *tool root* is object + typeless exclusive-required + // anyOf. Nested unions (task.outputSchema, etc.) must stay intact. + flattenExclusiveRequiredUnion(schema); normalizeArkPropertyComments(schema); normalizeEmptySchemas(schema); return schema; @@ -440,7 +443,6 @@ function walk(node: unknown): void { if (!node || typeof node !== "object") return; const obj = node as Record; rewriteNullableScalarAnyOf(obj); - flattenExclusiveRequiredUnion(obj); inferBareEnumScalarType(obj); collapseConstUnionAnyOf(obj); for (const k in obj) walk(obj[k]); diff --git a/packages/ai/test/schema-normalization.test.ts b/packages/ai/test/schema-normalization.test.ts index 872287cdf..60a5d79a3 100644 --- a/packages/ai/test/schema-normalization.test.ts +++ b/packages/ai/test/schema-normalization.test.ts @@ -630,6 +630,28 @@ describe("sanitizeSchemaForOpenAIResponses", () => { }); }); + it("does not flatten nested exclusive-required anyOf (xAI only rejects the tool root)", () => { + const schema = { + type: "object", + properties: { + outputSchema: { + type: "object", + properties: { + paths: { type: "array", items: { type: "string" } }, + scopes: { type: "array", items: { type: "string" } }, + }, + anyOf: [{ required: ["paths"] }, { required: ["scopes"] }], + }, + }, + required: ["outputSchema"], + }; + const sanitized = sanitizeSchemaForOpenAIResponses(schema); + expect(sanitized.anyOf).toBeUndefined(); + const outputSchema = (sanitized.properties as Record).outputSchema as Record; + expect(outputSchema.anyOf).toEqual([{ required: ["paths"] }, { required: ["scopes"] }]); + }); + + }); // --------------------------------------------------------------------------- diff --git a/packages/ai/test/schema-wire.test.ts b/packages/ai/test/schema-wire.test.ts index 9e11bfa1a..17f92c3f7 100644 --- a/packages/ai/test/schema-wire.test.ts +++ b/packages/ai/test/schema-wire.test.ts @@ -105,6 +105,29 @@ describe("toolWireSchema — raw JSON Schema normalization", () => { expect(wire.required).toEqual(["project"]); }); + it("does not flatten nested exclusive-required anyOf (only the tool root 400s xAI)", () => { + const wire = toolWireSchema( + jsonTool({ + type: "object", + properties: { + outputSchema: { + type: "object", + properties: { + paths: { type: "array", items: { type: "string" } }, + scopes: { type: "array", items: { type: "string" } }, + }, + anyOf: [{ required: ["paths"] }, { required: ["scopes"] }], + }, + }, + required: ["outputSchema"], + }), + ); + expect(wire.anyOf).toBeUndefined(); + const outputSchema = (wire.properties as Record).outputSchema as Record; + expect(outputSchema.anyOf).toEqual([{ required: ["paths"] }, { required: ["scopes"] }]); + }); + + it("preserves raw JSON Schema required defaults and safe-integer bounds", () => { const wire = toolWireSchema(