Skip to content

本文是 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: inputSchemaoutputSchema 都需要写吗?

A: inputSchema 必须写,用于告诉模型如何调用工具;outputSchema 可选,但写上后 SDK 会自动校验工具返回值,提升类型安全性。

Q: 工具抛出异常和在 outputSchema 里返回 error 字段,有什么区别?

A: 抛出异常会触发模型重试或终止;在 schema 里返回 error 字段则让模型拿到错误信息后自行决策(比如告知用户城市名无效),更适合用户友好的场景。

Q: 一次对话里模型会调用同一工具几次?

A: 没有硬性限制,由 StopCondition 配置决定。默认情况下模型可以多轮调用工具,直到认为任务完成。