Appearance
本文是 OpenRouter Agent SDK 的天气工具完整示例,展示如何通过 tool() 函数封装外部 API 调用。涵盖 inputSchema/outputSchema 的 Zod 类型定义、WeatherAPI 集成、城市未找到和限流的错误处理,以及带重试逻辑的生产级实现。模型可并行调用该工具查询多个城市,最终自然语言综合输出。
前置准备
bash
pnpm add @openrouter/sdk zod需要天气 API Key,本示例使用 WeatherAPI(有免费套餐)。
bash
export WEATHER_API_KEY=your_api_key_here
export OPENROUTER_API_KEY=your_openrouter_key基础实现
typescript
import { OpenRouter, tool } from '@openrouter/agent';
import { z } from 'zod';
const openrouter = new OpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
});
const weatherTool = tool({
name: 'get_weather',
description: 'Get current weather conditions for any city worldwide',
inputSchema: z.object({
city: z.string().describe('City name, e.g., "San Francisco" or "London, UK"'),
units: z
.enum(['celsius', 'fahrenheit'])
.default('celsius')
.describe('Temperature units'),
}),
outputSchema: z.object({
temperature: z.number(),
feelsLike: z.number(),
conditions: z.string(),
humidity: z.number(),
windSpeed: z.number(),
windDirection: z.string(),
location: z.object({
name: z.string(),
region: z.string(),
country: z.string(),
}),
}),
execute: async ({ city, units }) => {
const apiKey = process.env.WEATHER_API_KEY;
if (!apiKey) {
throw new Error('WEATHER_API_KEY environment variable not set');
}
const response = await fetch(
`https://api.weatherapi.com/v1/current.json?key=${apiKey}&q=${encodeURIComponent(city)}`
);
if (!response.ok) {
if (response.status === 400) {
throw new Error(`City not found: ${city}`);
}
throw new Error(`Weather API error: ${response.status}`);
}
const data = await response.json();
return {
temperature: units === 'celsius' ? data.current.temp_c : data.current.temp_f,
feelsLike: units === 'celsius' ? data.current.feelslike_c : data.current.feelslike_f,
conditions: data.current.condition.text,
humidity: data.current.humidity,
windSpeed: data.current.wind_kph,
windDirection: data.current.wind_dir,
location: {
name: data.location.name,
region: data.location.region,
country: data.location.country,
},
};
},
});使用方式
typescript
const result = openrouter.callModel({
model: 'openai/gpt-5-nano',
input: '东京现在天气怎么样?',
tools: [weatherTool],
});
const text = await result.getText();
// "东京,日本,目前多云,气温 22°C(体感 24°C),
// 湿度 65%,西南风 15 km/h。"多城市并行查询
模型会自动调用工具两次,分别查询两座城市:
typescript
const result = openrouter.callModel({
model: 'openai/gpt-5-nano',
input: 'Compare the weather in New York and Los Angeles',
tools: [weatherTool],
});
const text = await result.getText();带预报的扩展版本
typescript
const forecastTool = tool({
name: 'get_forecast',
description: 'Get weather forecast for the next few days',
inputSchema: z.object({
city: z.string().describe('City name'),
days: z.number().min(1).max(7).default(3).describe('Number of forecast days'),
units: z.enum(['celsius', 'fahrenheit']).default('celsius'),
}),
outputSchema: z.object({
location: z.string(),
forecast: z.array(
z.object({
date: z.string(),
maxTemp: z.number(),
minTemp: z.number(),
conditions: z.string(),
chanceOfRain: z.number(),
})
),
}),
execute: async ({ city, days, units }) => {
const apiKey = process.env.WEATHER_API_KEY;
if (!apiKey) throw new Error('WEATHER_API_KEY environment variable not set');
const response = await fetch(
`https://api.weatherapi.com/v1/forecast.json?key=${apiKey}&q=${encodeURIComponent(city)}&days=${days}`
);
if (!response.ok) throw new Error(`Weather API error: ${response.status}`);
const data = await response.json();
return {
location: `${data.location.name}, ${data.location.country}`,
forecast: data.forecast.forecastday.map((day: any) => ({
date: day.date,
maxTemp: units === 'celsius' ? day.day.maxtemp_c : day.day.maxtemp_f,
minTemp: units === 'celsius' ? day.day.mintemp_c : day.day.mintemp_f,
conditions: day.day.condition.text,
chanceOfRain: day.day.daily_chance_of_rain,
})),
};
},
});
// 组合使用两个工具
const result = openrouter.callModel({
model: 'openai/gpt-5-nano',
input: 'What is the weather in Paris today and for the next 3 days?',
tools: [weatherTool, forecastTool],
});错误处理与重试
工具内可以实现退避重试,而不是直接向模型抛异常:
typescript
const weatherToolWithRetry = tool({
name: 'get_weather',
description: 'Get current weather with retry logic',
inputSchema: z.object({
city: z.string(),
units: z.enum(['celsius', 'fahrenheit']).default('celsius'),
}),
outputSchema: z.object({
temperature: z.number(),
conditions: z.string(),
error: z.string().optional(),
}),
execute: async ({ city, units }) => {
const maxRetries = 3;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(
`https://api.weatherapi.com/v1/current.json?key=${process.env.WEATHER_API_KEY}&q=${encodeURIComponent(city)}`
);
if (response.status === 429) {
// 限流,指数退避
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
continue;
}
if (!response.ok) throw new Error(`API error: ${response.status}`);
const data = await response.json();
return {
temperature: units === 'celsius' ? data.current.temp_c : data.current.temp_f,
conditions: data.current.condition.text,
};
} catch (error) {
lastError = error as Error;
}
}
// 在 outputSchema 里返回错误,而非 throw
return {
temperature: 0,
conditions: 'Unknown',
error: `Failed after ${maxRetries} attempts: ${lastError?.message}`,
};
},
});单元测试
typescript
import { describe, it, expect, mock } from 'bun:test';
describe('weatherTool', () => {
it('returns weather data for valid city', async () => {
global.fetch = mock(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
current: {
temp_c: 22, temp_f: 72, feelslike_c: 24, feelslike_f: 75,
condition: { text: 'Sunny' }, humidity: 45, wind_kph: 10, wind_dir: 'NW',
},
location: { name: 'London', region: 'City of London', country: 'UK' },
}),
})
);
const result = await weatherTool.function.execute(
{ city: 'London', units: 'celsius' },
{ numberOfTurns: 1 }
);
expect(result.temperature).toBe(22);
expect(result.conditions).toBe('Sunny');
expect(result.location.name).toBe('London');
});
it('handles city not found', async () => {
global.fetch = mock(() =>
Promise.resolve({ ok: false, status: 400 })
);
await expect(
weatherTool.function.execute(
{ city: 'InvalidCity123', units: 'celsius' },
{ numberOfTurns: 1 }
)
).rejects.toThrow('City not found');
});
});常见问题
Q: inputSchema 和 outputSchema 都需要写吗?
A: inputSchema 必须写,用于告诉模型如何调用工具;outputSchema 可选,但写上后 SDK 会自动校验工具返回值,提升类型安全性。
Q: 工具抛出异常和在 outputSchema 里返回 error 字段,有什么区别?
A: 抛出异常会触发模型重试或终止;在 schema 里返回 error 字段则让模型拿到错误信息后自行决策(比如告知用户城市名无效),更适合用户友好的场景。
Q: 一次对话里模型会调用同一工具几次?
A: 没有硬性限制,由 StopCondition 配置决定。默认情况下模型可以多轮调用工具,直到认为任务完成。