From 0c88dea24732d400b7ea18651116e83eccbf2ef3 Mon Sep 17 00:00:00 2001 From: skfallin Date: Thu, 2 Apr 2026 13:50:47 +0200 Subject: [PATCH] Strip incompatible JSON Schema keywords from tool schemas --- src/services/api/openaiShim.ts | 8 ++-- src/utils/api.test.ts | 67 ++++++++++++++++++++++++++++++++++ src/utils/api.ts | 2 + src/utils/schemaSanitizer.ts | 30 +++++++++++++++ 4 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 src/utils/api.test.ts create mode 100644 src/utils/schemaSanitizer.ts diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 32ebc668..2f06d312 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -29,6 +29,7 @@ import { resolveCodexApiCredentials, resolveProviderRequest, } from './providerConfig.js' +import { stripIncompatibleSchemaKeywords } from '../../utils/schemaSanitizer.js' // --------------------------------------------------------------------------- // Types — minimal subset of Anthropic SDK types we need to produce @@ -235,11 +236,12 @@ function normalizeSchemaForOpenAI( schema: Record, strict = true, ): Record { - if (!schema || typeof schema !== 'object' || Array.isArray(schema)) { - return (schema ?? {}) as Record + const sanitizedSchema = stripIncompatibleSchemaKeywords(schema) + if (!sanitizedSchema || typeof sanitizedSchema !== 'object' || Array.isArray(sanitizedSchema)) { + return (sanitizedSchema ?? {}) as Record } - const record = { ...schema } + const record = { ...sanitizedSchema } if (record.type === 'object' && record.properties) { const properties = record.properties as Record> diff --git a/src/utils/api.test.ts b/src/utils/api.test.ts new file mode 100644 index 00000000..d2d0e380 --- /dev/null +++ b/src/utils/api.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from 'bun:test' +import { z } from 'zod/v4' +import { getEmptyToolPermissionContext, type Tool, type Tools } from '../Tool.js' +import { toolToAPISchema } from './api.js' + +test('toolToAPISchema strips incompatible schema keywords from input_schema', async () => { + const schema = await toolToAPISchema( + { + name: 'WebFetch', + inputSchema: z.strictObject({}), + inputJSONSchema: { + type: 'object', + properties: { + url: { + type: 'string', + format: 'uri', + description: 'Public HTTP or HTTPS URL', + }, + metadata: { + type: 'object', + properties: { + callback: { + type: 'string', + format: 'uri-reference', + }, + }, + }, + }, + }, + prompt: async () => 'Fetch a URL', + } as unknown as Tool, + { + getToolPermissionContext: async () => getEmptyToolPermissionContext(), + tools: [] as unknown as Tools, + agents: [], + }, + ) + + expect(schema).toMatchObject({ + input_schema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'Public HTTP or HTTPS URL', + }, + metadata: { + type: 'object', + properties: { + callback: { + type: 'string', + }, + }, + }, + }, + }, + }) + + const inputSchema = (schema as { input_schema: Record }).input_schema + const properties = inputSchema.properties as Record> + expect(properties.url?.format).toBeUndefined() + expect( + ( + properties.metadata?.properties as Record> + )?.callback?.format, + ).toBeUndefined() +}) diff --git a/src/utils/api.ts b/src/utils/api.ts index 9b66fd79..a18866e6 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -60,6 +60,7 @@ import { import { getPlatform } from './platform.js' import { countFilesRoundedRg } from './ripgrep.js' import { jsonStringify } from './slowOperations.js' +import { stripIncompatibleSchemaKeywords } from './schemaSanitizer.js' import type { SystemPrompt } from './systemPromptType.js' import { getToolSchemaCache } from './toolSchemaCache.js' import { windowsPathToPosixPath } from './windowsPaths.js' @@ -165,6 +166,7 @@ export async function toolToAPISchema( if (!isAgentSwarmsEnabled()) { input_schema = filterSwarmFieldsFromSchema(tool.name, input_schema) } + input_schema = stripIncompatibleSchemaKeywords(input_schema) base = { name: tool.name, diff --git a/src/utils/schemaSanitizer.ts b/src/utils/schemaSanitizer.ts new file mode 100644 index 00000000..6cc066d8 --- /dev/null +++ b/src/utils/schemaSanitizer.ts @@ -0,0 +1,30 @@ +/** + * Anthropic-compatible tool schemas reject several JSON Schema keywords that + * Zod commonly emits, especially string `format` validators like `uri`. + * Strip those fields recursively before sending tool schemas to providers. + */ +export function stripIncompatibleSchemaKeywords( + schema: T, +): T { + if (Array.isArray(schema)) { + return schema.map(item => stripIncompatibleSchemaKeywords(item)) as T + } + + if (!schema || typeof schema !== 'object') { + return schema + } + + const result: Record = {} + for (const [key, value] of Object.entries(schema as Record)) { + if (key === '$schema' || key === 'format' || key === 'propertyNames') { + continue + } + + result[key] = + value && typeof value === 'object' + ? stripIncompatibleSchemaKeywords(value) + : value + } + + return result as T +}