Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion src/lib/tool-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
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<string, unknown>, returns Record<string, unknown>.
* When given unknown, returns unknown (preserves primitives, null, etc).
*/
export function sanitizeJsonSchema(obj: Record<string, unknown>): Record<string, unknown>;
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<string, unknown> = {};
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.
Expand All @@ -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<string, unknown> {
if (!isZodSchema(zodSchema)) {
Expand All @@ -40,7 +84,9 @@ export function convertZodToJsonSchema(zodSchema: ZodType): Record<string, unkno
const jsonSchema = z4.toJSONSchema(zodSchema, {
target: 'draft-7',
});
return jsonSchema;
// jsonSchema is always a Record<string, unknown> from toJSONSchema
// The overloaded sanitizeJsonSchema preserves this type
return sanitizeJsonSchema(jsonSchema);
}

/**
Expand Down
20 changes: 20 additions & 0 deletions tests/e2e/call-model-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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', () => {
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/call-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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', () => {
Expand Down
130 changes: 130 additions & 0 deletions tests/unit/schema-sanitization.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
const properties = typedResult['properties'] as Record<string, unknown>;
const user = properties['user'] as Record<string, unknown>;
const userProperties = user['properties'] as Record<string, unknown>;
const name = userProperties['name'] as Record<string, unknown>;

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<string, unknown>;
const items = typedResult['items'] as Array<Record<string, unknown>>;

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<string, unknown>;
const location = properties['location'] as Record<string, unknown>;

expect(location).toHaveProperty('description', 'City and country');
expect(location).toHaveProperty('type', 'string');
});
});
24 changes: 24 additions & 0 deletions tests/utils/schema-test-helpers.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)[key]);
}
}