Skip to content

Commit 11145ec

Browse files
authored
feat: add strongly typed tools with createTool helpers (#106)
2 parents a42a700 + 4398042 commit 11145ec

File tree

12 files changed

+1051
-379
lines changed

12 files changed

+1051
-379
lines changed
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/*
2+
* Example: Typed Tool Calling with callModel
3+
*
4+
* This example demonstrates how to use the tool() function for
5+
* fully-typed tool definitions where execute params, return types, and event
6+
* types are automatically inferred from Zod schemas.
7+
*
8+
* Tool types are auto-detected based on configuration:
9+
* - Generator tool: When `eventSchema` is provided
10+
* - Regular tool: When `execute` is a function (no `eventSchema`)
11+
* - Manual tool: When `execute: false` is set
12+
*
13+
* To run this example from the examples directory:
14+
* npm run build && npx tsx callModel-typed-tool-calling.example.ts
15+
*/
16+
17+
import dotenv from "dotenv";
18+
dotenv.config();
19+
20+
import { OpenRouter, tool } from "../src/index.js";
21+
import z from "zod";
22+
23+
const openRouter = new OpenRouter({
24+
apiKey: process.env["OPENROUTER_API_KEY"] ?? "",
25+
});
26+
27+
// Create a typed regular tool using tool()
28+
// The execute function params are automatically typed as z.infer<typeof inputSchema>
29+
// The return type is enforced based on outputSchema
30+
const weatherTool = tool({
31+
name: "get_weather",
32+
description: "Get the current weather for a location",
33+
inputSchema: z.object({
34+
location: z.string().describe("The city and country, e.g. San Francisco, CA"),
35+
}),
36+
outputSchema: z.object({
37+
temperature: z.number(),
38+
description: z.string(),
39+
}),
40+
// params is automatically typed as { location: string }
41+
execute: async (params) => {
42+
console.log(`Getting weather for: ${params.location}`);
43+
// Return type is enforced as { temperature: number; description: string }
44+
return {
45+
temperature: 20,
46+
description: "Sunny",
47+
};
48+
},
49+
});
50+
51+
// Create a generator tool with typed progress events by providing eventSchema
52+
// The eventSchema triggers generator mode - execute becomes an async generator
53+
const searchTool = tool({
54+
name: "search_database",
55+
description: "Search database with progress updates",
56+
inputSchema: z.object({
57+
query: z.string().describe("The search query"),
58+
}),
59+
eventSchema: z.object({
60+
progress: z.number(),
61+
message: z.string(),
62+
}),
63+
outputSchema: z.object({
64+
results: z.array(z.string()),
65+
totalFound: z.number(),
66+
}),
67+
// execute is a generator that yields typed progress events
68+
execute: async function* (params) {
69+
console.log(`Searching for: ${params.query}`);
70+
// Each yield is typed as { progress: number; message: string }
71+
yield { progress: 25, message: "Searching..." };
72+
yield { progress: 50, message: "Processing results..." };
73+
yield { progress: 75, message: "Almost done..." };
74+
// Final result is typed as { results: string[]; totalFound: number }
75+
yield { progress: 100, message: "Complete!" };
76+
},
77+
});
78+
79+
async function main() {
80+
console.log("=== Typed Tool Calling Example ===\n");
81+
82+
// Use 'as const' to enable full type inference for tool calls
83+
const result = openRouter.callModel({
84+
instructions: "You are a helpful assistant. Your name is Mark",
85+
model: "openai/gpt-4o-mini",
86+
input: "Hello! What is the weather in San Francisco?",
87+
tools: [weatherTool] as const,
88+
});
89+
90+
// Get text response (tools are auto-executed)
91+
const text = await result.getText();
92+
console.log("Response:", text);
93+
94+
console.log("\n=== Getting Tool Calls ===\n");
95+
96+
// Create a fresh request for demonstrating getToolCalls
97+
const result2 = openRouter.callModel({
98+
model: "openai/gpt-4o-mini",
99+
input: "What's the weather like in Paris?",
100+
tools: [weatherTool] as const,
101+
maxToolRounds: 0, // Don't auto-execute, just get the tool calls
102+
});
103+
104+
// Tool calls are now typed based on the tool definitions!
105+
const toolCalls = await result2.getToolCalls();
106+
107+
for (const toolCall of toolCalls) {
108+
console.log(`Tool: ${toolCall.name}`);
109+
// toolCall.arguments is typed as { location: string }
110+
console.log(`Arguments:`, toolCall.arguments);
111+
}
112+
113+
console.log("\n=== Streaming Tool Calls ===\n");
114+
115+
// Create another request for demonstrating streaming
116+
const result3 = openRouter.callModel({
117+
model: "openai/gpt-4o-mini",
118+
input: "What's the weather in Tokyo?",
119+
tools: [weatherTool] as const,
120+
maxToolRounds: 0,
121+
});
122+
123+
// Stream tool calls with typed arguments
124+
for await (const toolCall of result3.getToolCallsStream()) {
125+
console.log(`Streamed tool: ${toolCall.name}`);
126+
// toolCall.arguments is typed based on tool definitions
127+
console.log(`Streamed arguments:`, toolCall.arguments);
128+
}
129+
130+
console.log("\n=== Generator Tool with Typed Events ===\n");
131+
132+
// Use generator tool with typed progress events
133+
const result4 = openRouter.callModel({
134+
model: "openai/gpt-4o-mini",
135+
input: "Search for documents about TypeScript",
136+
tools: [searchTool] as const,
137+
});
138+
139+
// Stream events from getToolStream - events are fully typed!
140+
for await (const event of result4.getToolStream()) {
141+
if (event.type === "preliminary_result") {
142+
// event.result is typed as { progress: number; message: string }
143+
console.log(`Progress: ${event.result.progress}% - ${event.result.message}`);
144+
} else if (event.type === "delta") {
145+
// Tool argument deltas
146+
process.stdout.write(event.content);
147+
}
148+
}
149+
150+
console.log("\n=== Mixed Tools with Typed Events ===\n");
151+
152+
// Use both regular and generator tools together
153+
const result5 = openRouter.callModel({
154+
model: "openai/gpt-4o-mini",
155+
input: "First search for weather data, then get the weather in Seattle",
156+
tools: [weatherTool, searchTool] as const,
157+
});
158+
159+
// Events are a union of all generator tool event types
160+
for await (const event of result5.getToolStream()) {
161+
if (event.type === "preliminary_result") {
162+
// event.result is typed as { progress: number; message: string }
163+
// (only searchTool has eventSchema, so that's the event type)
164+
console.log(`Event:`, event.result);
165+
}
166+
}
167+
}
168+
169+
main().catch(console.error);

src/funcs/call-model.ts

Lines changed: 20 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -1,120 +1,18 @@
1-
import type { OpenRouterCore } from "../core.js";
2-
import type { RequestOptions } from "../lib/sdks.js";
3-
import type { Tool, MaxToolRounds } from "../lib/tool-types.js";
4-
import type * as models from "../models/index.js";
1+
import type { OpenRouterCore } from '../core.js';
2+
import type { RequestOptions } from '../lib/sdks.js';
3+
import type { MaxToolRounds, Tool } from '../lib/tool-types.js';
4+
import type * as models from '../models/index.js';
55

6-
import { ModelResult } from "../lib/model-result.js";
7-
import { convertToolsToAPIFormat } from "../lib/tool-executor.js";
6+
import { ModelResult } from '../lib/model-result.js';
7+
import { convertToolsToAPIFormat } from '../lib/tool-executor.js';
88

99
/**
10-
* Checks if a message looks like a Claude-style message
10+
* Input type for callModel function
1111
*/
12-
function isClaudeStyleMessage(msg: any): msg is models.ClaudeMessageParam {
13-
if (!msg || typeof msg !== 'object') return false;
14-
15-
// Check if it has a role field that's user or assistant
16-
const role = msg.role;
17-
if (role !== 'user' && role !== 'assistant') return false;
18-
19-
// Check if content is an array with Claude-style content blocks
20-
if (Array.isArray(msg.content)) {
21-
return msg.content.some((block: any) =>
22-
block &&
23-
typeof block === 'object' &&
24-
block.type &&
25-
// Claude content block types (not OpenRouter types)
26-
(block.type === 'text' || block.type === 'image' || block.type === 'tool_use' || block.type === 'tool_result')
27-
);
28-
}
29-
30-
return false;
31-
}
32-
33-
/**
34-
* Converts Claude-style content blocks to OpenRouter format
35-
*/
36-
function convertClaudeContentBlock(
37-
block: models.ClaudeContentBlockParam
38-
): models.ResponseInputText | models.ResponseInputImage | null {
39-
if (!block || typeof block !== 'object' || !('type' in block)) {
40-
return null;
41-
}
42-
43-
switch (block.type) {
44-
case 'text': {
45-
const textBlock = block as models.ClaudeTextBlockParam;
46-
return {
47-
type: 'input_text',
48-
text: textBlock.text,
49-
};
50-
}
51-
case 'image': {
52-
const imageBlock = block as models.ClaudeImageBlockParam;
53-
if (imageBlock.source.type === 'url') {
54-
return {
55-
type: 'input_image',
56-
detail: 'auto',
57-
imageUrl: imageBlock.source.url,
58-
};
59-
} else if (imageBlock.source.type === 'base64') {
60-
const dataUri = `data:${imageBlock.source.media_type};base64,${imageBlock.source.data}`;
61-
return {
62-
type: 'input_image',
63-
detail: 'auto',
64-
imageUrl: dataUri,
65-
};
66-
}
67-
return null;
68-
}
69-
case 'tool_use':
70-
case 'tool_result':
71-
// tool_use and tool_result are not handled here as they map to different input types
72-
return null;
73-
default:
74-
return null;
75-
}
76-
}
77-
78-
/**
79-
* Converts a Claude-style message to OpenRouter EasyInputMessage format
80-
*/
81-
function convertClaudeMessage(msg: models.ClaudeMessageParam): models.OpenResponsesEasyInputMessage {
82-
const { role, content } = msg;
83-
84-
if (typeof content === 'string') {
85-
return {
86-
role: role === 'user' ? 'user' : 'assistant',
87-
content,
88-
};
89-
}
90-
91-
// Convert array of content blocks
92-
const convertedBlocks: (models.ResponseInputText | models.ResponseInputImage)[] = [];
93-
for (const block of content) {
94-
const converted = convertClaudeContentBlock(block);
95-
if (converted) {
96-
convertedBlocks.push(converted);
97-
}
98-
}
99-
100-
// If all blocks were text, concatenate them into a string
101-
const allText = convertedBlocks.every(b => b.type === 'input_text');
102-
if (allText) {
103-
const text = convertedBlocks
104-
.map(b => (b as models.ResponseInputText).text)
105-
.join('');
106-
return {
107-
role: role === 'user' ? 'user' : 'assistant',
108-
content: text,
109-
};
110-
}
111-
112-
// Otherwise, return as array
113-
return {
114-
role: role === 'user' ? 'user' : 'assistant',
115-
content: convertedBlocks,
116-
};
117-
}
12+
export type CallModelInput = Omit<models.OpenResponsesRequest, 'stream' | 'tools'> & {
13+
tools?: Tool[];
14+
maxToolRounds?: MaxToolRounds;
15+
};
11816

11917
/**
12018
* Get a response with multiple consumption patterns
@@ -141,43 +39,29 @@ function convertClaudeMessage(msg: models.ClaudeMessageParam): models.OpenRespon
14139
*/
14240
export function callModel(
14341
client: OpenRouterCore,
144-
request: Omit<models.OpenResponsesRequest, "stream" | "tools"> & {
145-
tools?: Tool[];
146-
maxToolRounds?: MaxToolRounds;
147-
},
148-
options?: RequestOptions
42+
request: CallModelInput,
43+
options?: RequestOptions,
14944
): ModelResult {
15045
const { tools, maxToolRounds, ...apiRequest } = request;
15146

152-
// Auto-convert Claude-style messages if detected
153-
let processedInput = apiRequest.input;
154-
if (Array.isArray(apiRequest.input)) {
155-
const hasClaudeMessages = apiRequest.input.some(isClaudeStyleMessage);
156-
if (hasClaudeMessages) {
157-
processedInput = apiRequest.input.map((msg: any) => {
158-
if (isClaudeStyleMessage(msg)) {
159-
return convertClaudeMessage(msg);
160-
}
161-
return msg;
162-
});
163-
}
164-
}
165-
16647
// Convert tools to API format and extract enhanced tools if present
16748
const apiTools = tools ? convertToolsToAPIFormat(tools) : undefined;
16849

169-
// Build the request with converted tools and input
50+
// Build the request with converted tools
17051
const finalRequest: models.OpenResponsesRequest = {
17152
...apiRequest,
172-
...(processedInput !== undefined && { input: processedInput }),
173-
...(apiTools !== undefined && { tools: apiTools }),
53+
...(apiTools !== undefined && {
54+
tools: apiTools,
55+
}),
17456
};
17557

17658
return new ModelResult({
17759
client,
17860
request: finalRequest,
17961
options: options ?? {},
18062
tools: tools ?? [],
181-
...(maxToolRounds !== undefined && { maxToolRounds }),
63+
...(maxToolRounds !== undefined && {
64+
maxToolRounds,
65+
}),
18266
});
18367
}

0 commit comments

Comments
 (0)