From 00347bda754bbdc91f99ca25ffd94c2f4b79f216 Mon Sep 17 00:00:00 2001 From: Jason Separovic Date: Mon, 2 Mar 2026 09:59:49 -0800 Subject: [PATCH] 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. --- src/agents/pi-tools.schema.ts | 29 +++-- src/agents/pi-tools.ts | 5 +- src/agents/schema/clean-for-xai.test.ts | 143 ++++++++++++++++++++++++ src/agents/schema/clean-for-xai.ts | 56 ++++++++++ 4 files changed, 221 insertions(+), 12 deletions(-) create mode 100644 src/agents/schema/clean-for-xai.test.ts create mode 100644 src/agents/schema/clean-for-xai.ts diff --git a/src/agents/pi-tools.schema.ts b/src/agents/pi-tools.schema.ts index f17d0077626..407f277645d 100644 --- a/src/agents/pi-tools.schema.ts +++ b/src/agents/pi-tools.schema.ts @@ -1,5 +1,6 @@ import type { AnyAgentTool } from "./pi-tools.types.js"; import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js"; +import { isXaiProvider, stripXaiUnsupportedKeywords } from "./schema/clean-for-xai.js"; function extractEnumValues(schema: unknown): unknown[] | undefined { if (!schema || typeof schema !== "object") { @@ -64,7 +65,7 @@ function mergePropertySchemas(existing: unknown, incoming: unknown): unknown { export function normalizeToolParameters( tool: AnyAgentTool, - options?: { modelProvider?: string }, + options?: { modelProvider?: string; modelId?: string }, ): AnyAgentTool { const schema = tool.parameters && typeof tool.parameters === "object" @@ -79,6 +80,7 @@ export function normalizeToolParameters( // - OpenAI rejects function tool schemas unless the *top-level* is `type: "object"`. // (TypeBox root unions compile to `{ anyOf: [...] }` without `type`). // - Anthropic expects full JSON Schema draft 2020-12 compliance. + // - xAI rejects validation-constraint keywords (minLength, maxLength, etc.) outright. // // Normalize once here so callers can always pass `tools` through unchanged. @@ -86,13 +88,24 @@ export function normalizeToolParameters( options?.modelProvider?.toLowerCase().includes("google") || options?.modelProvider?.toLowerCase().includes("gemini"); const isAnthropicProvider = options?.modelProvider?.toLowerCase().includes("anthropic"); + const isXai = isXaiProvider(options?.modelProvider, options?.modelId); + + function applyProviderCleaning(s: unknown): unknown { + if (isGeminiProvider && !isAnthropicProvider) { + return cleanSchemaForGemini(s); + } + if (isXai) { + return stripXaiUnsupportedKeywords(s); + } + return s; + } // If schema already has type + properties (no top-level anyOf to merge), - // clean it for Gemini compatibility (but only if using Gemini, not Anthropic) + // clean it for Gemini/xAI compatibility as appropriate. if ("type" in schema && "properties" in schema && !Array.isArray(schema.anyOf)) { return { ...tool, - parameters: isGeminiProvider && !isAnthropicProvider ? cleanSchemaForGemini(schema) : schema, + parameters: applyProviderCleaning(schema), }; } @@ -107,10 +120,7 @@ export function normalizeToolParameters( const schemaWithType = { ...schema, type: "object" }; return { ...tool, - parameters: - isGeminiProvider && !isAnthropicProvider - ? cleanSchemaForGemini(schemaWithType) - : schemaWithType, + parameters: applyProviderCleaning(schemaWithType), }; } @@ -184,10 +194,7 @@ export function normalizeToolParameters( // - OpenAI rejects schemas without top-level `type: "object"`. // - Anthropic accepts proper JSON Schema with constraints. // Merging properties preserves useful enums like `action` while keeping schemas portable. - parameters: - isGeminiProvider && !isAnthropicProvider - ? cleanSchemaForGemini(flattenedSchema) - : flattenedSchema, + parameters: applyProviderCleaning(flattenedSchema), }; } diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index f2f8a505e74..b5e9276b7fc 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -524,7 +524,10 @@ export function createOpenClawCodingTools(options?: { // Without this, some providers (notably OpenAI) will reject root-level union schemas. // Provider-specific cleaning: Gemini needs constraint keywords stripped, but Anthropic expects them. const normalized = subagentFiltered.map((tool) => - normalizeToolParameters(tool, { modelProvider: options?.modelProvider }), + normalizeToolParameters(tool, { + modelProvider: options?.modelProvider, + modelId: options?.modelId, + }), ); const withHooks = normalized.map((tool) => wrapToolWithBeforeToolCallHook(tool, { diff --git a/src/agents/schema/clean-for-xai.test.ts b/src/agents/schema/clean-for-xai.test.ts new file mode 100644 index 00000000000..a48cc99fbc2 --- /dev/null +++ b/src/agents/schema/clean-for-xai.test.ts @@ -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 }; + }; + 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 }; + }; + 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; + 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 } } }; + }; + 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>; + }; + 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; + }; + 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; + 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); + }); +}); diff --git a/src/agents/schema/clean-for-xai.ts b/src/agents/schema/clean-for-xai.ts new file mode 100644 index 00000000000..b18b5746371 --- /dev/null +++ b/src/agents/schema/clean-for-xai.ts @@ -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; + const cleaned: Record = {}; + 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).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; +}