From 96922bd5c9ffed84c562958acb4fbd33fa11debe Mon Sep 17 00:00:00 2001 From: David Andrews Date: Sat, 15 Aug 2026 02:34:19 -0400 Subject: [PATCH] fix(ai): flattened xAI MCP exclusive-required anyOf --- packages/ai/CHANGELOG.md | 4 ++ packages/ai/src/utils/schema/normalize.ts | 55 +++++++++++++++++++ .../utils/schema/strict-tool-validation.ts | 28 ++++++++-- packages/ai/src/utils/schema/wire.ts | 48 ++++++++++++++++ .../openai-responses-tool-quarantine.test.ts | 46 ++++++++++++++++ packages/ai/test/schema-normalization.test.ts | 24 ++++++++ packages/ai/test/schema-wire.test.ts | 19 +++++++ 7 files changed, 219 insertions(+), 5 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index d83115a8b..f8bfaf21b 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -33,6 +33,10 @@ ### Fixed - 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. + ## [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 87e1823f0..0a08b6158 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -1575,6 +1575,11 @@ function normalizeOpenAIResponsesSchemaNode(value: unknown, cache: WeakMap 0) { + output.properties = properties; + } + delete output[unionKey]; + return true; +} + function declaresObjectType(type: unknown): boolean { if (type === "object") return true; if (!Array.isArray(type)) return false; diff --git a/packages/ai/src/utils/schema/strict-tool-validation.ts b/packages/ai/src/utils/schema/strict-tool-validation.ts index 666c83d5d..d20a50469 100644 --- a/packages/ai/src/utils/schema/strict-tool-validation.ts +++ b/packages/ai/src/utils/schema/strict-tool-validation.ts @@ -2,11 +2,12 @@ * Detects tool-parameter schemas that pass structural JSON-Schema validation * (so {@link isValidJsonSchema} accepts them) yet make OpenAI-style providers * reject the whole request with HTTP 400 — namely an `enum`/`const` whose - * value(s) cannot satisfy the node's declared `type`. MCP servers emit these - * when a nullable/array branch is built incorrectly (e.g. a non-null `enum` - * copied onto a `type: "null"` branch, or an `enum` placed on an `array` - * schema instead of its `items`). One such tool 400s the entire turn, so - * callers quarantine just the offending tool. See issue #2652. + * value(s) cannot satisfy the node's declared `type`, or a root `anyOf`/`oneOf` + * whose branches are not objects (xAI: "tool parameter root must be an object + * type"). MCP servers emit these when a nullable/array branch is built + * incorrectly, or when exclusive-required is encoded as a typeless union. + * One such tool 400s the entire turn, so callers quarantine just the offending + * tool. See issue #2652. */ type JsonRecord = Record; @@ -91,6 +92,23 @@ export function findStrictToolSchemaViolation(schema: unknown, path = "#"): stri } } + // xAI rejects the whole request when the *root* schema is typed as object + // (or has properties) AND still carries an anyOf/oneOf with a typeless or + // non-object branch. Nested unions and pure root unions are not this error. + if (path === "#" && (types.includes("object") || (node.properties !== undefined && typeof node.properties === "object"))) { + for (const key of ["anyOf", "oneOf"] as const) { + const arr = node[key]; + if (!Array.isArray(arr) || arr.length === 0) continue; + const hasNonObjectBranch = arr.some(branch => { + if (typeof branch !== "object" || branch === null || Array.isArray(branch)) return true; + const branchTypes = declaredTypes(branch as JsonRecord); + return !branchTypes.includes("object"); + }); + if (hasNonObjectBranch) return `${path}/${key}`; + } + } + + for (const key of CHILD_MAP_KEYS) { const sub = node[key]; if (sub && typeof sub === "object" && !Array.isArray(sub)) { diff --git a/packages/ai/src/utils/schema/wire.ts b/packages/ai/src/utils/schema/wire.ts index 2f203c9de..0f88c7964 100644 --- a/packages/ai/src/utils/schema/wire.ts +++ b/packages/ai/src/utils/schema/wire.ts @@ -204,6 +204,53 @@ function rewriteNullableScalarAnyOf(schema: Record): void { schema.type = [scalarType, "null"]; } +function isTypelessObjectConstraintBranch(branch: unknown): boolean { + if (!isSchemaRecord(branch)) return false; + if (Object.hasOwn(branch, "type")) return false; + for (const key in branch) { + if (!Object.hasOwn(branch, key)) continue; + if ( + key === "required" || + key === "properties" || + key === "additionalProperties" || + key === "description" || + key === "title" + ) { + continue; + } + return false; + } + return ( + Object.hasOwn(branch, "required") || + Object.hasOwn(branch, "properties") || + Object.hasOwn(branch, "additionalProperties") + ); +} + +function flattenExclusiveRequiredUnion(schema: Record): void { + const unionKey = Array.isArray(schema.anyOf) ? "anyOf" : Array.isArray(schema.oneOf) ? "oneOf" : undefined; + if (!unionKey) return; + const union = schema[unionKey]; + if (!Array.isArray(union) || union.length === 0) return; + const typedObject = schema.type === "object" || (Array.isArray(schema.type) && schema.type.includes("object")); + if (!typedObject && !isSchemaRecord(schema.properties)) return; + if (!union.every(isTypelessObjectConstraintBranch)) return; + + const properties = isSchemaRecord(schema.properties) ? schema.properties : {}; + for (const branch of union) { + if (!isSchemaRecord(branch) || !isSchemaRecord(branch.properties)) continue; + for (const name in branch.properties) { + if (!Object.hasOwn(branch.properties, name)) continue; + if (!Object.hasOwn(properties, name)) properties[name] = branch.properties[name]; + } + } + if (!isSchemaRecord(schema.properties) && Object.keys(properties).length > 0) { + schema.properties = properties; + } + delete schema[unionKey]; +} + + /** Keys whose values are a single JSON Schema (not an array or map). */ const SCHEMA_VALUE_KEYS = [ "additionalProperties", @@ -393,6 +440,7 @@ 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/openai-responses-tool-quarantine.test.ts b/packages/ai/test/openai-responses-tool-quarantine.test.ts index cb661b9b8..dbe72bbd6 100644 --- a/packages/ai/test/openai-responses-tool-quarantine.test.ts +++ b/packages/ai/test/openai-responses-tool-quarantine.test.ts @@ -54,6 +54,28 @@ describe("findStrictToolSchemaViolation (#2652)", () => { // enum without a declared type cannot contradict anything. expect(findStrictToolSchemaViolation({ enum: ["x"] })).toBeNull(); }); + + test("flags a root anyOf whose branches are not objects (xAI root-union 400)", () => { + expect( + findStrictToolSchemaViolation({ + type: "object", + properties: { project: { type: "string" } }, + anyOf: [{ required: ["paths"] }, { required: ["scopes"] }], + }), + ).toBe("#/anyOf"); + }); + + test("accepts a root anyOf of typed object branches", () => { + expect( + findStrictToolSchemaViolation({ + anyOf: [ + { type: "object", properties: { a: { type: "string" } } }, + { type: "object", properties: { b: { type: "number" } } }, + ], + }), + ).toBeNull(); + }); + }); const badTool: Tool = { @@ -91,6 +113,30 @@ describe("convertTools quarantine (#2652)", () => { expect(convertTools([goodTool], true, makeModel())).toHaveLength(1); }); + test("keeps an exclusive-required MCP tool after Responses flatten", () => { + const coverageTool: Tool = { + name: "mcp__codebase_memory_check_index_coverage", + description: "coverage", + parameters: { + type: "object", + properties: { + project: { type: "string" }, + paths: { type: "array", items: { type: "string" } }, + scopes: { type: "array", items: { type: "string" } }, + }, + required: ["project"], + anyOf: [{ required: ["paths"] }, { required: ["scopes"] }], + } as unknown as Tool["parameters"], + }; + const out = convertTools([coverageTool, goodTool], true, makeModel()) as Array<{ + name: string; + parameters: { anyOf?: unknown }; + }>; + expect(out.map(t => t.name)).toEqual(["mcp__codebase_memory_check_index_coverage", "read_file"]); + expect(out[0]?.parameters.anyOf).toBeUndefined(); + }); + + test("reports the hidden tool name and the offending schema path", () => { const dropped: Array<{ name: string; path: string }> = []; convertTools([badTool], true, makeModel(), (name, path) => dropped.push({ name, path })); diff --git a/packages/ai/test/schema-normalization.test.ts b/packages/ai/test/schema-normalization.test.ts index 4e5f87b03..872287cdf 100644 --- a/packages/ai/test/schema-normalization.test.ts +++ b/packages/ai/test/schema-normalization.test.ts @@ -606,6 +606,30 @@ describe("sanitizeSchemaForOpenAIResponses", () => { expect(properties.self).toBe(sanitized as unknown as object); expect((sanitized as { type: unknown }).type).toBe("object"); }); + + it("flattens exclusive-required anyOf into the parent object (xAI root-union 400)", () => { + const schema = { + type: "object", + properties: { + project: { type: "string" }, + paths: { type: "array", items: { type: "string" } }, + scopes: { type: "array", items: { type: "string" } }, + }, + required: ["project"], + anyOf: [{ required: ["paths"] }, { required: ["scopes"] }], + }; + + expect(sanitizeSchemaForOpenAIResponses(schema)).toEqual({ + type: "object", + properties: { + project: { type: "string" }, + paths: { type: "array", items: { type: "string" } }, + scopes: { type: "array", items: { type: "string" } }, + }, + required: ["project"], + }); + }); + }); // --------------------------------------------------------------------------- diff --git a/packages/ai/test/schema-wire.test.ts b/packages/ai/test/schema-wire.test.ts index ef7078871..9e11bfa1a 100644 --- a/packages/ai/test/schema-wire.test.ts +++ b/packages/ai/test/schema-wire.test.ts @@ -87,6 +87,25 @@ describe("toolWireSchema — raw JSON Schema normalization", () => { }); }); + it("flattens exclusive-required anyOf so xAI completions accept the tool", () => { + const wire = toolWireSchema( + jsonTool({ + type: "object", + properties: { + project: { type: "string" }, + paths: { type: "array", items: { type: "string" } }, + scopes: { type: "array", items: { type: "string" } }, + }, + required: ["project"], + anyOf: [{ required: ["paths"] }, { required: ["scopes"] }], + }), + ); + expect(wire.anyOf).toBeUndefined(); + expect(wire.type).toBe("object"); + expect(wire.required).toEqual(["project"]); + }); + + it("preserves raw JSON Schema required defaults and safe-integer bounds", () => { const wire = toolWireSchema( jsonTool({