mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-09 15:35:17 +00:00
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:
committed by
Peter Steinberger
parent
da05395c2a
commit
00347bda75
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
143
src/agents/schema/clean-for-xai.test.ts
Normal file
143
src/agents/schema/clean-for-xai.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
56
src/agents/schema/clean-for-xai.ts
Normal file
56
src/agents/schema/clean-for-xai.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user