fix(tools): strip xAI-unsupported JSON Schema keywords from tool definitions

xAI rejects minLength, maxLength, minItems, maxItems, minContains, and
maxContains in tool schemas with a 502 error instead of ignoring them.
This causes all requests to fail when any tool definition includes these
validation-constraint keywords (e.g. sessions_spawn uses maxLength and
maxItems on its attachment fields).

Add stripXaiUnsupportedKeywords() in schema/clean-for-xai.ts, mirroring
the existing cleanSchemaForGemini() pattern. Apply it in normalizeToolParameters()
when the provider is xai directly, or openrouter with an x-ai/* model id.

Fixes tool calls for x-ai/grok-* models both direct and via OpenRouter.
This commit is contained in:
Jason Separovic
2026-03-02 09:59:49 -08:00
committed by Peter Steinberger
parent da05395c2a
commit 00347bda75
4 changed files with 221 additions and 12 deletions

View File

@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import { isXaiProvider, stripXaiUnsupportedKeywords } from "./clean-for-xai.js";
describe("isXaiProvider", () => {
it("matches direct xai provider", () => {
expect(isXaiProvider("xai")).toBe(true);
});
it("matches x-ai provider string", () => {
expect(isXaiProvider("x-ai")).toBe(true);
});
it("matches openrouter with x-ai model id", () => {
expect(isXaiProvider("openrouter", "x-ai/grok-4.1-fast")).toBe(true);
});
it("does not match openrouter with non-xai model id", () => {
expect(isXaiProvider("openrouter", "openai/gpt-4o")).toBe(false);
});
it("does not match openai provider", () => {
expect(isXaiProvider("openai")).toBe(false);
});
it("does not match google provider", () => {
expect(isXaiProvider("google")).toBe(false);
});
it("handles undefined provider", () => {
expect(isXaiProvider(undefined)).toBe(false);
});
});
describe("stripXaiUnsupportedKeywords", () => {
it("strips minLength and maxLength from string properties", () => {
const schema = {
type: "object",
properties: {
name: { type: "string", minLength: 1, maxLength: 64, description: "A name" },
},
};
const result = stripXaiUnsupportedKeywords(schema) as {
properties: { name: Record<string, unknown> };
};
expect(result.properties.name.minLength).toBeUndefined();
expect(result.properties.name.maxLength).toBeUndefined();
expect(result.properties.name.type).toBe("string");
expect(result.properties.name.description).toBe("A name");
});
it("strips minItems and maxItems from array properties", () => {
const schema = {
type: "object",
properties: {
items: { type: "array", minItems: 1, maxItems: 50, items: { type: "string" } },
},
};
const result = stripXaiUnsupportedKeywords(schema) as {
properties: { items: Record<string, unknown> };
};
expect(result.properties.items.minItems).toBeUndefined();
expect(result.properties.items.maxItems).toBeUndefined();
expect(result.properties.items.type).toBe("array");
});
it("strips minContains and maxContains", () => {
const schema = {
type: "array",
minContains: 1,
maxContains: 5,
contains: { type: "string" },
};
const result = stripXaiUnsupportedKeywords(schema) as Record<string, unknown>;
expect(result.minContains).toBeUndefined();
expect(result.maxContains).toBeUndefined();
expect(result.contains).toBeDefined();
});
it("strips keywords recursively inside nested objects", () => {
const schema = {
type: "object",
properties: {
attachment: {
type: "object",
properties: {
content: { type: "string", maxLength: 6_700_000 },
},
},
},
};
const result = stripXaiUnsupportedKeywords(schema) as {
properties: { attachment: { properties: { content: Record<string, unknown> } } };
};
expect(result.properties.attachment.properties.content.maxLength).toBeUndefined();
expect(result.properties.attachment.properties.content.type).toBe("string");
});
it("strips keywords inside anyOf/oneOf/allOf variants", () => {
const schema = {
anyOf: [{ type: "string", minLength: 1 }, { type: "null" }],
};
const result = stripXaiUnsupportedKeywords(schema) as {
anyOf: Array<Record<string, unknown>>;
};
expect(result.anyOf[0].minLength).toBeUndefined();
expect(result.anyOf[0].type).toBe("string");
});
it("strips keywords inside array item schemas", () => {
const schema = {
type: "array",
items: { type: "string", maxLength: 100 },
};
const result = stripXaiUnsupportedKeywords(schema) as {
items: Record<string, unknown>;
};
expect(result.items.maxLength).toBeUndefined();
expect(result.items.type).toBe("string");
});
it("preserves all other schema keywords", () => {
const schema = {
type: "object",
description: "A tool schema",
required: ["name"],
properties: {
name: { type: "string", description: "The name", enum: ["foo", "bar"] },
},
additionalProperties: false,
};
const result = stripXaiUnsupportedKeywords(schema) as Record<string, unknown>;
expect(result.type).toBe("object");
expect(result.description).toBe("A tool schema");
expect(result.required).toEqual(["name"]);
expect(result.additionalProperties).toBe(false);
});
it("passes through primitives and null unchanged", () => {
expect(stripXaiUnsupportedKeywords(null)).toBeNull();
expect(stripXaiUnsupportedKeywords("string")).toBe("string");
expect(stripXaiUnsupportedKeywords(42)).toBe(42);
});
});

View File

@@ -0,0 +1,56 @@
// xAI rejects these JSON Schema validation keywords in tool definitions instead of
// ignoring them, causing 502 errors for any request that includes them. Strip them
// before sending to xAI directly, or via OpenRouter when the downstream model is xAI.
export const XAI_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
"minLength",
"maxLength",
"minItems",
"maxItems",
"minContains",
"maxContains",
]);
export function stripXaiUnsupportedKeywords(schema: unknown): unknown {
if (!schema || typeof schema !== "object") {
return schema;
}
if (Array.isArray(schema)) {
return schema.map(stripXaiUnsupportedKeywords);
}
const obj = schema as Record<string, unknown>;
const cleaned: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (XAI_UNSUPPORTED_SCHEMA_KEYWORDS.has(key)) {
continue;
}
if (key === "properties" && value && typeof value === "object" && !Array.isArray(value)) {
cleaned[key] = Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([k, v]) => [
k,
stripXaiUnsupportedKeywords(v),
]),
);
} else if (key === "items" && value && typeof value === "object") {
cleaned[key] = Array.isArray(value)
? value.map(stripXaiUnsupportedKeywords)
: stripXaiUnsupportedKeywords(value);
} else if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(value)) {
cleaned[key] = value.map(stripXaiUnsupportedKeywords);
} else {
cleaned[key] = value;
}
}
return cleaned;
}
export function isXaiProvider(modelProvider?: string, modelId?: string): boolean {
const provider = modelProvider?.toLowerCase() ?? "";
if (provider.includes("xai") || provider.includes("x-ai")) {
return true;
}
// OpenRouter proxies to xAI when the model id starts with "x-ai/"
if (provider === "openrouter" && modelId?.toLowerCase().startsWith("x-ai/")) {
return true;
}
return false;
}