fix(ai): flattened xAI exclusive-required anyOf at tool root only

This commit is contained in:
David Andrews
2026-08-15 04:06:36 -04:00
committed by can1357
parent 96922bd5c9
commit cb96258405
5 changed files with 54 additions and 7 deletions
+1 -1
View File
@@ -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
+5 -5
View File
@@ -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<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
+3 -1
View File
@@ -114,6 +114,9 @@ const kStrippedSchema = Symbol("pi.schema.descriptions.stripped");
function postProcessJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
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<string, unknown>;
rewriteNullableScalarAnyOf(obj);
flattenExclusiveRequiredUnion(obj);
inferBareEnumScalarType(obj);
collapseConstUnionAnyOf(obj);
for (const k in obj) walk(obj[k]);
@@ -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<string, unknown>).outputSchema as Record<string, unknown>;
expect(outputSchema.anyOf).toEqual([{ required: ["paths"] }, { required: ["scopes"] }]);
});
});
// ---------------------------------------------------------------------------
+23
View File
@@ -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<string, unknown>).outputSchema as Record<string, unknown>;
expect(outputSchema.anyOf).toEqual([{ required: ["paths"] }, { required: ["scopes"] }]);
});
it("preserves raw JSON Schema required defaults and safe-integer bounds", () => {
const wire = toolWireSchema(