Skip to content

Commit 00347bd

Browse files
Jason Separovicsteipete
authored andcommitted
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.
1 parent da05395 commit 00347bd

File tree

4 files changed

+221
-12
lines changed

4 files changed

+221
-12
lines changed

src/agents/pi-tools.schema.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { AnyAgentTool } from "./pi-tools.types.js";
22
import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js";
3+
import { isXaiProvider, stripXaiUnsupportedKeywords } from "./schema/clean-for-xai.js";
34

45
function extractEnumValues(schema: unknown): unknown[] | undefined {
56
if (!schema || typeof schema !== "object") {
@@ -64,7 +65,7 @@ function mergePropertySchemas(existing: unknown, incoming: unknown): unknown {
6465

6566
export function normalizeToolParameters(
6667
tool: AnyAgentTool,
67-
options?: { modelProvider?: string },
68+
options?: { modelProvider?: string; modelId?: string },
6869
): AnyAgentTool {
6970
const schema =
7071
tool.parameters && typeof tool.parameters === "object"
@@ -79,20 +80,32 @@ export function normalizeToolParameters(
7980
// - OpenAI rejects function tool schemas unless the *top-level* is `type: "object"`.
8081
// (TypeBox root unions compile to `{ anyOf: [...] }` without `type`).
8182
// - Anthropic expects full JSON Schema draft 2020-12 compliance.
83+
// - xAI rejects validation-constraint keywords (minLength, maxLength, etc.) outright.
8284
//
8385
// Normalize once here so callers can always pass `tools` through unchanged.
8486

8587
const isGeminiProvider =
8688
options?.modelProvider?.toLowerCase().includes("google") ||
8789
options?.modelProvider?.toLowerCase().includes("gemini");
8890
const isAnthropicProvider = options?.modelProvider?.toLowerCase().includes("anthropic");
91+
const isXai = isXaiProvider(options?.modelProvider, options?.modelId);
92+
93+
function applyProviderCleaning(s: unknown): unknown {
94+
if (isGeminiProvider && !isAnthropicProvider) {
95+
return cleanSchemaForGemini(s);
96+
}
97+
if (isXai) {
98+
return stripXaiUnsupportedKeywords(s);
99+
}
100+
return s;
101+
}
89102

90103
// If schema already has type + properties (no top-level anyOf to merge),
91-
// clean it for Gemini compatibility (but only if using Gemini, not Anthropic)
104+
// clean it for Gemini/xAI compatibility as appropriate.
92105
if ("type" in schema && "properties" in schema && !Array.isArray(schema.anyOf)) {
93106
return {
94107
...tool,
95-
parameters: isGeminiProvider && !isAnthropicProvider ? cleanSchemaForGemini(schema) : schema,
108+
parameters: applyProviderCleaning(schema),
96109
};
97110
}
98111

@@ -107,10 +120,7 @@ export function normalizeToolParameters(
107120
const schemaWithType = { ...schema, type: "object" };
108121
return {
109122
...tool,
110-
parameters:
111-
isGeminiProvider && !isAnthropicProvider
112-
? cleanSchemaForGemini(schemaWithType)
113-
: schemaWithType,
123+
parameters: applyProviderCleaning(schemaWithType),
114124
};
115125
}
116126

@@ -184,10 +194,7 @@ export function normalizeToolParameters(
184194
// - OpenAI rejects schemas without top-level `type: "object"`.
185195
// - Anthropic accepts proper JSON Schema with constraints.
186196
// Merging properties preserves useful enums like `action` while keeping schemas portable.
187-
parameters:
188-
isGeminiProvider && !isAnthropicProvider
189-
? cleanSchemaForGemini(flattenedSchema)
190-
: flattenedSchema,
197+
parameters: applyProviderCleaning(flattenedSchema),
191198
};
192199
}
193200

src/agents/pi-tools.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -524,7 +524,10 @@ export function createOpenClawCodingTools(options?: {
524524
// Without this, some providers (notably OpenAI) will reject root-level union schemas.
525525
// Provider-specific cleaning: Gemini needs constraint keywords stripped, but Anthropic expects them.
526526
const normalized = subagentFiltered.map((tool) =>
527-
normalizeToolParameters(tool, { modelProvider: options?.modelProvider }),
527+
normalizeToolParameters(tool, {
528+
modelProvider: options?.modelProvider,
529+
modelId: options?.modelId,
530+
}),
528531
);
529532
const withHooks = normalized.map((tool) =>
530533
wrapToolWithBeforeToolCallHook(tool, {
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { describe, expect, it } from "vitest";
2+
import { isXaiProvider, stripXaiUnsupportedKeywords } from "./clean-for-xai.js";
3+
4+
describe("isXaiProvider", () => {
5+
it("matches direct xai provider", () => {
6+
expect(isXaiProvider("xai")).toBe(true);
7+
});
8+
9+
it("matches x-ai provider string", () => {
10+
expect(isXaiProvider("x-ai")).toBe(true);
11+
});
12+
13+
it("matches openrouter with x-ai model id", () => {
14+
expect(isXaiProvider("openrouter", "x-ai/grok-4.1-fast")).toBe(true);
15+
});
16+
17+
it("does not match openrouter with non-xai model id", () => {
18+
expect(isXaiProvider("openrouter", "openai/gpt-4o")).toBe(false);
19+
});
20+
21+
it("does not match openai provider", () => {
22+
expect(isXaiProvider("openai")).toBe(false);
23+
});
24+
25+
it("does not match google provider", () => {
26+
expect(isXaiProvider("google")).toBe(false);
27+
});
28+
29+
it("handles undefined provider", () => {
30+
expect(isXaiProvider(undefined)).toBe(false);
31+
});
32+
});
33+
34+
describe("stripXaiUnsupportedKeywords", () => {
35+
it("strips minLength and maxLength from string properties", () => {
36+
const schema = {
37+
type: "object",
38+
properties: {
39+
name: { type: "string", minLength: 1, maxLength: 64, description: "A name" },
40+
},
41+
};
42+
const result = stripXaiUnsupportedKeywords(schema) as {
43+
properties: { name: Record<string, unknown> };
44+
};
45+
expect(result.properties.name.minLength).toBeUndefined();
46+
expect(result.properties.name.maxLength).toBeUndefined();
47+
expect(result.properties.name.type).toBe("string");
48+
expect(result.properties.name.description).toBe("A name");
49+
});
50+
51+
it("strips minItems and maxItems from array properties", () => {
52+
const schema = {
53+
type: "object",
54+
properties: {
55+
items: { type: "array", minItems: 1, maxItems: 50, items: { type: "string" } },
56+
},
57+
};
58+
const result = stripXaiUnsupportedKeywords(schema) as {
59+
properties: { items: Record<string, unknown> };
60+
};
61+
expect(result.properties.items.minItems).toBeUndefined();
62+
expect(result.properties.items.maxItems).toBeUndefined();
63+
expect(result.properties.items.type).toBe("array");
64+
});
65+
66+
it("strips minContains and maxContains", () => {
67+
const schema = {
68+
type: "array",
69+
minContains: 1,
70+
maxContains: 5,
71+
contains: { type: "string" },
72+
};
73+
const result = stripXaiUnsupportedKeywords(schema) as Record<string, unknown>;
74+
expect(result.minContains).toBeUndefined();
75+
expect(result.maxContains).toBeUndefined();
76+
expect(result.contains).toBeDefined();
77+
});
78+
79+
it("strips keywords recursively inside nested objects", () => {
80+
const schema = {
81+
type: "object",
82+
properties: {
83+
attachment: {
84+
type: "object",
85+
properties: {
86+
content: { type: "string", maxLength: 6_700_000 },
87+
},
88+
},
89+
},
90+
};
91+
const result = stripXaiUnsupportedKeywords(schema) as {
92+
properties: { attachment: { properties: { content: Record<string, unknown> } } };
93+
};
94+
expect(result.properties.attachment.properties.content.maxLength).toBeUndefined();
95+
expect(result.properties.attachment.properties.content.type).toBe("string");
96+
});
97+
98+
it("strips keywords inside anyOf/oneOf/allOf variants", () => {
99+
const schema = {
100+
anyOf: [{ type: "string", minLength: 1 }, { type: "null" }],
101+
};
102+
const result = stripXaiUnsupportedKeywords(schema) as {
103+
anyOf: Array<Record<string, unknown>>;
104+
};
105+
expect(result.anyOf[0].minLength).toBeUndefined();
106+
expect(result.anyOf[0].type).toBe("string");
107+
});
108+
109+
it("strips keywords inside array item schemas", () => {
110+
const schema = {
111+
type: "array",
112+
items: { type: "string", maxLength: 100 },
113+
};
114+
const result = stripXaiUnsupportedKeywords(schema) as {
115+
items: Record<string, unknown>;
116+
};
117+
expect(result.items.maxLength).toBeUndefined();
118+
expect(result.items.type).toBe("string");
119+
});
120+
121+
it("preserves all other schema keywords", () => {
122+
const schema = {
123+
type: "object",
124+
description: "A tool schema",
125+
required: ["name"],
126+
properties: {
127+
name: { type: "string", description: "The name", enum: ["foo", "bar"] },
128+
},
129+
additionalProperties: false,
130+
};
131+
const result = stripXaiUnsupportedKeywords(schema) as Record<string, unknown>;
132+
expect(result.type).toBe("object");
133+
expect(result.description).toBe("A tool schema");
134+
expect(result.required).toEqual(["name"]);
135+
expect(result.additionalProperties).toBe(false);
136+
});
137+
138+
it("passes through primitives and null unchanged", () => {
139+
expect(stripXaiUnsupportedKeywords(null)).toBeNull();
140+
expect(stripXaiUnsupportedKeywords("string")).toBe("string");
141+
expect(stripXaiUnsupportedKeywords(42)).toBe(42);
142+
});
143+
});

src/agents/schema/clean-for-xai.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// xAI rejects these JSON Schema validation keywords in tool definitions instead of
2+
// ignoring them, causing 502 errors for any request that includes them. Strip them
3+
// before sending to xAI directly, or via OpenRouter when the downstream model is xAI.
4+
export const XAI_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
5+
"minLength",
6+
"maxLength",
7+
"minItems",
8+
"maxItems",
9+
"minContains",
10+
"maxContains",
11+
]);
12+
13+
export function stripXaiUnsupportedKeywords(schema: unknown): unknown {
14+
if (!schema || typeof schema !== "object") {
15+
return schema;
16+
}
17+
if (Array.isArray(schema)) {
18+
return schema.map(stripXaiUnsupportedKeywords);
19+
}
20+
const obj = schema as Record<string, unknown>;
21+
const cleaned: Record<string, unknown> = {};
22+
for (const [key, value] of Object.entries(obj)) {
23+
if (XAI_UNSUPPORTED_SCHEMA_KEYWORDS.has(key)) {
24+
continue;
25+
}
26+
if (key === "properties" && value && typeof value === "object" && !Array.isArray(value)) {
27+
cleaned[key] = Object.fromEntries(
28+
Object.entries(value as Record<string, unknown>).map(([k, v]) => [
29+
k,
30+
stripXaiUnsupportedKeywords(v),
31+
]),
32+
);
33+
} else if (key === "items" && value && typeof value === "object") {
34+
cleaned[key] = Array.isArray(value)
35+
? value.map(stripXaiUnsupportedKeywords)
36+
: stripXaiUnsupportedKeywords(value);
37+
} else if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(value)) {
38+
cleaned[key] = value.map(stripXaiUnsupportedKeywords);
39+
} else {
40+
cleaned[key] = value;
41+
}
42+
}
43+
return cleaned;
44+
}
45+
46+
export function isXaiProvider(modelProvider?: string, modelId?: string): boolean {
47+
const provider = modelProvider?.toLowerCase() ?? "";
48+
if (provider.includes("xai") || provider.includes("x-ai")) {
49+
return true;
50+
}
51+
// OpenRouter proxies to xAI when the model id starts with "x-ai/"
52+
if (provider === "openrouter" && modelId?.toLowerCase().startsWith("x-ai/")) {
53+
return true;
54+
}
55+
return false;
56+
}

0 commit comments

Comments
 (0)