diff --git a/src/common/utils/tools/schemaSanitizer.test.ts b/src/common/utils/tools/schemaSanitizer.test.ts new file mode 100644 index 0000000000..1fef411dcc --- /dev/null +++ b/src/common/utils/tools/schemaSanitizer.test.ts @@ -0,0 +1,269 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ +import { sanitizeToolSchemaForOpenAI, sanitizeMCPToolsForOpenAI } from "./schemaSanitizer"; +import type { Tool } from "ai"; + +// Test helper to access tool parameters +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getParams(tool: Tool): any { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (tool as any).parameters; +} + +// Test helper to access tool inputSchema (MCP tools) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getInputSchema(tool: Tool): any { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const inputSchema = (tool as any).inputSchema; + // inputSchema has a jsonSchema getter + return inputSchema?.jsonSchema; +} + +describe("schemaSanitizer", () => { + describe("sanitizeToolSchemaForOpenAI", () => { + it("should strip minLength from string properties", () => { + const tool = { + description: "Test tool", + parameters: { + type: "object", + properties: { + content: { type: "string", minLength: 1 }, + }, + }, + } as unknown as Tool; + + const sanitized = sanitizeToolSchemaForOpenAI(tool); + const params = getParams(sanitized); + + expect(params.properties.content).toEqual({ type: "string" }); + expect(params.properties.content.minLength).toBeUndefined(); + }); + + it("should strip multiple unsupported properties", () => { + const tool = { + description: "Test tool", + parameters: { + type: "object", + properties: { + name: { type: "string", minLength: 1, maxLength: 100, pattern: "^[a-z]+$" }, + age: { type: "number", minimum: 0, maximum: 150, default: 25 }, + }, + }, + } as unknown as Tool; + + const sanitized = sanitizeToolSchemaForOpenAI(tool); + const params = getParams(sanitized); + + expect(params.properties.name).toEqual({ type: "string" }); + expect(params.properties.age).toEqual({ type: "number" }); + }); + + it("should handle nested objects", () => { + const tool = { + description: "Test tool", + parameters: { + type: "object", + properties: { + user: { + type: "object", + properties: { + email: { type: "string", format: "email", minLength: 5 }, + }, + }, + }, + }, + } as unknown as Tool; + + const sanitized = sanitizeToolSchemaForOpenAI(tool); + const params = getParams(sanitized); + + expect(params.properties.user.properties.email).toEqual({ type: "string" }); + }); + + it("should handle array items", () => { + const tool = { + description: "Test tool", + parameters: { + type: "object", + properties: { + tags: { + type: "array", + items: { type: "string", minLength: 1 }, + minItems: 1, + maxItems: 10, + }, + }, + }, + } as unknown as Tool; + + const sanitized = sanitizeToolSchemaForOpenAI(tool); + const params = getParams(sanitized); + + expect(params.properties.tags.items).toEqual({ type: "string" }); + expect(params.properties.tags.minItems).toBeUndefined(); + expect(params.properties.tags.maxItems).toBeUndefined(); + }); + + it("should handle anyOf/oneOf schemas", () => { + const tool = { + description: "Test tool", + parameters: { + type: "object", + properties: { + value: { + oneOf: [ + { type: "string", minLength: 1 }, + { type: "number", minimum: 0 }, + ], + }, + }, + }, + } as unknown as Tool; + + const sanitized = sanitizeToolSchemaForOpenAI(tool); + const params = getParams(sanitized); + + expect(params.properties.value.oneOf[0]).toEqual({ type: "string" }); + expect(params.properties.value.oneOf[1]).toEqual({ type: "number" }); + }); + + it("should preserve required and type properties", () => { + const tool = { + description: "Test tool", + parameters: { + type: "object", + properties: { + content: { type: "string", minLength: 1 }, + }, + required: ["content"], + }, + } as unknown as Tool; + + const sanitized = sanitizeToolSchemaForOpenAI(tool); + const params = getParams(sanitized); + + expect(params.type).toBe("object"); + expect(params.required).toEqual(["content"]); + }); + + it("should return tool as-is if no parameters", () => { + const tool = { + description: "Test tool", + } as unknown as Tool; + + const sanitized = sanitizeToolSchemaForOpenAI(tool); + + expect(sanitized).toEqual(tool); + }); + + it("should not mutate the original tool", () => { + const tool = { + description: "Test tool", + parameters: { + type: "object", + properties: { + content: { type: "string", minLength: 1 }, + }, + }, + } as unknown as Tool; + + sanitizeToolSchemaForOpenAI(tool); + const params = getParams(tool); + + // Original should still have minLength + expect(params.properties.content.minLength).toBe(1); + }); + + it("should sanitize MCP tools with inputSchema", () => { + // MCP tools use inputSchema with a jsonSchema getter instead of parameters + const jsonSchema = { + type: "object", + properties: { + content: { type: "string", minLength: 1, maxLength: 100 }, + count: { type: "number", minimum: 0, maximum: 10 }, + }, + required: ["content"], + }; + + const mcpTool = { + type: "dynamic", + description: "MCP test tool", + inputSchema: { + // Simulate the jsonSchema getter that @ai-sdk/mcp creates + get jsonSchema() { + return jsonSchema; + }, + }, + execute: () => Promise.resolve({}), + } as unknown as Tool; + + const sanitized = sanitizeToolSchemaForOpenAI(mcpTool); + const schema = getInputSchema(sanitized); + + // Unsupported properties should be stripped + expect(schema.properties.content).toEqual({ type: "string" }); + expect(schema.properties.count).toEqual({ type: "number" }); + // Supported properties should be preserved + expect(schema.type).toBe("object"); + expect(schema.required).toEqual(["content"]); + }); + + it("should not mutate the original MCP tool inputSchema", () => { + const jsonSchema = { + type: "object", + properties: { + content: { type: "string", minLength: 1 }, + }, + }; + + const mcpTool = { + type: "dynamic", + description: "MCP test tool", + inputSchema: { + get jsonSchema() { + return jsonSchema; + }, + }, + execute: () => Promise.resolve({}), + } as unknown as Tool; + + sanitizeToolSchemaForOpenAI(mcpTool); + + // Original should still have minLength + expect(jsonSchema.properties.content.minLength).toBe(1); + }); + }); + + describe("sanitizeMCPToolsForOpenAI", () => { + it("should sanitize all tools in a record", () => { + const tools = { + tool1: { + description: "Tool 1", + parameters: { + type: "object", + properties: { + content: { type: "string", minLength: 1 }, + }, + }, + }, + tool2: { + description: "Tool 2", + parameters: { + type: "object", + properties: { + count: { type: "number", minimum: 0 }, + }, + }, + }, + } as unknown as Record; + + const sanitized = sanitizeMCPToolsForOpenAI(tools); + + expect(getParams(sanitized.tool1).properties.content).toEqual({ + type: "string", + }); + expect(getParams(sanitized.tool2).properties.count).toEqual({ + type: "number", + }); + }); + }); +}); diff --git a/src/common/utils/tools/schemaSanitizer.ts b/src/common/utils/tools/schemaSanitizer.ts new file mode 100644 index 0000000000..a5a7ad2c79 --- /dev/null +++ b/src/common/utils/tools/schemaSanitizer.ts @@ -0,0 +1,181 @@ +import { type Tool } from "ai"; + +/** + * JSON Schema properties that are not permitted by OpenAI's Responses API. + * + * OpenAI's Structured Outputs has stricter JSON Schema validation than other providers. + * MCP tools may have schemas with these properties which work fine with Anthropic + * but fail with OpenAI. We strip these properties to ensure compatibility. + * + * @see https://platform.openai.com/docs/guides/structured-outputs + * @see https://github.com/vercel/ai/discussions/5164 + */ +const OPENAI_UNSUPPORTED_SCHEMA_PROPERTIES = new Set([ + // String validation + "minLength", + "maxLength", + "pattern", + "format", + // Number validation + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + // Array validation + "minItems", + "maxItems", + "uniqueItems", + // Object validation + "minProperties", + "maxProperties", + // General + "default", + "examples", + "deprecated", + "readOnly", + "writeOnly", + // Composition (partially supported - strip from items/properties) + // Note: oneOf/anyOf at root level may work, but not in nested contexts +]); + +/** + * Recursively strip unsupported schema properties for OpenAI compatibility. + * This mutates the schema in place for efficiency. + */ +function stripUnsupportedProperties(schema: unknown): void { + if (typeof schema !== "object" || schema === null) { + return; + } + + const obj = schema as Record; + + // Remove unsupported properties at this level + for (const prop of OPENAI_UNSUPPORTED_SCHEMA_PROPERTIES) { + if (prop in obj) { + delete obj[prop]; + } + } + + // Recursively process nested schemas + if (obj.properties && typeof obj.properties === "object") { + for (const propSchema of Object.values(obj.properties as Record)) { + stripUnsupportedProperties(propSchema); + } + } + + if (obj.items) { + if (Array.isArray(obj.items)) { + for (const itemSchema of obj.items) { + stripUnsupportedProperties(itemSchema); + } + } else { + stripUnsupportedProperties(obj.items); + } + } + + if (obj.additionalProperties && typeof obj.additionalProperties === "object") { + stripUnsupportedProperties(obj.additionalProperties); + } + + // Handle anyOf/oneOf/allOf + for (const keyword of ["anyOf", "oneOf", "allOf"]) { + if (Array.isArray(obj[keyword])) { + for (const subSchema of obj[keyword] as unknown[]) { + stripUnsupportedProperties(subSchema); + } + } + } + + // Handle definitions/defs (JSON Schema draft-07 and later) + for (const defsKey of ["definitions", "$defs"]) { + if (obj[defsKey] && typeof obj[defsKey] === "object") { + for (const defSchema of Object.values(obj[defsKey] as Record)) { + stripUnsupportedProperties(defSchema); + } + } + } +} + +/** + * Sanitize a tool's parameter schema for OpenAI Responses API compatibility. + * + * OpenAI's Responses API has stricter JSON Schema validation than other providers. + * This function creates a new tool with sanitized parameters that strips + * unsupported schema properties like minLength, maximum, default, etc. + * + * Tools can have schemas in two places: + * - `parameters`: Used by tools created with ai SDK's `tool()` function + * - `inputSchema`: Used by MCP tools created with `dynamicTool()` from @ai-sdk/mcp + * + * @param tool - The original tool to sanitize + * @returns A new tool with sanitized parameter schema + */ +export function sanitizeToolSchemaForOpenAI(tool: Tool): Tool { + // Access tool internals - the AI SDK tool structure varies: + // - Regular tools have `parameters` (Zod schema) + // - MCP/dynamic tools have `inputSchema` (JSON Schema wrapper with getter) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const toolRecord = tool as any as Record; + + // Check for inputSchema first (MCP tools use this) + // The inputSchema is a wrapper object with a jsonSchema getter + if (toolRecord.inputSchema && typeof toolRecord.inputSchema === "object") { + const inputSchemaWrapper = toolRecord.inputSchema as Record; + + // Get the actual JSON Schema - it's exposed via a getter + const rawJsonSchema = inputSchemaWrapper.jsonSchema; + if (rawJsonSchema && typeof rawJsonSchema === "object") { + // Deep clone and sanitize + const clonedSchema = JSON.parse(JSON.stringify(rawJsonSchema)) as Record; + stripUnsupportedProperties(clonedSchema); + + // Create a new inputSchema wrapper that returns our sanitized schema + const sanitizedInputSchema = { + ...inputSchemaWrapper, + // Override the jsonSchema getter with our sanitized version + get jsonSchema() { + return clonedSchema; + }, + }; + + return { + ...tool, + inputSchema: sanitizedInputSchema, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any as Tool; + } + } + + // Fall back to parameters (regular AI SDK tools) + if (!toolRecord.parameters) { + return tool; + } + + // Deep clone the parameters to avoid mutating the original + const clonedParams = JSON.parse(JSON.stringify(toolRecord.parameters)) as unknown; + + // Strip unsupported properties + stripUnsupportedProperties(clonedParams); + + // Create a new tool with sanitized parameters + return { + ...tool, + parameters: clonedParams, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any as Tool; +} + +/** + * Sanitize all MCP tools for OpenAI compatibility. + * + * @param mcpTools - Record of MCP tools to sanitize + * @returns Record of sanitized tools + */ +export function sanitizeMCPToolsForOpenAI(mcpTools: Record): Record { + const sanitized: Record = {}; + for (const [name, tool] of Object.entries(mcpTools)) { + sanitized[name] = sanitizeToolSchemaForOpenAI(tool); + } + return sanitized; +} diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index e3fb536adb..55b0d8f887 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -13,6 +13,7 @@ import { createTodoWriteTool, createTodoReadTool } from "@/node/services/tools/t import { createStatusSetTool } from "@/node/services/tools/status_set"; import { wrapWithInitWait } from "@/node/services/tools/wrapWithInitWait"; import { log } from "@/node/services/log"; +import { sanitizeMCPToolsForOpenAI } from "@/common/utils/tools/schemaSanitizer"; import type { Runtime } from "@/node/runtime/Runtime"; import type { InitStateManager } from "@/node/services/initStateManager"; @@ -157,17 +158,29 @@ export async function getToolsForModel( } case "openai": { + // Sanitize MCP tools for OpenAI's stricter JSON Schema validation. + // OpenAI's Responses API doesn't support certain schema properties like + // minLength, maximum, default, etc. that are valid JSON Schema but not + // accepted by OpenAI's Structured Outputs implementation. + const sanitizedMcpTools = mcpTools ? sanitizeMCPToolsForOpenAI(mcpTools) : {}; + // Only add web search for models that support it if (modelId.includes("gpt-5") || modelId.includes("gpt-4")) { const { openai } = await import("@ai-sdk/openai"); allTools = { ...baseTools, - ...(mcpTools ?? {}), + ...sanitizedMcpTools, // Provider-specific tool types are compatible with Tool at runtime web_search: openai.tools.webSearch({ searchContextSize: "high", }) as Tool, }; + } else { + // For other OpenAI models (o1, o3, etc.), still use sanitized MCP tools + allTools = { + ...baseTools, + ...sanitizedMcpTools, + }; } break; }