fix(ai): flattened xAI MCP exclusive-required anyOf

This commit is contained in:
David Andrews
2026-08-15 02:34:19 -04:00
committed by can1357
parent 87cfda9368
commit 96922bd5c9
7 changed files with 219 additions and 5 deletions
+4
View File
@@ -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
+55
View File
@@ -1575,6 +1575,11 @@ function normalizeOpenAIResponsesSchemaNode(value: unknown, cache: WeakMap<JsonO
changed = true;
}
if (flattenOpenAIResponsesObjectConstraintUnion(output)) {
changed = true;
}
// Safe to overwrite the seed: any cyclic re-entry above already observed
// the seeded partial and set `changed = true` for that node, so a node
// that finishes with `changed === false` is provably non-cyclic and
@@ -1584,6 +1589,56 @@ function normalizeOpenAIResponsesSchemaNode(value: unknown, cache: WeakMap<JsonO
return result;
}
/**
* xAI (and some OpenAI-compat hosts) reject a tool whose *root* schema is an
* `anyOf`/`oneOf` with a typeless or non-object branch — even when the node
* also declares `type: "object"` and `properties`. MCP servers emit this as
* an exclusive-required pair (`anyOf: [{required:["paths"]},{required:["scopes"]}]`).
* Fold those object-constraint fragments into the parent object so the tool
* stays on the wire; exclusive-required becomes both-optional (server still
* enforces the OR).
*/
function isTypelessObjectConstraintBranch(branch: unknown): boolean {
if (!isJsonObject(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 flattenOpenAIResponsesObjectConstraintUnion(output: JsonObject): boolean {
const unionKey = Array.isArray(output.anyOf) ? "anyOf" : Array.isArray(output.oneOf) ? "oneOf" : undefined;
if (!unionKey) return false;
const union = output[unionKey];
if (!Array.isArray(union) || union.length === 0) return false;
if (!declaresObjectType(output.type) && !isJsonObject(output.properties)) return false;
if (!union.every(isTypelessObjectConstraintBranch)) return false;
const properties = isJsonObject(output.properties) ? output.properties : {};
for (const branch of union) {
if (!isJsonObject(branch) || !isJsonObject(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 (!isJsonObject(output.properties) && Object.keys(properties).length > 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;
@@ -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<string, unknown>;
@@ -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)) {
+48
View File
@@ -204,6 +204,53 @@ function rewriteNullableScalarAnyOf(schema: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>;
rewriteNullableScalarAnyOf(obj);
flattenExclusiveRequiredUnion(obj);
inferBareEnumScalarType(obj);
collapseConstUnionAnyOf(obj);
for (const k in obj) walk(obj[k]);
@@ -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 }));
@@ -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"],
});
});
});
// ---------------------------------------------------------------------------
+19
View File
@@ -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({