diff --git a/src/lib/tool-executor.ts b/src/lib/tool-executor.ts index 53a57bb2..980271a0 100644 --- a/src/lib/tool-executor.ts +++ b/src/lib/tool-executor.ts @@ -13,6 +13,48 @@ import { hasExecuteFunction, isGeneratorTool, isRegularExecuteTool } from './too // Re-export ZodError for convenience export const ZodError = z4.ZodError; +/** + * Typeguard to check if a value is a non-null object (not an array). + */ +function isNonNullObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Recursively remove keys prefixed with ~ from an object. + * These are metadata properties (like ~standard from Standard Schema) + * that should not be sent to downstream providers. + * @see https://github.com/OpenRouterTeam/typescript-sdk/issues/131 + * + * When given a Record, returns Record. + * When given unknown, returns unknown (preserves primitives, null, etc). + */ +export function sanitizeJsonSchema(obj: Record): Record; +export function sanitizeJsonSchema(obj: unknown): unknown; +export function sanitizeJsonSchema(obj: unknown): unknown { + if (obj === null || typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(sanitizeJsonSchema); + } + + // At this point, obj is a non-null, non-array object + // Use typeguard to narrow the type for type-safe property access + if (!isNonNullObject(obj)) { + return obj; + } + + const result: Record = {}; + for (const key of Object.keys(obj)) { + if (!key.startsWith('~')) { + result[key] = sanitizeJsonSchema(obj[key]); + } + } + return result; +} + /** * Typeguard to check if a value is a valid Zod schema compatible with zod/v4. * Zod schemas have a _zod property that contains schema metadata. @@ -31,6 +73,8 @@ function isZodSchema(value: unknown): value is z4.ZodType { /** * Convert a Zod schema to JSON Schema using Zod v4's toJSONSchema function. * Accepts ZodType from the main zod package for user compatibility. + * The resulting schema is sanitized to remove metadata properties (like ~standard) + * that would cause 400 errors with downstream providers. */ export function convertZodToJsonSchema(zodSchema: ZodType): Record { if (!isZodSchema(zodSchema)) { @@ -40,7 +84,9 @@ export function convertZodToJsonSchema(zodSchema: ZodType): Record from toJSONSchema + // The overloaded sanitizeJsonSchema preserves this type + return sanitizeJsonSchema(jsonSchema); } /** diff --git a/tests/e2e/call-model-tools.test.ts b/tests/e2e/call-model-tools.test.ts index 47f605ec..9a61191c 100644 --- a/tests/e2e/call-model-tools.test.ts +++ b/tests/e2e/call-model-tools.test.ts @@ -2,6 +2,8 @@ import * as dotenv from 'dotenv'; import { beforeAll, describe, expect, it } from 'vitest'; import { toJSONSchema, z } from 'zod/v4'; import { OpenRouter, ToolType, toChatMessage, stepCountIs } from '../../src/index.js'; +import { convertZodToJsonSchema } from '../../src/lib/tool-executor.js'; +import { assertNoTildeKeys } from '../utils/schema-test-helpers.js'; dotenv.config(); @@ -68,6 +70,24 @@ describe('Enhanced Tool Support for callModel', () => { 'City and country e.g. Bogotá, Colombia', ); }); + + it('should sanitize ~ prefixed metadata properties from Zod v4 schemas (fixes #131)', () => { + // Zod v4's toJSONSchema may include ~standard or other ~ prefixed properties + // that cause 400 errors with downstream providers + const schema = z.object({ + name: z.string(), + age: z.number(), + }); + + const jsonSchema = convertZodToJsonSchema(schema); + + // Recursively check that no ~ prefixed keys exist + assertNoTildeKeys(jsonSchema); + + // Verify schema is still valid + expect(jsonSchema).toHaveProperty('type', 'object'); + expect(jsonSchema).toHaveProperty('properties'); + }); }); describe('Tool Definition', () => { diff --git a/tests/e2e/call-model.test.ts b/tests/e2e/call-model.test.ts index ef1ad7ca..c4466fbf 100644 --- a/tests/e2e/call-model.test.ts +++ b/tests/e2e/call-model.test.ts @@ -1024,7 +1024,7 @@ describe('callModel E2E Tests', () => { it('should include tool.preliminary_result events with correct shape when generator tools are executed', async () => { const response = client.callModel({ - model: 'openai/gpt-4o-mini', + model: 'anthropic/claude-haiku-4.5', input: fromChatMessages([ { role: 'user', @@ -1108,7 +1108,7 @@ describe('callModel E2E Tests', () => { // The stream should complete without errors regardless of tool execution expect(true).toBe(true); - }, 45000); + }, 90000); }); describe('Multiple concurrent consumption patterns', () => { diff --git a/tests/unit/schema-sanitization.test.ts b/tests/unit/schema-sanitization.test.ts new file mode 100644 index 00000000..57e68068 --- /dev/null +++ b/tests/unit/schema-sanitization.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest'; +import { sanitizeJsonSchema, convertZodToJsonSchema } from '../../src/lib/tool-executor.js'; +import { z } from 'zod/v4'; +import { assertNoTildeKeys } from '../utils/schema-test-helpers.js'; + +describe('sanitizeJsonSchema', () => { + it('should remove ~standard property from root level', () => { + const input = { + type: 'object', + properties: { name: { type: 'string' } }, + '~standard': { version: 1, vendor: 'zod' }, + }; + + const result = sanitizeJsonSchema(input); + + expect(result).not.toHaveProperty('~standard'); + expect(result).toHaveProperty('type', 'object'); + expect(result).toHaveProperty('properties'); + }); + + it('should remove all ~ prefixed properties from nested objects', () => { + const input = { + type: 'object', + '~metadata': { foo: 'bar' }, + properties: { + user: { + type: 'object', + '~internal': true, + properties: { + name: { type: 'string', '~validator': 'custom' }, + }, + }, + }, + }; + + const result = sanitizeJsonSchema(input); + const typedResult = result as Record; + const properties = typedResult['properties'] as Record; + const user = properties['user'] as Record; + const userProperties = user['properties'] as Record; + const name = userProperties['name'] as Record; + + expect(typedResult).not.toHaveProperty('~metadata'); + expect(user).not.toHaveProperty('~internal'); + expect(name).not.toHaveProperty('~validator'); + expect(name).toHaveProperty('type', 'string'); + }); + + it('should handle arrays correctly', () => { + const input = { + type: 'array', + '~arrayMeta': 'remove-me', + items: [ + { type: 'string', '~itemMeta': 'also-remove' }, + { type: 'number' }, + ], + }; + + const result = sanitizeJsonSchema(input); + const typedResult = result as Record; + const items = typedResult['items'] as Array>; + + expect(typedResult).not.toHaveProperty('~arrayMeta'); + expect(items[0]).not.toHaveProperty('~itemMeta'); + expect(items[0]).toHaveProperty('type', 'string'); + expect(items[1]).toHaveProperty('type', 'number'); + }); + + it('should pass through primitive values unchanged', () => { + expect(sanitizeJsonSchema(null)).toBe(null); + expect(sanitizeJsonSchema(undefined)).toBe(undefined); + expect(sanitizeJsonSchema(42)).toBe(42); + expect(sanitizeJsonSchema('string')).toBe('string'); + expect(sanitizeJsonSchema(true)).toBe(true); + }); + + it('should handle empty objects', () => { + expect(sanitizeJsonSchema({})).toEqual({}); + }); + + it('should handle empty arrays', () => { + expect(sanitizeJsonSchema([])).toEqual([]); + }); + + it('should preserve non-~ prefixed properties', () => { + const input = { + type: 'object', + description: 'A user object', + required: ['name'], + properties: { + name: { type: 'string' }, + }, + }; + + const result = sanitizeJsonSchema(input); + + expect(result).toEqual(input); + }); +}); + +describe('convertZodToJsonSchema', () => { + it('should return sanitized JSON schema without ~ prefixed properties', () => { + const schema = z.object({ + name: z.string().describe("The user's name"), + age: z.number().min(0).describe("The user's age"), + }); + + const jsonSchema = convertZodToJsonSchema(schema); + + // Check that standard schema properties are present + expect(jsonSchema).toHaveProperty('type', 'object'); + expect(jsonSchema).toHaveProperty('properties'); + + // Check that no ~ prefixed keys exist anywhere in the schema + assertNoTildeKeys(jsonSchema); + }); + + it('should preserve all valid JSON Schema properties', () => { + const schema = z.object({ + location: z.string().describe('City and country'), + }); + + const jsonSchema = convertZodToJsonSchema(schema); + const properties = jsonSchema['properties'] as Record; + const location = properties['location'] as Record; + + expect(location).toHaveProperty('description', 'City and country'); + expect(location).toHaveProperty('type', 'string'); + }); +}); diff --git a/tests/utils/schema-test-helpers.ts b/tests/utils/schema-test-helpers.ts new file mode 100644 index 00000000..dbc88129 --- /dev/null +++ b/tests/utils/schema-test-helpers.ts @@ -0,0 +1,24 @@ +import { expect } from 'vitest'; + +/** + * Recursively checks that no keys prefixed with ~ exist in the given object. + * Used to verify that JSON schemas have been properly sanitized. + * @throws AssertionError if a ~ prefixed key is found + */ +export function assertNoTildeKeys(obj: unknown): void { + if (obj === null || typeof obj !== 'object') { + return; + } + + if (Array.isArray(obj)) { + for (const item of obj) { + assertNoTildeKeys(item); + } + return; + } + + for (const key of Object.keys(obj)) { + expect(key.startsWith('~')).toBe(false); + assertNoTildeKeys((obj as Record)[key]); + } +}