From 81fa1dd8908f7c52c6d20a74e5ebb0ac945468f0 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 16 Dec 2025 17:21:49 -0500 Subject: [PATCH 1/8] feat: add typed tool creation helpers and type inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tool() function for creating tools with full type inference from Zod schemas - Auto-detect tool type based on configuration (generator, regular, or manual) - Add type inference helpers: InferToolInput, InferToolOutput, InferToolEvent - Add TypedToolCall and TypedToolCallUnion for typed tool call handling - Add generic type parameters to event types for typed streaming - Export all tool types and helpers from main index - Add comprehensive unit tests for create-tool - Add typed-tool-calling example demonstrating the new API 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../callModel-typed-tool-calling.example.ts | 169 ++++++++++ src/index.ts | 30 ++ src/lib/create-tool.ts | 312 ++++++++++++++++++ src/lib/tool-types.ts | 78 ++++- tests/unit/create-tool.test.ts | 224 +++++++++++++ 5 files changed, 803 insertions(+), 10 deletions(-) create mode 100644 examples/callModel-typed-tool-calling.example.ts create mode 100644 src/lib/create-tool.ts create mode 100644 tests/unit/create-tool.test.ts diff --git a/examples/callModel-typed-tool-calling.example.ts b/examples/callModel-typed-tool-calling.example.ts new file mode 100644 index 00000000..2f383ac2 --- /dev/null +++ b/examples/callModel-typed-tool-calling.example.ts @@ -0,0 +1,169 @@ +/* + * Example: Typed Tool Calling with callModel + * + * This example demonstrates how to use the tool() function for + * fully-typed tool definitions where execute params, return types, and event + * types are automatically inferred from Zod schemas. + * + * Tool types are auto-detected based on configuration: + * - Generator tool: When `eventSchema` is provided + * - Regular tool: When `execute` is a function (no `eventSchema`) + * - Manual tool: When `execute: false` is set + * + * To run this example from the examples directory: + * npm run build && npx tsx callModel-typed-tool-calling.example.ts + */ + +import dotenv from "dotenv"; +dotenv.config(); + +import { OpenRouter, tool } from "../src/index.js"; +import z from "zod"; + +const openRouter = new OpenRouter({ + apiKey: process.env["OPENROUTER_API_KEY"] ?? "", +}); + +// Create a typed regular tool using tool() +// The execute function params are automatically typed as z.infer +// The return type is enforced based on outputSchema +const weatherTool = tool({ + name: "get_weather", + description: "Get the current weather for a location", + inputSchema: z.object({ + location: z.string().describe("The city and country, e.g. San Francisco, CA"), + }), + outputSchema: z.object({ + temperature: z.number(), + description: z.string(), + }), + // params is automatically typed as { location: string } + execute: async (params) => { + console.log(`Getting weather for: ${params.location}`); + // Return type is enforced as { temperature: number; description: string } + return { + temperature: 20, + description: "Sunny", + }; + }, +}); + +// Create a generator tool with typed progress events by providing eventSchema +// The eventSchema triggers generator mode - execute becomes an async generator +const searchTool = tool({ + name: "search_database", + description: "Search database with progress updates", + inputSchema: z.object({ + query: z.string().describe("The search query"), + }), + eventSchema: z.object({ + progress: z.number(), + message: z.string(), + }), + outputSchema: z.object({ + results: z.array(z.string()), + totalFound: z.number(), + }), + // execute is a generator that yields typed progress events + execute: async function* (params) { + console.log(`Searching for: ${params.query}`); + // Each yield is typed as { progress: number; message: string } + yield { progress: 25, message: "Searching..." }; + yield { progress: 50, message: "Processing results..." }; + yield { progress: 75, message: "Almost done..." }; + // Final result is typed as { results: string[]; totalFound: number } + yield { progress: 100, message: "Complete!" }; + }, +}); + +async function main() { + console.log("=== Typed Tool Calling Example ===\n"); + + // Use 'as const' to enable full type inference for tool calls + const result = openRouter.callModel({ + instructions: "You are a helpful assistant. Your name is Mark", + model: "openai/gpt-4o-mini", + input: "Hello! What is the weather in San Francisco?", + tools: [weatherTool] as const, + }); + + // Get text response (tools are auto-executed) + const text = await result.getText(); + console.log("Response:", text); + + console.log("\n=== Getting Tool Calls ===\n"); + + // Create a fresh request for demonstrating getToolCalls + const result2 = openRouter.callModel({ + model: "openai/gpt-4o-mini", + input: "What's the weather like in Paris?", + tools: [weatherTool] as const, + maxToolRounds: 0, // Don't auto-execute, just get the tool calls + }); + + // Tool calls are now typed based on the tool definitions! + const toolCalls = await result2.getToolCalls(); + + for (const toolCall of toolCalls) { + console.log(`Tool: ${toolCall.name}`); + // toolCall.arguments is typed as { location: string } + console.log(`Arguments:`, toolCall.arguments); + } + + console.log("\n=== Streaming Tool Calls ===\n"); + + // Create another request for demonstrating streaming + const result3 = openRouter.callModel({ + model: "openai/gpt-4o-mini", + input: "What's the weather in Tokyo?", + tools: [weatherTool] as const, + maxToolRounds: 0, + }); + + // Stream tool calls with typed arguments + for await (const toolCall of result3.getToolCallsStream()) { + console.log(`Streamed tool: ${toolCall.name}`); + // toolCall.arguments is typed based on tool definitions + console.log(`Streamed arguments:`, toolCall.arguments); + } + + console.log("\n=== Generator Tool with Typed Events ===\n"); + + // Use generator tool with typed progress events + const result4 = openRouter.callModel({ + model: "openai/gpt-4o-mini", + input: "Search for documents about TypeScript", + tools: [searchTool] as const, + }); + + // Stream events from getToolStream - events are fully typed! + for await (const event of result4.getToolStream()) { + if (event.type === "preliminary_result") { + // event.result is typed as { progress: number; message: string } + console.log(`Progress: ${event.result.progress}% - ${event.result.message}`); + } else if (event.type === "delta") { + // Tool argument deltas + process.stdout.write(event.content); + } + } + + console.log("\n=== Mixed Tools with Typed Events ===\n"); + + // Use both regular and generator tools together + const result5 = openRouter.callModel({ + model: "openai/gpt-4o-mini", + input: "First search for weather data, then get the weather in Seattle", + tools: [weatherTool, searchTool] as const, + }); + + // Events are a union of all generator tool event types + for await (const event of result5.getToolStream()) { + if (event.type === "preliminary_result") { + // event.result is typed as { progress: number; message: string } + // (only searchTool has eventSchema, so that's the event type) + console.log(`Event:`, event.result); + } + } +} + +main().catch(console.error); diff --git a/src/index.ts b/src/index.ts index 5d07d4e8..9855efd7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,3 +11,33 @@ export * from "./sdk/sdk.js"; // Message format compatibility helpers export { fromClaudeMessages, toClaudeMessage } from "./lib/anthropic-compat.js"; export { fromChatMessages, toChatMessage } from "./lib/chat-compat.js"; + +// Tool creation helpers +export { tool, createTool, createGeneratorTool, createManualTool } from "./lib/create-tool.js"; + +// Tool types +export type { + Tool, + ToolWithExecute, + ToolWithGenerator, + ManualTool, + TurnContext, + InferToolInput, + InferToolOutput, + InferToolEvent, + InferToolEventsUnion, + TypedToolCall, + TypedToolCallUnion, + ToolStreamEvent, + ChatStreamEvent, + EnhancedResponseStreamEvent, + ToolPreliminaryResultEvent, +} from "./lib/tool-types.js"; + +export { + ToolType, + hasExecuteFunction, + isGeneratorTool, + isRegularExecuteTool, + isToolPreliminaryResultEvent, +} from "./lib/tool-types.js"; diff --git a/src/lib/create-tool.ts b/src/lib/create-tool.ts new file mode 100644 index 00000000..688a4cc9 --- /dev/null +++ b/src/lib/create-tool.ts @@ -0,0 +1,312 @@ +import type { ZodObject, ZodRawShape, ZodType, z } from "zod/v4"; +import { + ToolType, + type TurnContext, + type ToolWithExecute, + type ToolWithGenerator, + type ManualTool, +} from "./tool-types.js"; + +/** + * Configuration for a regular tool (without eventSchema) + */ +type RegularToolConfig< + TInput extends ZodObject, + TOutput extends ZodType = ZodType, +> = { + name: string; + description?: string; + inputSchema: TInput; + outputSchema?: TOutput; + eventSchema?: undefined; + execute: ( + params: z.infer, + context?: TurnContext + ) => Promise> | z.infer; +}; + +/** + * Configuration for a generator tool (with eventSchema) + */ +type GeneratorToolConfig< + TInput extends ZodObject, + TEvent extends ZodType, + TOutput extends ZodType, +> = { + name: string; + description?: string; + inputSchema: TInput; + eventSchema: TEvent; + outputSchema: TOutput; + execute: ( + params: z.infer, + context?: TurnContext + ) => AsyncGenerator | z.infer>; +}; + +/** + * Configuration for a manual tool (execute: false, no eventSchema or outputSchema) + */ +type ManualToolConfig> = { + name: string; + description?: string; + inputSchema: TInput; + execute: false; +}; + +/** + * Type guard to check if config is a generator tool config (has eventSchema) + */ +function isGeneratorConfig< + TInput extends ZodObject, + TEvent extends ZodType, + TOutput extends ZodType, +>( + config: + | GeneratorToolConfig + | RegularToolConfig + | ManualToolConfig +): config is GeneratorToolConfig { + return "eventSchema" in config && config.eventSchema !== undefined; +} + +/** + * Type guard to check if config is a manual tool config (execute === false) + */ +function isManualConfig>( + config: + | GeneratorToolConfig + | RegularToolConfig + | ManualToolConfig +): config is ManualToolConfig { + return config.execute === false; +} + +/** + * Creates a tool with full type inference from Zod schemas. + * + * The tool type is automatically determined based on the configuration: + * - **Generator tool**: When `eventSchema` is provided + * - **Regular tool**: When `execute` is a function (no `eventSchema`) + * - **Manual tool**: When `execute: false` is set + * + * @example Regular tool: + * ```typescript + * const weatherTool = tool({ + * name: "get_weather", + * description: "Get weather for a location", + * inputSchema: z.object({ location: z.string() }), + * outputSchema: z.object({ temperature: z.number() }), + * execute: async (params) => { + * // params is typed as { location: string } + * return { temperature: 72 }; // return type is enforced + * }, + * }); + * ``` + * + * @example Generator tool (with eventSchema): + * ```typescript + * const progressTool = tool({ + * name: "process_data", + * inputSchema: z.object({ data: z.string() }), + * eventSchema: z.object({ progress: z.number() }), + * outputSchema: z.object({ result: z.string() }), + * execute: async function* (params) { + * yield { progress: 50 }; // typed as event + * yield { result: "done" }; // typed as output + * }, + * }); + * ``` + * + * @example Manual tool (execute: false): + * ```typescript + * const manualTool = tool({ + * name: "external_action", + * inputSchema: z.object({ action: z.string() }), + * execute: false, + * }); + * ``` + */ +// Overload for generator tools (when eventSchema is provided) +export function tool< + TInput extends ZodObject, + TEvent extends ZodType, + TOutput extends ZodType, +>( + config: GeneratorToolConfig +): ToolWithGenerator; + +// Overload for manual tools (execute: false) +export function tool>( + config: ManualToolConfig +): ManualTool; + +// Overload for regular tools (no eventSchema) +export function tool< + TInput extends ZodObject, + TOutput extends ZodType = ZodType, +>(config: RegularToolConfig): ToolWithExecute; + +// Implementation +export function tool< + TInput extends ZodObject, + TEvent extends ZodType, + TOutput extends ZodType, +>( + config: + | GeneratorToolConfig + | RegularToolConfig + | ManualToolConfig +): + | ToolWithGenerator + | ToolWithExecute + | ManualTool { + // Check for manual tool first (execute === false) + if (isManualConfig(config)) { + const fn: ManualTool["function"] = { + name: config.name, + inputSchema: config.inputSchema, + }; + + if (config.description !== undefined) { + fn.description = config.description; + } + + return { + type: ToolType.Function, + function: fn, + }; + } + + // Check for generator tool (has eventSchema) + if (isGeneratorConfig(config)) { + const fn: ToolWithGenerator["function"] = { + name: config.name, + inputSchema: config.inputSchema, + eventSchema: config.eventSchema, + outputSchema: config.outputSchema, + // The config execute allows yielding both events and output, + // but the interface only types for events (output is extracted separately) + execute: config.execute as ToolWithGenerator< + TInput, + TEvent, + TOutput + >["function"]["execute"], + }; + + if (config.description !== undefined) { + fn.description = config.description; + } + + return { + type: ToolType.Function, + function: fn, + }; + } + + // Regular tool (has execute function, no eventSchema) + const fn: ToolWithExecute["function"] = { + name: config.name, + inputSchema: config.inputSchema, + execute: config.execute, + }; + + if (config.description !== undefined) { + fn.description = config.description; + } + + if (config.outputSchema !== undefined) { + fn.outputSchema = config.outputSchema; + } + + return { + type: ToolType.Function, + function: fn, + }; +} + +/** + * @deprecated Use `tool()` instead. This function is kept for backwards compatibility. + */ +export const createTool = tool; + +/** + * Creates a generator tool with streaming capabilities. + * + * @deprecated Use `tool()` with `eventSchema` instead. This function is kept for backwards compatibility. + * + * @example + * ```typescript + * // Instead of createGeneratorTool, use tool with eventSchema: + * const progressTool = tool({ + * name: "process_data", + * inputSchema: z.object({ data: z.string() }), + * eventSchema: z.object({ progress: z.number() }), + * outputSchema: z.object({ result: z.string() }), + * execute: async function* (params) { + * yield { progress: 50 }; // typed as event + * yield { result: "done" }; // typed as output + * }, + * }); + * ``` + */ +export function createGeneratorTool< + TInput extends ZodObject, + TEvent extends ZodType, + TOutput extends ZodType, +>(config: { + name: string; + description?: string; + inputSchema: TInput; + eventSchema: TEvent; + outputSchema: TOutput; + execute: ( + params: z.infer, + context?: TurnContext + ) => AsyncGenerator | z.infer>; +}): ToolWithGenerator { + return tool(config); +} + +/** + * Creates a manual tool without an execute function. + * + * @deprecated Use `tool()` with `execute: false` instead. This function is kept for backwards compatibility. + * + * @example + * ```typescript + * // Instead of createManualTool, use tool with execute: false: + * const manualTool = tool({ + * name: "external_api", + * inputSchema: z.object({ query: z.string() }), + * execute: false, + * }); + * ``` + */ +export function createManualTool< + TInput extends ZodObject, + TOutput extends ZodType = ZodType, +>(config: { + name: string; + description?: string; + inputSchema: TInput; + outputSchema?: TOutput; +}): ManualTool { + const fn: ManualTool["function"] = { + name: config.name, + inputSchema: config.inputSchema, + }; + + if (config.description !== undefined) { + fn.description = config.description; + } + + if (config.outputSchema !== undefined) { + fn.outputSchema = config.outputSchema; + } + + return { + type: ToolType.Function, + function: fn, + }; +} diff --git a/src/lib/tool-types.ts b/src/lib/tool-types.ts index a384b7e4..7fc41105 100644 --- a/src/lib/tool-types.ts +++ b/src/lib/tool-types.ts @@ -128,6 +128,58 @@ export type Tool = | ToolWithGenerator, ZodType, ZodType> | ManualTool, ZodType>; +/** + * Extracts the input type from a tool definition + */ +export type InferToolInput = T extends { function: { inputSchema: infer S } } + ? S extends ZodType + ? z.infer + : unknown + : unknown; + +/** + * Extracts the output type from a tool definition + */ +export type InferToolOutput = T extends { function: { outputSchema: infer S } } + ? S extends ZodType + ? z.infer + : unknown + : unknown; + +/** + * A tool call with typed arguments based on the tool's inputSchema + */ +export type TypedToolCall = { + id: string; + name: T extends { function: { name: infer N } } ? N : string; + arguments: InferToolInput; +}; + +/** + * Union of typed tool calls for a tuple of tools + */ +export type TypedToolCallUnion = { + [K in keyof T]: T[K] extends Tool ? TypedToolCall : never; +}[number]; + +/** + * Extracts the event type from a generator tool definition + * Returns `never` for non-generator tools + */ +export type InferToolEvent = T extends { function: { eventSchema: infer S } } + ? S extends ZodType + ? z.infer + : never + : never; + +/** + * Union of event types for all generator tools in a tuple + * Filters out non-generator tools (which return `never`) + */ +export type InferToolEventsUnion = { + [K in keyof T]: T[K] extends Tool ? InferToolEvent : never; +}[number]; + /** * Type guard to check if a tool has an execute function */ @@ -207,34 +259,39 @@ export interface APITool { /** * Tool preliminary result event emitted during generator tool execution + * @template TEvent - The event type from the tool's eventSchema */ -export type ToolPreliminaryResultEvent = { +export type ToolPreliminaryResultEvent = { type: 'tool.preliminary_result'; toolCallId: string; - result: unknown; + result: TEvent; timestamp: number; }; /** * Enhanced stream event types for getFullResponsesStream * Extends OpenResponsesStreamEvent with tool preliminary results + * @template TEvent - The event type from generator tools */ -export type EnhancedResponseStreamEvent = OpenResponsesStreamEvent | ToolPreliminaryResultEvent; +export type EnhancedResponseStreamEvent = + | OpenResponsesStreamEvent + | ToolPreliminaryResultEvent; /** * Type guard to check if an event is a tool preliminary result event */ -export function isToolPreliminaryResultEvent( - event: EnhancedResponseStreamEvent, -): event is ToolPreliminaryResultEvent { +export function isToolPreliminaryResultEvent( + event: EnhancedResponseStreamEvent, +): event is ToolPreliminaryResultEvent { return event.type === 'tool.preliminary_result'; } /** * Tool stream event types for getToolStream * Includes both argument deltas and preliminary results + * @template TEvent - The event type from generator tools */ -export type ToolStreamEvent = +export type ToolStreamEvent = | { type: 'delta'; content: string; @@ -242,14 +299,15 @@ export type ToolStreamEvent = | { type: 'preliminary_result'; toolCallId: string; - result: unknown; + result: TEvent; }; /** * Chat stream event types for getFullChatStream * Includes content deltas, completion events, and tool preliminary results + * @template TEvent - The event type from generator tools */ -export type ChatStreamEvent = +export type ChatStreamEvent = | { type: 'content.delta'; delta: string; @@ -261,7 +319,7 @@ export type ChatStreamEvent = | { type: 'tool.preliminary_result'; toolCallId: string; - result: unknown; + result: TEvent; } | { type: string; diff --git a/tests/unit/create-tool.test.ts b/tests/unit/create-tool.test.ts new file mode 100644 index 00000000..830144c5 --- /dev/null +++ b/tests/unit/create-tool.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod/v4'; +import { tool, createTool, createGeneratorTool, createManualTool } from '../../src/lib/create-tool.js'; +import { ToolType } from '../../src/lib/tool-types.js'; + +describe('tool', () => { + describe('tool - regular tools', () => { + it('should create a tool with the correct structure', () => { + const testTool = tool({ + name: 'test_tool', + description: 'A test tool', + inputSchema: z.object({ + input: z.string(), + }), + execute: async (params) => { + return { result: params.input }; + }, + }); + + expect(testTool.type).toBe(ToolType.Function); + expect(testTool.function.name).toBe('test_tool'); + expect(testTool.function.description).toBe('A test tool'); + expect(testTool.function.inputSchema).toBeDefined(); + }); + + it('should infer execute params from inputSchema', async () => { + const weatherTool = tool({ + name: 'weather', + inputSchema: z.object({ + location: z.string(), + units: z.enum(['celsius', 'fahrenheit']).optional(), + }), + execute: async (params) => { + // params should be typed as { location: string; units?: 'celsius' | 'fahrenheit' } + const location: string = params.location; + const units: 'celsius' | 'fahrenheit' | undefined = params.units; + return { location, units }; + }, + }); + + const result = await weatherTool.function.execute({ location: 'NYC', units: 'fahrenheit' }); + expect(result.location).toBe('NYC'); + expect(result.units).toBe('fahrenheit'); + }); + + it('should enforce output schema return type', async () => { + const tempTool = tool({ + name: 'get_temperature', + inputSchema: z.object({ + location: z.string(), + }), + outputSchema: z.object({ + temperature: z.number(), + description: z.string(), + }), + execute: async (_params) => { + // Return type should be enforced as { temperature: number; description: string } + return { + temperature: 72, + description: 'Sunny', + }; + }, + }); + + const result = await tempTool.function.execute({ location: 'NYC' }); + expect(result.temperature).toBe(72); + expect(result.description).toBe('Sunny'); + }); + + it('should support synchronous execute functions', () => { + const syncTool = tool({ + name: 'sync_tool', + inputSchema: z.object({ + a: z.number(), + b: z.number(), + }), + execute: (params) => { + return { sum: params.a + params.b }; + }, + }); + + const result = syncTool.function.execute({ a: 5, b: 3 }); + expect(result).toEqual({ sum: 8 }); + }); + + it('should pass context to execute function', async () => { + let receivedContext: unknown; + + const contextTool = tool({ + name: 'context_tool', + inputSchema: z.object({}), + execute: async (_params, context) => { + receivedContext = context; + return {}; + }, + }); + + const mockContext = { + numberOfTurns: 3, + messageHistory: [], + model: 'test-model', + }; + + await contextTool.function.execute({}, mockContext); + expect(receivedContext).toEqual(mockContext); + }); + }); + + describe('tool - generator tools (with eventSchema)', () => { + it('should create a generator tool with the correct structure', () => { + const streamingTool = tool({ + name: 'streaming_tool', + description: 'A streaming tool', + inputSchema: z.object({ + query: z.string(), + }), + eventSchema: z.object({ + progress: z.number(), + }), + outputSchema: z.object({ + result: z.string(), + }), + execute: async function* (_params) { + yield { progress: 50 }; + yield { result: 'done' }; + }, + }); + + expect(streamingTool.type).toBe(ToolType.Function); + expect(streamingTool.function.name).toBe('streaming_tool'); + expect(streamingTool.function.eventSchema).toBeDefined(); + expect(streamingTool.function.outputSchema).toBeDefined(); + }); + + it('should yield properly typed events and output', async () => { + const progressTool = tool({ + name: 'progress_tool', + inputSchema: z.object({ + data: z.string(), + }), + eventSchema: z.object({ + status: z.string(), + progress: z.number(), + }), + outputSchema: z.object({ + completed: z.boolean(), + result: z.string(), + }), + execute: async function* (params) { + yield { status: 'started', progress: 0 }; + yield { status: 'processing', progress: 50 }; + yield { completed: true, result: `Processed: ${params.data}` }; + }, + }); + + const results: unknown[] = []; + const mockContext = { numberOfTurns: 1, messageHistory: [] }; + for await (const event of progressTool.function.execute({ data: 'test' }, mockContext)) { + results.push(event); + } + + expect(results).toHaveLength(3); + expect(results[0]).toEqual({ status: 'started', progress: 0 }); + expect(results[1]).toEqual({ status: 'processing', progress: 50 }); + expect(results[2]).toEqual({ completed: true, result: 'Processed: test' }); + }); + }); + + describe('tool - manual tools (execute: false)', () => { + it('should create a manual tool without execute function', () => { + const manualTool = tool({ + name: 'manual_tool', + description: 'A manual tool', + inputSchema: z.object({ + query: z.string(), + }), + execute: false, + }); + + expect(manualTool.type).toBe(ToolType.Function); + expect(manualTool.function.name).toBe('manual_tool'); + expect(manualTool.function).not.toHaveProperty('execute'); + }); + }); + + describe('deprecated functions - backwards compatibility', () => { + it('createTool should still work', () => { + const testTool = createTool({ + name: 'test_tool', + inputSchema: z.object({ input: z.string() }), + execute: async (params) => ({ result: params.input }), + }); + + expect(testTool.type).toBe(ToolType.Function); + expect(testTool.function.name).toBe('test_tool'); + }); + + it('createGeneratorTool should still work', () => { + const generatorTool = createGeneratorTool({ + name: 'generator_tool', + inputSchema: z.object({ query: z.string() }), + eventSchema: z.object({ progress: z.number() }), + outputSchema: z.object({ result: z.string() }), + execute: async function* () { + yield { progress: 50 }; + yield { result: 'done' }; + }, + }); + + expect(generatorTool.type).toBe(ToolType.Function); + expect(generatorTool.function.name).toBe('generator_tool'); + }); + + it('createManualTool should still work', () => { + const manualTool = createManualTool({ + name: 'manual_tool', + inputSchema: z.object({ query: z.string() }), + }); + + expect(manualTool.type).toBe(ToolType.Function); + expect(manualTool.function.name).toBe('manual_tool'); + }); + }); +}); From 0288fb10dcd62859efd75cedd75b07290ad73845 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 16 Dec 2025 17:26:30 -0500 Subject: [PATCH 2/8] fix: improve type inference for tools without outputSchema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split RegularToolConfig into two types: with and without outputSchema - Tools without outputSchema now correctly infer return type from execute function - Fixes 'result' is of type 'unknown' error in tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/lib/create-tool.ts | 62 +++++++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/src/lib/create-tool.ts b/src/lib/create-tool.ts index 688a4cc9..78fe2a50 100644 --- a/src/lib/create-tool.ts +++ b/src/lib/create-tool.ts @@ -8,16 +8,16 @@ import { } from "./tool-types.js"; /** - * Configuration for a regular tool (without eventSchema) + * Configuration for a regular tool with outputSchema */ -type RegularToolConfig< +type RegularToolConfigWithOutput< TInput extends ZodObject, - TOutput extends ZodType = ZodType, + TOutput extends ZodType, > = { name: string; description?: string; inputSchema: TInput; - outputSchema?: TOutput; + outputSchema: TOutput; eventSchema?: undefined; execute: ( params: z.infer, @@ -25,6 +25,24 @@ type RegularToolConfig< ) => Promise> | z.infer; }; +/** + * Configuration for a regular tool without outputSchema (infers return type from execute) + */ +type RegularToolConfigWithoutOutput< + TInput extends ZodObject, + TReturn, +> = { + name: string; + description?: string; + inputSchema: TInput; + outputSchema?: undefined; + eventSchema?: undefined; + execute: ( + params: z.infer, + context?: TurnContext + ) => Promise | TReturn; +}; + /** * Configuration for a generator tool (with eventSchema) */ @@ -54,6 +72,13 @@ type ManualToolConfig> = { execute: false; }; +/** + * Union type for all regular tool configs + */ +type RegularToolConfig, TOutput extends ZodType, TReturn> = + | RegularToolConfigWithOutput + | RegularToolConfigWithoutOutput; + /** * Type guard to check if config is a generator tool config (has eventSchema) */ @@ -61,10 +86,11 @@ function isGeneratorConfig< TInput extends ZodObject, TEvent extends ZodType, TOutput extends ZodType, + TReturn, >( config: | GeneratorToolConfig - | RegularToolConfig + | RegularToolConfig | ManualToolConfig ): config is GeneratorToolConfig { return "eventSchema" in config && config.eventSchema !== undefined; @@ -73,10 +99,10 @@ function isGeneratorConfig< /** * Type guard to check if config is a manual tool config (execute === false) */ -function isManualConfig>( +function isManualConfig, TOutput extends ZodType, TReturn>( config: | GeneratorToolConfig - | RegularToolConfig + | RegularToolConfig | ManualToolConfig ): config is ManualToolConfig { return config.execute === false; @@ -141,25 +167,33 @@ export function tool>( config: ManualToolConfig ): ManualTool; -// Overload for regular tools (no eventSchema) +// Overload for regular tools with outputSchema export function tool< TInput extends ZodObject, - TOutput extends ZodType = ZodType, ->(config: RegularToolConfig): ToolWithExecute; + TOutput extends ZodType, +>(config: RegularToolConfigWithOutput): ToolWithExecute; + +// Overload for regular tools without outputSchema (infers return type) +export function tool< + TInput extends ZodObject, + TReturn, +>(config: RegularToolConfigWithoutOutput): ToolWithExecute>; // Implementation export function tool< TInput extends ZodObject, TEvent extends ZodType, TOutput extends ZodType, + TReturn, >( config: | GeneratorToolConfig - | RegularToolConfig + | RegularToolConfig | ManualToolConfig ): | ToolWithGenerator | ToolWithExecute + | ToolWithExecute> | ManualTool { // Check for manual tool first (execute === false) if (isManualConfig(config)) { @@ -205,11 +239,13 @@ export function tool< } // Regular tool (has execute function, no eventSchema) - const fn: ToolWithExecute["function"] = { + // Type assertion needed because we have two overloads (with/without outputSchema) + // and the implementation needs to handle both cases + const fn = { name: config.name, inputSchema: config.inputSchema, execute: config.execute, - }; + } as ToolWithExecute["function"]; if (config.description !== undefined) { fn.description = config.description; From f45122b2dfc3c61851b2333eeec72308fb21292d Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 16 Dec 2025 17:27:29 -0500 Subject: [PATCH 3/8] refactor: rename create-tool.ts to tool.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/index.ts | 2 +- src/lib/{create-tool.ts => tool.ts} | 0 tests/unit/create-tool.test.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/lib/{create-tool.ts => tool.ts} (100%) diff --git a/src/index.ts b/src/index.ts index 9855efd7..fa596068 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ export { fromClaudeMessages, toClaudeMessage } from "./lib/anthropic-compat.js"; export { fromChatMessages, toChatMessage } from "./lib/chat-compat.js"; // Tool creation helpers -export { tool, createTool, createGeneratorTool, createManualTool } from "./lib/create-tool.js"; +export { tool, createTool, createGeneratorTool, createManualTool } from "./lib/tool.js"; // Tool types export type { diff --git a/src/lib/create-tool.ts b/src/lib/tool.ts similarity index 100% rename from src/lib/create-tool.ts rename to src/lib/tool.ts diff --git a/tests/unit/create-tool.test.ts b/tests/unit/create-tool.test.ts index 830144c5..0cde8554 100644 --- a/tests/unit/create-tool.test.ts +++ b/tests/unit/create-tool.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { z } from 'zod/v4'; -import { tool, createTool, createGeneratorTool, createManualTool } from '../../src/lib/create-tool.js'; +import { tool, createTool, createGeneratorTool, createManualTool } from '../../src/lib/tool.js'; import { ToolType } from '../../src/lib/tool-types.js'; describe('tool', () => { From 527bceb2db39cab7a3d2cac4eec24b78239b3863 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Wed, 17 Dec 2025 15:15:00 -0500 Subject: [PATCH 4/8] fix: export ClaudeMessageParam and CallModelInput types - Export all Claude message types from models/index.ts and src/index.ts - Export CallModelInput type from call-model.ts - Simplify sdk.ts callModel signature to use CallModelInput type - Remove unused imports from sdk.ts Fixes TypeScript compilation errors in CI --- src/funcs/call-model.ts | 13 ++++++++----- src/index.ts | 28 ++++++++++++++++++++++++++++ src/models/index.ts | 1 + src/sdk/sdk.ts | 9 ++------- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/funcs/call-model.ts b/src/funcs/call-model.ts index 71ef5983..2fdb661d 100644 --- a/src/funcs/call-model.ts +++ b/src/funcs/call-model.ts @@ -6,7 +6,13 @@ import type * as models from "../models/index.js"; import { ModelResult } from "../lib/model-result.js"; import { convertToolsToAPIFormat } from "../lib/tool-executor.js"; - +/** + * Input type for callModel function + */ +export type CallModelInput = Omit & { + tools?: Tool[]; + maxToolRounds?: MaxToolRounds; +}; /** * Get a response with multiple consumption patterns @@ -33,10 +39,7 @@ import { convertToolsToAPIFormat } from "../lib/tool-executor.js"; */ export function callModel( client: OpenRouterCore, - request: Omit & { - tools?: Tool[]; - maxToolRounds?: MaxToolRounds; - }, + request: CallModelInput, options?: RequestOptions ): ModelResult { const { tools, maxToolRounds, ...apiRequest } = request; diff --git a/src/index.ts b/src/index.ts index fa596068..0461c5e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,34 @@ export * from "./sdk/sdk.js"; export { fromClaudeMessages, toClaudeMessage } from "./lib/anthropic-compat.js"; export { fromChatMessages, toChatMessage } from "./lib/chat-compat.js"; +// Claude message types +export type { + ClaudeMessage, + ClaudeMessageParam, + ClaudeContentBlock, + ClaudeContentBlockParam, + ClaudeTextBlock, + ClaudeThinkingBlock, + ClaudeRedactedThinkingBlock, + ClaudeToolUseBlock, + ClaudeServerToolUseBlock, + ClaudeTextBlockParam, + ClaudeImageBlockParam, + ClaudeToolUseBlockParam, + ClaudeToolResultBlockParam, + ClaudeStopReason, + ClaudeUsage, + ClaudeCacheControl, + ClaudeTextCitation, + ClaudeCitationCharLocation, + ClaudeCitationPageLocation, + ClaudeCitationContentBlockLocation, + ClaudeCitationWebSearchResultLocation, + ClaudeCitationSearchResultLocation, + ClaudeBase64ImageSource, + ClaudeURLImageSource, +} from "./models/claude-message.js"; + // Tool creation helpers export { tool, createTool, createGeneratorTool, createManualTool } from "./lib/tool.js"; diff --git a/src/models/index.ts b/src/models/index.ts index 1d8403af..f8e040d1 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -155,3 +155,4 @@ export * from "./usermessage.js"; export * from "./websearchengine.js"; export * from "./websearchpreviewtooluserlocation.js"; export * from "./websearchstatus.js"; +export * from "./claude-message.js"; diff --git a/src/sdk/sdk.ts b/src/sdk/sdk.ts index ec947c79..fa0219a8 100644 --- a/src/sdk/sdk.ts +++ b/src/sdk/sdk.ts @@ -24,8 +24,7 @@ import { } from "../funcs/call-model.js"; import type { ModelResult } from "../lib/model-result.js"; import type { RequestOptions } from "../lib/sdks.js"; -import { type MaxToolRounds, Tool, ToolType } from "../lib/tool-types.js"; -import type { OpenResponsesRequest } from "../models/openresponsesrequest.js"; +import { type MaxToolRounds, ToolType } from "../lib/tool-types.js"; export { ToolType }; export type { MaxToolRounds }; @@ -99,11 +98,7 @@ export class OpenRouter extends ClientSDK { // #region sdk-class-body callModel( - request: Omit & { - input?: CallModelInput; - tools?: Tool[]; - maxToolRounds?: MaxToolRounds; - }, + request: CallModelInput, options?: RequestOptions, ): ModelResult { return callModelFunc(this, request, options); From a26646f4dba9eda467e5e90d4b98e11e0fcafd3c Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Wed, 17 Dec 2025 16:11:38 -0500 Subject: [PATCH 5/8] fix(tests): wrap message arrays with conversion functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update all E2E tests to explicitly use fromChatMessages() and fromClaudeMessages() conversion functions instead of passing raw message arrays directly. This fixes CI validation failures caused by removal of automatic message format conversion. Now all tests properly demonstrate the required usage pattern: - Chat-style messages → fromChatMessages() - Claude-style messages → fromClaudeMessages() Changes: - Add imports for fromChatMessages and fromClaudeMessages - Update ~35 test cases to wrap message arrays in conversion functions - Fix syntax error (trailing comma) from bulk replacement Fixes validation errors: "Invalid input: expected string, received array" --- tests/e2e/call-model.test.ts | 107 ++++++++++++++++++----------------- 1 file changed, 54 insertions(+), 53 deletions(-) diff --git a/tests/e2e/call-model.test.ts b/tests/e2e/call-model.test.ts index f3349c88..4868c02f 100644 --- a/tests/e2e/call-model.test.ts +++ b/tests/e2e/call-model.test.ts @@ -6,7 +6,8 @@ import type { OpenResponsesFunctionCallOutput } from '../../src/models/openrespo import { beforeAll, describe, expect, it } from 'vitest'; import { z } from 'zod/v4'; import { OpenRouter, ToolType } from '../../src/sdk/sdk.js'; -import { toChatMessage } from '../../src/lib/chat-compat.js'; +import { fromChatMessages, toChatMessage } from '../../src/lib/chat-compat.js'; +import { fromClaudeMessages } from '../../src/lib/anthropic-compat.js'; import { OpenResponsesNonStreamingResponse } from '../../src/models/openresponsesnonstreamingresponse.js'; import { OpenResponsesStreamEvent } from '../../src/models/openresponsesstreamevent.js'; @@ -28,7 +29,7 @@ describe('callModel E2E Tests', () => { it('should accept chat-style Message array as input', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'system', content: 'You are a helpful assistant.', @@ -37,7 +38,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: "Say 'chat test' and nothing else.", }, - ], + ]), }); const text = await response.getText(); @@ -50,7 +51,7 @@ describe('callModel E2E Tests', () => { it('should handle multi-turn chat-style conversation', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: 'My favorite color is blue.', @@ -63,7 +64,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: 'What is my favorite color?', }, - ], + ]), }); const text = await response.getText(); @@ -75,7 +76,7 @@ describe('callModel E2E Tests', () => { it('should handle system message in chat-style input', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'system', content: 'Always respond with exactly one word.', @@ -84,7 +85,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: 'Say hello.', }, - ], + ]), }); const text = await response.getText(); @@ -96,12 +97,12 @@ describe('callModel E2E Tests', () => { it('should accept chat-style tools (ToolDefinitionJson)', async () => { const response = client.callModel({ model: 'qwen/qwen3-vl-8b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "What's the weather in Paris? Use the get_weather tool.", }, - ], + ]), tools: [ { type: ToolType.Function, @@ -137,7 +138,7 @@ describe('callModel E2E Tests', () => { it.skip('should work with chat-style messages and chat-style tools together', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.1-8b-instruct', - input: [ + input: fromChatMessages([ { role: 'system', content: 'You are a helpful assistant. Use tools when needed.', @@ -146,7 +147,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: 'Get the weather in Tokyo using the weather tool.', }, - ], + ]), tools: [ { type: ToolType.Function, @@ -189,7 +190,7 @@ describe('callModel E2E Tests', () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: claudeMessages, + input: fromClaudeMessages(claudeMessages), }); const text = await response.getText(); @@ -214,7 +215,7 @@ describe('callModel E2E Tests', () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: claudeMessages, + input: fromClaudeMessages(claudeMessages), }); const text = await response.getText(); @@ -242,7 +243,7 @@ describe('callModel E2E Tests', () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: claudeMessages, + input: fromClaudeMessages(claudeMessages), }); const text = await response.getText(); @@ -270,7 +271,7 @@ describe('callModel E2E Tests', () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: claudeMessages, + input: fromClaudeMessages(claudeMessages), }); const text = await response.getText(); @@ -284,12 +285,12 @@ describe('callModel E2E Tests', () => { it('should successfully get text from a response', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "Say 'Hello, World!' and nothing else.", }, - ], + ]), }); const text = await response.getText(); @@ -303,7 +304,7 @@ describe('callModel E2E Tests', () => { it('should handle multi-turn conversations', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: 'My name is Bob.', @@ -316,7 +317,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: 'What is my name?', }, - ], + ]), }); const text = await response.getText(); @@ -330,12 +331,12 @@ describe('callModel E2E Tests', () => { it('should successfully get a complete message from response', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "Say 'test message' and nothing else.", }, - ], + ]), }); const fullResponse = await response.getResponse(); @@ -364,12 +365,12 @@ describe('callModel E2E Tests', () => { it('should have proper message structure', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: 'Respond with a simple greeting.', }, - ], + ]), }); const fullResponse = await response.getResponse(); @@ -425,7 +426,7 @@ describe('callModel E2E Tests', () => { it('should successfully stream text deltas', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: 'Count from 1 to 5.', @@ -450,7 +451,7 @@ describe('callModel E2E Tests', () => { it('should stream progressively without waiting for completion', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: 'Write a short poem.', @@ -485,7 +486,7 @@ describe('callModel E2E Tests', () => { it('should successfully stream incremental message updates in ResponsesOutputMessage format', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "Say 'streaming test'.", @@ -526,7 +527,7 @@ describe('callModel E2E Tests', () => { it('should return ResponsesOutputMessage with correct shape', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "Say 'hello world'.", @@ -575,7 +576,7 @@ describe('callModel E2E Tests', () => { it('should include OpenResponsesFunctionCallOutput with correct shape when tools are executed', async () => { const response = client.callModel({ model: 'openai/gpt-4o-mini', - input: [ + input: fromChatMessages([ { role: 'user', content: "What's the weather in Tokyo? Use the get_weather tool.", @@ -673,7 +674,7 @@ describe('callModel E2E Tests', () => { it('should return messages with all required fields and correct types', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: 'Count from 1 to 3.', @@ -714,7 +715,7 @@ describe('callModel E2E Tests', () => { it.skip('should successfully stream reasoning deltas for reasoning models', async () => { const response = client.callModel({ model: 'minimax/minimax-m2', - input: [ + input: fromChatMessages([ { role: 'user', content: 'What is 2+2?', @@ -744,7 +745,7 @@ describe('callModel E2E Tests', () => { it('should successfully stream tool call deltas when tools are called', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.1-8b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "What's the weather like in Paris? Use the get_weather tool to find out.", @@ -800,7 +801,7 @@ describe('callModel E2E Tests', () => { it('should successfully stream all response events', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "Say 'hello'.", @@ -832,7 +833,7 @@ describe('callModel E2E Tests', () => { it('should include text delta events', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: 'Count to 3.', @@ -865,7 +866,7 @@ describe('callModel E2E Tests', () => { it('should successfully stream in chat-compatible format', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "Say 'test'.", @@ -891,7 +892,7 @@ describe('callModel E2E Tests', () => { it('should return events with correct shape for each event type', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: 'Count from 1 to 3.', @@ -952,7 +953,7 @@ describe('callModel E2E Tests', () => { it('should validate content.delta events have proper structure', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "Say 'hello world'.", @@ -989,7 +990,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', - input: [ + input: fromChatMessages([ { role: 'user', content: 'What time is it? Use the get_time tool.', @@ -1078,7 +1079,7 @@ describe('callModel E2E Tests', () => { it('should allow reading text and streaming simultaneously', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "Say 'concurrent test'.", @@ -1113,7 +1114,7 @@ describe('callModel E2E Tests', () => { it('should allow multiple stream consumers', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: 'Write a short sentence.', @@ -1161,7 +1162,7 @@ describe('callModel E2E Tests', () => { it('should allow sequential consumption - text then stream', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "Say 'sequential test'.", @@ -1191,7 +1192,7 @@ describe('callModel E2E Tests', () => { it('should allow sequential consumption - stream then text', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "Say 'reverse test'.", @@ -1222,7 +1223,7 @@ describe('callModel E2E Tests', () => { it('should handle invalid model gracefully', async () => { const response = client.callModel({ model: 'invalid/model-that-does-not-exist', - input: [ + input: fromChatMessages([ { role: 'user', content: 'Test', @@ -1253,7 +1254,7 @@ describe('callModel E2E Tests', () => { it('should return full response with correct shape', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "Say 'hello'.", @@ -1299,7 +1300,7 @@ describe('callModel E2E Tests', () => { it('should return usage with correct shape including all token details', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "Say 'hello'.", @@ -1355,7 +1356,7 @@ describe('callModel E2E Tests', () => { it('should return error and incompleteDetails fields with correct shape', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "Say 'test'.", @@ -1381,7 +1382,7 @@ describe('callModel E2E Tests', () => { it('should allow concurrent access with other methods', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "Say 'test'.", @@ -1409,7 +1410,7 @@ describe('callModel E2E Tests', () => { it('should return consistent results on multiple calls', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "Say 'consistent'.", @@ -1434,7 +1435,7 @@ describe('callModel E2E Tests', () => { it('should respect maxOutputTokens parameter', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: 'Write a long story about a cat.', @@ -1453,7 +1454,7 @@ describe('callModel E2E Tests', () => { it('should work with instructions parameter', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "Say exactly: 'test complete'", @@ -1473,7 +1474,7 @@ describe('callModel E2E Tests', () => { it('should support provider parameter with correct shape', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "Say 'provider test'.", @@ -1495,7 +1496,7 @@ describe('callModel E2E Tests', () => { it('should support provider with order preference', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "Say 'ordered provider'.", @@ -1520,7 +1521,7 @@ describe('callModel E2E Tests', () => { it('should support provider with ignore list', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "Say 'ignore test'.", @@ -1543,7 +1544,7 @@ describe('callModel E2E Tests', () => { it('should support provider with quantizations filter', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.2-1b-instruct', - input: [ + input: fromChatMessages([ { role: 'user', content: "Say 'quantization test'.", From 772f5689556f6aec47c81da8b36f4a068b716107 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Wed, 17 Dec 2025 16:18:45 -0500 Subject: [PATCH 6/8] fix(tests): properly wrap all message arrays with conversion functions Update E2E tests to use fromChatMessages() and fromClaudeMessages() wrappers for all message array inputs. This fixes CI validation failures by ensuring all tests explicitly convert message formats before passing to callModel(). Changes: - Add imports for fromChatMessages and fromClaudeMessages - Use Python script to automatically wrap input: [...] patterns with fromChatMessages([...]) - Manually wrap Claude message variables with fromClaudeMessages() - All ~40 test cases now properly use conversion functions This ensures tests demonstrate the correct SDK usage pattern and pass validation. --- tests/e2e/call-model.test.ts | 60 ++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/tests/e2e/call-model.test.ts b/tests/e2e/call-model.test.ts index 4868c02f..aa726581 100644 --- a/tests/e2e/call-model.test.ts +++ b/tests/e2e/call-model.test.ts @@ -431,7 +431,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: 'Count from 1 to 5.', }, - ], + ]), }); const deltas: string[] = []; @@ -456,7 +456,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: 'Write a short poem.', }, - ], + ]), }); let firstDeltaTime: number | null = null; @@ -491,7 +491,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: "Say 'streaming test'.", }, - ], + ]), }); const messages: (ResponsesOutputMessage | OpenResponsesFunctionCallOutput)[] = []; @@ -532,7 +532,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: "Say 'hello world'.", }, - ], + ]), }); const messages: (ResponsesOutputMessage | OpenResponsesFunctionCallOutput)[] = []; @@ -581,7 +581,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: "What's the weather in Tokyo? Use the get_weather tool.", }, - ], + ]), tools: [ { type: ToolType.Function, @@ -679,7 +679,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: 'Count from 1 to 3.', }, - ], + ]), }); for await (const message of response.getNewMessagesStream()) { @@ -720,7 +720,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: 'What is 2+2?', }, - ], + ]), reasoning: { enabled: true, effort: 'low', @@ -750,7 +750,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: "What's the weather like in Paris? Use the get_weather tool to find out.", }, - ], + ]), tools: [ { type: ToolType.Function, @@ -806,7 +806,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: "Say 'hello'.", }, - ], + ]), }); const events: EnhancedResponseStreamEvent[] = []; @@ -838,7 +838,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: 'Count to 3.', }, - ], + ]), }); const textDeltaEvents: EnhancedResponseStreamEvent[] = []; @@ -871,7 +871,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: "Say 'test'.", }, - ], + ]), }); const chunks: ChatStreamEvent[] = []; @@ -897,7 +897,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: 'Count from 1 to 3.', }, - ], + ]), }); let hasContentDelta = false; @@ -958,7 +958,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: "Say 'hello world'.", }, - ], + ]), }); const contentDeltas: ChatStreamEvent[] = []; @@ -995,7 +995,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: 'What time is it? Use the get_time tool.', }, - ], + ]), tools: [ { type: ToolType.Function, @@ -1084,7 +1084,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: "Say 'concurrent test'.", }, - ], + ]), }); // Get full text and stream concurrently @@ -1119,7 +1119,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: 'Write a short sentence.', }, - ], + ]), }); // Start two concurrent stream consumers @@ -1167,7 +1167,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: "Say 'sequential test'.", }, - ], + ]), }); // First, get the full text @@ -1197,7 +1197,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: "Say 'reverse test'.", }, - ], + ]), }); // First, collect deltas from stream @@ -1228,7 +1228,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: 'Test', }, - ], + ]), }); await expect(response.getText()).rejects.toThrow(); @@ -1259,7 +1259,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: "Say 'hello'.", }, - ], + ]), }); const fullResponse = await response.getResponse(); @@ -1305,7 +1305,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: "Say 'hello'.", }, - ], + ]), }); const fullResponse = await response.getResponse(); @@ -1361,7 +1361,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: "Say 'test'.", }, - ], + ]), }); const fullResponse = await response.getResponse(); @@ -1387,7 +1387,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: "Say 'test'.", }, - ], + ]), }); // Get both text and full response concurrently @@ -1415,7 +1415,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: "Say 'consistent'.", }, - ], + ]), }); const firstCall = await response.getResponse(); @@ -1440,7 +1440,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: 'Write a long story about a cat.', }, - ], + ]), maxOutputTokens: 10, }); @@ -1459,7 +1459,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: "Say exactly: 'test complete'", }, - ], + ]), instructions: 'You are a helpful assistant. Keep responses concise.', }); @@ -1479,7 +1479,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: "Say 'provider test'.", }, - ], + ]), provider: { allowFallbacks: true, requireParameters: false, @@ -1501,7 +1501,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: "Say 'ordered provider'.", }, - ], + ]), provider: { order: [ 'Together', @@ -1526,7 +1526,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: "Say 'ignore test'.", }, - ], + ]), provider: { ignore: [ 'SomeProvider', @@ -1549,7 +1549,7 @@ describe('callModel E2E Tests', () => { role: 'user', content: "Say 'quantization test'.", }, - ], + ]), provider: { allowFallbacks: true, }, From 318b926fd0750b6976ec5c2c42553de26dc59fa9 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Wed, 17 Dec 2025 16:21:26 -0500 Subject: [PATCH 7/8] fix types --- tests/e2e/call-model-tools.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/e2e/call-model-tools.test.ts b/tests/e2e/call-model-tools.test.ts index 40f487d6..0fe1dcc2 100644 --- a/tests/e2e/call-model-tools.test.ts +++ b/tests/e2e/call-model-tools.test.ts @@ -64,8 +64,7 @@ describe('Enhanced Tool Support for callModel', () => { target: 'openapi-3.0', }); - // @ts-expect-error - description is not a property of _JSONSchema - expect(jsonSchema.properties?.location?.description).toBe( + expect(jsonSchema.properties?.location?.['description']).toBe( 'City and country e.g. Bogotá, Colombia', ); }); From 4398042325b1f602b535a2897c219a23fdb9f0fc Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Wed, 17 Dec 2025 16:42:49 -0500 Subject: [PATCH 8/8] enhancements --- src/index.ts | 2 +- src/lib/anthropic-compat.ts | 16 +++---- src/lib/chat-compat.ts | 4 ++ src/lib/tool.ts | 86 ---------------------------------- tests/unit/create-tool.test.ts | 41 +--------------- 5 files changed, 12 insertions(+), 137 deletions(-) diff --git a/src/index.ts b/src/index.ts index 09459556..e07ef423 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,7 +42,7 @@ export type { } from "./models/claude-message.js"; // Tool creation helpers -export { tool, createTool, createGeneratorTool, createManualTool } from "./lib/tool.js"; +export { tool } from "./lib/tool.js"; // Tool types export type { diff --git a/src/lib/anthropic-compat.ts b/src/lib/anthropic-compat.ts index 35c8dce1..1fe68c25 100644 --- a/src/lib/anthropic-compat.ts +++ b/src/lib/anthropic-compat.ts @@ -5,7 +5,7 @@ import { OpenResponsesEasyInputMessageRoleUser, } from '../models/openresponseseasyinputmessage.js'; import { OpenResponsesFunctionCallOutputType } from '../models/openresponsesfunctioncalloutput.js'; -import { OpenResponsesInputMessageItemRoleUser } from '../models/openresponsesinputmessageitem.js'; +import { OpenResponsesInputMessageItemRoleUser, OpenResponsesInputMessageItemRoleDeveloper } from '../models/openresponsesinputmessageitem.js'; import { convertToClaudeMessage } from './stream-transformers.js'; /** @@ -150,12 +150,7 @@ export function fromClaudeMessages( toolOutput = textParts.join(''); // Map images to image_generation_call items - for (let i = 0; i < imageParts.length; i++) { - const imagePart = imageParts[i]; - if (!imagePart) { - continue; - } - + imageParts.forEach((imagePart, i) => { let imageUrl: string; if (imagePart.source.type === 'url') { @@ -173,7 +168,7 @@ export function fromClaudeMessages( result: imageUrl, status: 'completed', }); - } + }); } // Add the function call output for the text portion (if any) @@ -222,13 +217,14 @@ export function fromClaudeMessages( role: role === 'user' ? OpenResponsesInputMessageItemRoleUser.User - : OpenResponsesInputMessageItemRoleUser.User, // Map assistant to user as well since OpenRouter doesn't have assistant for this type + : OpenResponsesInputMessageItemRoleDeveloper.Developer, content: contentItems, }); } else { // Use simple string format for text-only messages const textContent = contentItems - .map((item) => (item as models.ResponseInputText).text) + .filter((item): item is models.ResponseInputText => item.type === 'input_text') + .map((item) => item.text) .join(''); if (textContent.length > 0) { diff --git a/src/lib/chat-compat.ts b/src/lib/chat-compat.ts index 745df5f4..d852b18a 100644 --- a/src/lib/chat-compat.ts +++ b/src/lib/chat-compat.ts @@ -41,6 +41,10 @@ function mapChatRole( return OpenResponsesEasyInputMessageRoleAssistant.Assistant; case "developer": return OpenResponsesEasyInputMessageRoleDeveloper.Developer; + default: { + const exhaustiveCheck: never = role; + throw new Error(`Unhandled role type: ${exhaustiveCheck}`); + } } } diff --git a/src/lib/tool.ts b/src/lib/tool.ts index 78fe2a50..23445feb 100644 --- a/src/lib/tool.ts +++ b/src/lib/tool.ts @@ -260,89 +260,3 @@ export function tool< function: fn, }; } - -/** - * @deprecated Use `tool()` instead. This function is kept for backwards compatibility. - */ -export const createTool = tool; - -/** - * Creates a generator tool with streaming capabilities. - * - * @deprecated Use `tool()` with `eventSchema` instead. This function is kept for backwards compatibility. - * - * @example - * ```typescript - * // Instead of createGeneratorTool, use tool with eventSchema: - * const progressTool = tool({ - * name: "process_data", - * inputSchema: z.object({ data: z.string() }), - * eventSchema: z.object({ progress: z.number() }), - * outputSchema: z.object({ result: z.string() }), - * execute: async function* (params) { - * yield { progress: 50 }; // typed as event - * yield { result: "done" }; // typed as output - * }, - * }); - * ``` - */ -export function createGeneratorTool< - TInput extends ZodObject, - TEvent extends ZodType, - TOutput extends ZodType, ->(config: { - name: string; - description?: string; - inputSchema: TInput; - eventSchema: TEvent; - outputSchema: TOutput; - execute: ( - params: z.infer, - context?: TurnContext - ) => AsyncGenerator | z.infer>; -}): ToolWithGenerator { - return tool(config); -} - -/** - * Creates a manual tool without an execute function. - * - * @deprecated Use `tool()` with `execute: false` instead. This function is kept for backwards compatibility. - * - * @example - * ```typescript - * // Instead of createManualTool, use tool with execute: false: - * const manualTool = tool({ - * name: "external_api", - * inputSchema: z.object({ query: z.string() }), - * execute: false, - * }); - * ``` - */ -export function createManualTool< - TInput extends ZodObject, - TOutput extends ZodType = ZodType, ->(config: { - name: string; - description?: string; - inputSchema: TInput; - outputSchema?: TOutput; -}): ManualTool { - const fn: ManualTool["function"] = { - name: config.name, - inputSchema: config.inputSchema, - }; - - if (config.description !== undefined) { - fn.description = config.description; - } - - if (config.outputSchema !== undefined) { - fn.outputSchema = config.outputSchema; - } - - return { - type: ToolType.Function, - function: fn, - }; -} diff --git a/tests/unit/create-tool.test.ts b/tests/unit/create-tool.test.ts index 0cde8554..bb26c0cd 100644 --- a/tests/unit/create-tool.test.ts +++ b/tests/unit/create-tool.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { z } from 'zod/v4'; -import { tool, createTool, createGeneratorTool, createManualTool } from '../../src/lib/tool.js'; +import { tool } from '../../src/lib/tool.js'; import { ToolType } from '../../src/lib/tool-types.js'; describe('tool', () => { @@ -182,43 +182,4 @@ describe('tool', () => { expect(manualTool.function).not.toHaveProperty('execute'); }); }); - - describe('deprecated functions - backwards compatibility', () => { - it('createTool should still work', () => { - const testTool = createTool({ - name: 'test_tool', - inputSchema: z.object({ input: z.string() }), - execute: async (params) => ({ result: params.input }), - }); - - expect(testTool.type).toBe(ToolType.Function); - expect(testTool.function.name).toBe('test_tool'); - }); - - it('createGeneratorTool should still work', () => { - const generatorTool = createGeneratorTool({ - name: 'generator_tool', - inputSchema: z.object({ query: z.string() }), - eventSchema: z.object({ progress: z.number() }), - outputSchema: z.object({ result: z.string() }), - execute: async function* () { - yield { progress: 50 }; - yield { result: 'done' }; - }, - }); - - expect(generatorTool.type).toBe(ToolType.Function); - expect(generatorTool.function.name).toBe('generator_tool'); - }); - - it('createManualTool should still work', () => { - const manualTool = createManualTool({ - name: 'manual_tool', - inputSchema: z.object({ query: z.string() }), - }); - - expect(manualTool.type).toBe(ToolType.Function); - expect(manualTool.function.name).toBe('manual_tool'); - }); - }); });