fix(ai): flattened xAI MCP exclusive-required anyOf
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user