create-agent

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Build a Modular AI Agent with OpenRouter

使用OpenRouter构建模块化AI Agent

This skill helps you create a modular AI agent with:
  • Standalone Agent Core - Runs independently, extensible via hooks
  • OpenRouter SDK - Unified access to 300+ language models
  • Optional Ink TUI - Beautiful terminal UI (separate from agent logic)
本技能可帮助你创建一个模块化AI Agent,具备以下特性:
  • 独立Agent核心 - 可独立运行,通过钩子实现扩展
  • OpenRouter SDK - 统一访问300+大语言模型
  • 可选Ink TUI - 美观的终端UI(与Agent逻辑分离)

Architecture

架构

┌─────────────────────────────────────────────────────┐
│                    Your Application                 │
├─────────────────────────────────────────────────────┤
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐  │
│  │   Ink TUI   │  │  HTTP API   │  │   Discord   │  │
│  └──────┬──────┘  └──────┬──────┘  └──────┬──────┘  │
│         │                │                │         │
│         └────────────────┼────────────────┘         │
│                          ▼                          │
│              ┌───────────────────────┐              │
│              │      Agent Core       │              │
│              │  (hooks & lifecycle)  │              │
│              └───────────┬───────────┘              │
│                          ▼                          │
│              ┌───────────────────────┐              │
│              │    OpenRouter SDK     │              │
│              └───────────────────────┘              │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│                    Your Application                 │
├─────────────────────────────────────────────────────┤
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐  │
│  │   Ink TUI   │  │  HTTP API   │  │   Discord   │  │
│  └──────┬──────┘  └──────┬──────┘  └──────┬──────┘  │
│         │                │                │         │
│         └────────────────┼────────────────┘         │
│                          ▼                          │
│              ┌───────────────────────┐              │
│              │      Agent Core       │              │
│              │  (hooks & lifecycle)  │              │
│              └───────────┬───────────┘              │
│                          ▼                          │
│              ┌───────────────────────┐              │
│              │    OpenRouter SDK     │              │
│              └───────────────────────┘              │
└─────────────────────────────────────────────────────┘

Prerequisites

前置条件

Get an OpenRouter API key at: https://openrouter.ai/settings/keys
⚠️ Security: Never commit API keys. Use environment variables.
在以下地址获取OpenRouter API密钥:https://openrouter.ai/settings/keys
⚠️ 安全提示: 绝不要提交API密钥,请使用环境变量存储。

Project Setup

项目设置

Step 1: Initialize Project

步骤1:初始化项目

bash
mkdir my-agent && cd my-agent
npm init -y
npm pkg set type="module"
bash
mkdir my-agent && cd my-agent
npm init -y
npm pkg set type="module"

Step 2: Install Dependencies

步骤2:安装依赖

bash
npm install @openrouter/sdk zod eventemitter3
npm install ink react  # Optional: only for TUI
npm install -D typescript @types/react tsx
bash
npm install @openrouter/sdk zod eventemitter3
npm install ink react  # 可选:仅用于TUI
npm install -D typescript @types/react tsx

Step 3: Create tsconfig.json

步骤3:创建tsconfig.json

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "jsx": "react-jsx",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist"
  },
  "include": ["src"]
}
json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "jsx": "react-jsx",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist"
  },
  "include": ["src"]
}

Step 4: Add Scripts to package.json

步骤4:在package.json中添加脚本

json
{
  "scripts": {
    "start": "tsx src/cli.tsx",
    "start:headless": "tsx src/headless.ts",
    "dev": "tsx watch src/cli.tsx"
  }
}
json
{
  "scripts": {
    "start": "tsx src/cli.tsx",
    "start:headless": "tsx src/headless.ts",
    "dev": "tsx watch src/cli.tsx"
  }
}

File Structure

文件结构

bash
src/
├── agent.ts        # Standalone agent core with hooks
├── tools.ts        # Tool definitions
├── cli.tsx         # Ink TUI (optional interface)
└── headless.ts     # Headless usage example
bash
src/
├── agent.ts        # 带钩子的独立Agent核心
├── tools.ts        # 工具定义
├── cli.tsx         # Ink TUI(可选界面)
└── headless.ts     # 无界面使用示例

Step 1: Agent Core with Hooks

步骤1:带钩子的Agent核心

Create
src/agent.ts
- the standalone agent that can run anywhere:
typescript
import { OpenRouter, tool, stepCountIs } from '@openrouter/sdk';
import type { Tool, StopCondition, StreamableOutputItem } from '@openrouter/sdk';
import { EventEmitter } from 'eventemitter3';
import { z } from 'zod';

// Message types
export interface Message {
  role: 'user' | 'assistant' | 'system';
  content: string;
}

// Agent events for hooks (items-based streaming model)
export interface AgentEvents {
  'message:user': (message: Message) => void;
  'message:assistant': (message: Message) => void;
  'item:update': (item: StreamableOutputItem) => void;  // Items emitted with same ID, replace by ID
  'stream:start': () => void;
  'stream:delta': (delta: string, accumulated: string) => void;
  'stream:end': (fullText: string) => void;
  'tool:call': (name: string, args: unknown) => void;
  'tool:result': (name: string, result: unknown) => void;
  'reasoning:update': (text: string) => void;  // Extended thinking content
  'error': (error: Error) => void;
  'thinking:start': () => void;
  'thinking:end': () => void;
}


// Agent configuration
export interface AgentConfig {
  apiKey: string;
  model?: string;
  instructions?: string;
  tools?: Tool<z.ZodTypeAny, z.ZodTypeAny>[];
  maxSteps?: number;
}

// The Agent class - runs independently of any UI
export class Agent extends EventEmitter<AgentEvents> {
  private client: OpenRouter;
  private messages: Message[] = [];
  private config: Required<Omit<AgentConfig, 'apiKey'>> & { apiKey: string };

  constructor(config: AgentConfig) {
    super();
    this.client = new OpenRouter({ apiKey: config.apiKey });
    this.config = {
      apiKey: config.apiKey,
      model: config.model ?? 'openrouter/auto',
      instructions: config.instructions ?? 'You are a helpful assistant.',
      tools: config.tools ?? [],
      maxSteps: config.maxSteps ?? 5,
    };
  }

  // Get conversation history
  getMessages(): Message[] {
    return [...this.messages];
  }

  // Clear conversation
  clearHistory(): void {
    this.messages = [];
  }

  // Add a system message
  setInstructions(instructions: string): void {
    this.config.instructions = instructions;
  }

  // Register additional tools at runtime
  addTool(newTool: Tool<z.ZodTypeAny, z.ZodTypeAny>): void {
    this.config.tools.push(newTool);
  }

  // Send a message and get streaming response using items-based model
  // Items are emitted multiple times with the same ID but progressively updated content
  // Replace items by their ID rather than accumulating chunks
  async send(content: string): Promise<string> {
    const userMessage: Message = { role: 'user', content };
    this.messages.push(userMessage);
    this.emit('message:user', userMessage);
    this.emit('thinking:start');

    try {
      const result = this.client.callModel({
        model: this.config.model,
        instructions: this.config.instructions,
        input: this.messages.map((m) => ({ role: m.role, content: m.content })),
        tools: this.config.tools.length > 0 ? this.config.tools : undefined,
        stopWhen: [stepCountIs(this.config.maxSteps)],
      });

      this.emit('stream:start');
      let fullText = '';

      // Use getItemsStream() for items-based streaming (recommended)
      // Each item emission is complete - replace by ID, don't accumulate
      for await (const item of result.getItemsStream()) {
        // Emit the item for UI state management (use Map keyed by item.id)
        this.emit('item:update', item);

        switch (item.type) {
          case 'message':
            // Message items contain progressively updated content
            const textContent = item.content?.find((c: { type: string }) => c.type === 'output_text');
            if (textContent && 'text' in textContent) {
              const newText = textContent.text;
              if (newText !== fullText) {
                const delta = newText.slice(fullText.length);
                fullText = newText;
                this.emit('stream:delta', delta, fullText);
              }
            }
            break;
          case 'function_call':
            // Function call arguments stream progressively
            if (item.status === 'completed') {
              this.emit('tool:call', item.name, JSON.parse(item.arguments || '{}'));
            }
            break;
          case 'function_call_output':
            this.emit('tool:result', item.callId, item.output);
            break;
          case 'reasoning':
            // Extended thinking/reasoning content
            const reasoningText = item.content?.find((c: { type: string }) => c.type === 'reasoning_text');
            if (reasoningText && 'text' in reasoningText) {
              this.emit('reasoning:update', reasoningText.text);
            }
            break;
          // Additional item types: web_search_call, file_search_call, image_generation_call
        }
      }

      // Get final text if streaming didn't capture it
      if (!fullText) {
        fullText = await result.getText();
      }

      this.emit('stream:end', fullText);

      const assistantMessage: Message = { role: 'assistant', content: fullText };
      this.messages.push(assistantMessage);
      this.emit('message:assistant', assistantMessage);

      return fullText;
    } catch (err) {
      const error = err instanceof Error ? err : new Error(String(err));
      this.emit('error', error);
      throw error;
    } finally {
      this.emit('thinking:end');
    }
  }

  // Send without streaming (simpler for programmatic use)
  async sendSync(content: string): Promise<string> {
    const userMessage: Message = { role: 'user', content };
    this.messages.push(userMessage);
    this.emit('message:user', userMessage);

    try {
      const result = this.client.callModel({
        model: this.config.model,
        instructions: this.config.instructions,
        input: this.messages.map((m) => ({ role: m.role, content: m.content })),
        tools: this.config.tools.length > 0 ? this.config.tools : undefined,
        stopWhen: [stepCountIs(this.config.maxSteps)],
      });

      const fullText = await result.getText();
      const assistantMessage: Message = { role: 'assistant', content: fullText };
      this.messages.push(assistantMessage);
      this.emit('message:assistant', assistantMessage);

      return fullText;
    } catch (err) {
      const error = err instanceof Error ? err : new Error(String(err));
      this.emit('error', error);
      throw error;
    }
  }
}

// Factory function for easy creation
export function createAgent(config: AgentConfig): Agent {
  return new Agent(config);
}
创建
src/agent.ts
- 可在任意环境运行的独立Agent:
typescript
import { OpenRouter, tool, stepCountIs } from '@openrouter/sdk';
import type { Tool, StopCondition, StreamableOutputItem } from '@openrouter/sdk';
import { EventEmitter } from 'eventemitter3';
import { z } from 'zod';

// 消息类型
export interface Message {
  role: 'user' | 'assistant' | 'system';
  content: string;
}

// 用于钩子的Agent事件(基于项的流模型)
export interface AgentEvents {
  'message:user': (message: Message) => void;
  'message:assistant': (message: Message) => void;
  'item:update': (item: StreamableOutputItem) => void;  // 项使用相同ID发送,需按ID替换
  'stream:start': () => void;
  'stream:delta': (delta: string, accumulated: string) => void;
  'stream:end': (fullText: string) => void;
  'tool:call': (name: string, args: unknown) => void;
  'tool:result': (name: string, result: unknown) => void;
  'reasoning:update': (text: string) => void;  // 扩展思考内容
  'error': (error: Error) => void;
  'thinking:start': () => void;
  'thinking:end': () => void;
}


// Agent配置
export interface AgentConfig {
  apiKey: string;
  model?: string;
  instructions?: string;
  tools?: Tool<z.ZodTypeAny, z.ZodTypeAny>[];
  maxSteps?: number;
}

// Agent类 - 独立于任何UI运行
export class Agent extends EventEmitter<AgentEvents> {
  private client: OpenRouter;
  private messages: Message[] = [];
  private config: Required<Omit<AgentConfig, 'apiKey'>> & { apiKey: string };

  constructor(config: AgentConfig) {
    super();
    this.client = new OpenRouter({ apiKey: config.apiKey });
    this.config = {
      apiKey: config.apiKey,
      model: config.model ?? 'openrouter/auto',
      instructions: config.instructions ?? 'You are a helpful assistant.',
      tools: config.tools ?? [],
      maxSteps: config.maxSteps ?? 5,
    };
  }

  // 获取对话历史
  getMessages(): Message[] {
    return [...this.messages];
  }

  // 清空对话
  clearHistory(): void {
    this.messages = [];
  }

  // 添加系统消息
  setInstructions(instructions: string): void {
    this.config.instructions = instructions;
  }

  // 在运行时注册额外工具
  addTool(newTool: Tool<z.ZodTypeAny, z.ZodTypeAny>): void {
    this.config.tools.push(newTool);
  }

  // 发送消息并使用基于项的模型获取流式响应
  // 项使用相同ID多次发送,需按ID替换而非累积块
  async send(content: string): Promise<string> {
    const userMessage: Message = { role: 'user', content };
    this.messages.push(userMessage);
    this.emit('message:user', userMessage);
    this.emit('thinking:start');

    try {
      const result = this.client.callModel({
        model: this.config.model,
        instructions: this.config.instructions,
        input: this.messages.map((m) => ({ role: m.role, content: m.content })),
        tools: this.config.tools.length > 0 ? this.config.tools : undefined,
        stopWhen: [stepCountIs(this.config.maxSteps)],
      });

      this.emit('stream:start');
      let fullText = '';

      // 使用getItemsStream()进行基于项的流式传输(推荐)
      // 每个项发送都是完整内容 - 按ID替换,不要累积
      for await (const item of result.getItemsStream()) {
        // 发送项用于UI状态管理(使用以item.id为键的Map)
        this.emit('item:update', item);

        switch (item.type) {
          case 'message':
            // 消息项包含逐步更新的内容
            const textContent = item.content?.find((c: { type: string }) => c.type === 'output_text');
            if (textContent && 'text' in textContent) {
              const newText = textContent.text;
              if (newText !== fullText) {
                const delta = newText.slice(fullText.length);
                fullText = newText;
                this.emit('stream:delta', delta, fullText);
              }
            }
            break;
          case 'function_call':
            // 函数调用参数逐步流式传输
            if (item.status === 'completed') {
              this.emit('tool:call', item.name, JSON.parse(item.arguments || '{}'));
            }
            break;
          case 'function_call_output':
            this.emit('tool:result', item.callId, item.output);
            break;
          case 'reasoning':
            // 扩展思考内容
            const reasoningText = item.content?.find((c: { type: string }) => c.type === 'reasoning_text');
            if (reasoningText && 'text' in reasoningText) {
              this.emit('reasoning:update', reasoningText.text);
            }
            break;
          // 其他项类型:web_search_call, file_search_call, image_generation_call
        }
      }

      // 如果流式传输未捕获完整文本,则获取最终文本
      if (!fullText) {
        fullText = await result.getText();
      }

      this.emit('stream:end', fullText);

      const assistantMessage: Message = { role: 'assistant', content: fullText };
      this.messages.push(assistantMessage);
      this.emit('message:assistant', assistantMessage);

      return fullText;
    } catch (err) {
      const error = err instanceof Error ? err : new Error(String(err));
      this.emit('error', error);
      throw error;
    } finally {
      this.emit('thinking:end');
    }
  }

  // 无流式传输发送消息(更适合程序化使用)
  async sendSync(content: string): Promise<string> {
    const userMessage: Message = { role: 'user', content };
    this.messages.push(userMessage);
    this.emit('message:user', userMessage);

    try {
      const result = this.client.callModel({
        model: this.config.model,
        instructions: this.config.instructions,
        input: this.messages.map((m) => ({ role: m.role, content: m.content })),
        tools: this.config.tools.length > 0 ? this.config.tools : undefined,
        stopWhen: [stepCountIs(this.config.maxSteps)],
      });

      const fullText = await result.getText();
      const assistantMessage: Message = { role: 'assistant', content: fullText };
      this.messages.push(assistantMessage);
      this.emit('message:assistant', assistantMessage);

      return fullText;
    } catch (err) {
      const error = err instanceof Error ? err : new Error(String(err));
      this.emit('error', error);
      throw error;
    }
  }
}

// 用于快速创建的工厂函数
export function createAgent(config: AgentConfig): Agent {
  return new Agent(config);
}

Step 2: Define Tools

步骤2:定义工具

Create
src/tools.ts
:
typescript
import { tool } from '@openrouter/sdk';
import { z } from 'zod';

export const timeTool = tool({
  name: 'get_current_time',
  description: 'Get the current date and time',
  inputSchema: z.object({
    timezone: z.string().optional().describe('Timezone (e.g., "UTC", "America/New_York")'),
  }),
  execute: async ({ timezone }) => {
    return {
      time: new Date().toLocaleString('en-US', { timeZone: timezone || 'UTC' }),
      timezone: timezone || 'UTC',
    };
  },
});

export const calculatorTool = tool({
  name: 'calculate',
  description: 'Perform mathematical calculations',
  inputSchema: z.object({
    expression: z.string().describe('Math expression (e.g., "2 + 2", "sqrt(16)")'),
  }),
  execute: async ({ expression }) => {
    // Simple safe eval for basic math
    const sanitized = expression.replace(/[^0-9+\-*/().\s]/g, '');
    const result = Function(`"use strict"; return (${sanitized})`)();
    return { expression, result };
  },
});

export const defaultTools = [timeTool, calculatorTool];
创建
src/tools.ts
typescript
import { tool } from '@openrouter/sdk';
import { z } from 'zod';

export const timeTool = tool({
  name: 'get_current_time',
  description: '获取当前日期和时间',
  inputSchema: z.object({
    timezone: z.string().optional().describe('时区(例如:"UTC", "America/New_York")'),
  }),
  execute: async ({ timezone }) => {
    return {
      time: new Date().toLocaleString('en-US', { timeZone: timezone || 'UTC' }),
      timezone: timezone || 'UTC',
    };
  },
});

export const calculatorTool = tool({
  name: 'calculate',
  description: '执行数学计算',
  inputSchema: z.object({
    expression: z.string().describe('数学表达式(例如:"2 + 2", "sqrt(16)")'),
  }),
  execute: async ({ expression }) => {
    // 用于基础数学的简单安全求值
    const sanitized = expression.replace(/[^0-9+\-*/().\s]/g, '');
    const result = Function(`"use strict"; return (${sanitized})`)();
    return { expression, result };
  },
});

export const defaultTools = [timeTool, calculatorTool];

Step 3: Headless Usage (No UI)

步骤3:无界面使用(无UI)

Create
src/headless.ts
- use the agent programmatically:
typescript
import { createAgent } from './agent.js';
import { defaultTools } from './tools.js';

async function main() {
  const agent = createAgent({
    apiKey: process.env.OPENROUTER_API_KEY!,
    model: 'openrouter/auto',
    instructions: 'You are a helpful assistant with access to tools.',
    tools: defaultTools,
  });

  // Hook into events
  agent.on('thinking:start', () => console.log('\n🤔 Thinking...'));
  agent.on('tool:call', (name, args) => console.log(`🔧 Using ${name}:`, args));
  agent.on('stream:delta', (delta) => process.stdout.write(delta));
  agent.on('stream:end', () => console.log('\n'));
  agent.on('error', (err) => console.error('❌ Error:', err.message));

  // Interactive loop
  const readline = await import('readline');
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });

  console.log('Agent ready. Type your message (Ctrl+C to exit):\n');

  const prompt = () => {
    rl.question('You: ', async (input) => {
      if (!input.trim()) {
        prompt();
        return;
      }
      await agent.send(input);
      prompt();
    });
  };

  prompt();
}

main().catch(console.error);
Run headless:
OPENROUTER_API_KEY=sk-or-... npm run start:headless
创建
src/headless.ts
- 程序化使用Agent:
typescript
import { createAgent } from './agent.js';
import { defaultTools } from './tools.js';

async function main() {
  const agent = createAgent({
    apiKey: process.env.OPENROUTER_API_KEY!,
    model: 'openrouter/auto',
    instructions: 'You are a helpful assistant with access to tools.',
    tools: defaultTools,
  });

  // 监听事件
  agent.on('thinking:start', () => console.log('\n🤔 思考中...'));
  agent.on('tool:call', (name, args) => console.log(`🔧 使用工具 ${name}:`, args));
  agent.on('stream:delta', (delta) => process.stdout.write(delta));
  agent.on('stream:end', () => console.log('\n'));
  agent.on('error', (err) => console.error('❌ 错误:', err.message));

  // 交互式循环
  const readline = await import('readline');
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });

  console.log('Agent已就绪。输入消息(按Ctrl+C退出):\n');

  const prompt = () => {
    rl.question('你: ', async (input) => {
      if (!input.trim()) {
        prompt();
        return;
      }
      await agent.send(input);
      prompt();
    });
  };

  prompt();
}

main().catch(console.error);
运行无界面版本:
OPENROUTER_API_KEY=sk-or-... npm run start:headless

Step 4: Ink TUI (Optional Interface)

步骤4:Ink TUI(可选界面)

Create
src/cli.tsx
- a beautiful terminal UI that uses the agent with items-based streaming:
tsx
import React, { useState, useEffect, useCallback } from 'react';
import { render, Box, Text, useInput, useApp } from 'ink';
import type { StreamableOutputItem } from '@openrouter/sdk';
import { createAgent, type Agent, type Message } from './agent.js';
import { defaultTools } from './tools.js';

// Initialize agent (runs independently of UI)
const agent = createAgent({
  apiKey: process.env.OPENROUTER_API_KEY!,
  model: 'openrouter/auto',
  instructions: 'You are a helpful assistant. Be concise.',
  tools: defaultTools,
});

function ChatMessage({ message }: { message: Message }) {
  const isUser = message.role === 'user';
  return (
    <Box flexDirection="column" marginBottom={1}>
      <Text bold color={isUser ? 'cyan' : 'green'}>
        {isUser ? '▶ You' : '◀ Assistant'}
      </Text>
      <Text wrap="wrap">{message.content}</Text>
    </Box>
  );
}

// Render streaming items by type using the items-based pattern
function ItemRenderer({ item }: { item: StreamableOutputItem }) {
  switch (item.type) {
    case 'message': {
      const textContent = item.content?.find((c: { type: string }) => c.type === 'output_text');
      const text = textContent && 'text' in textContent ? textContent.text : '';
      return (
        <Box flexDirection="column" marginBottom={1}>
          <Text bold color="green">◀ Assistant</Text>
          <Text wrap="wrap">{text}</Text>
          {item.status !== 'completed' && <Text color="gray"></Text>}
        </Box>
      );
    }
    case 'function_call':
      return (
        <Text color="yellow">
          {item.status === 'completed' ? '  ✓' : '  🔧'} {item.name}
          {item.status === 'in_progress' && '...'}
        </Text>
      );
    case 'reasoning': {
      const reasoningText = item.content?.find((c: { type: string }) => c.type === 'reasoning_text');
      const text = reasoningText && 'text' in reasoningText ? reasoningText.text : '';
      return (
        <Box flexDirection="column" marginBottom={1}>
          <Text bold color="magenta">💭 Thinking</Text>
          <Text wrap="wrap" color="gray">{text}</Text>
        </Box>
      );
    }
    default:
      return null;
  }
}

function InputField({
  value,
  onChange,
  onSubmit,
  disabled,
}: {
  value: string;
  onChange: (v: string) => void;
  onSubmit: () => void;
  disabled: boolean;
}) {
  useInput((input, key) => {
    if (disabled) return;
    if (key.return) onSubmit();
    else if (key.backspace || key.delete) onChange(value.slice(0, -1));
    else if (input && !key.ctrl && !key.meta) onChange(value + input);
  });

  return (
    <Box>
      <Text color="yellow">{'> '}</Text>
      <Text>{value}</Text>
      <Text color="gray">{disabled ? ' ···' : '█'}</Text>
    </Box>
  );
}

function App() {
  const { exit } = useApp();
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  // Use Map keyed by item ID for efficient React state updates (items-based pattern)
  const [items, setItems] = useState<Map<string, StreamableOutputItem>>(new Map());

  useInput((_, key) => {
    if (key.escape) exit();
  });

  // Subscribe to agent events using items-based streaming
  useEffect(() => {
    const onThinkingStart = () => {
      setIsLoading(true);
      setItems(new Map()); // Clear items for new response
    };

    // Items-based streaming: replace items by ID, don't accumulate
    const onItemUpdate = (item: StreamableOutputItem) => {
      setItems((prev) => new Map(prev).set(item.id, item));
    };

    const onMessageAssistant = () => {
      setMessages(agent.getMessages());
      setItems(new Map()); // Clear streaming items
      setIsLoading(false);
    };

    const onError = (err: Error) => {
      setIsLoading(false);
    };

    agent.on('thinking:start', onThinkingStart);
    agent.on('item:update', onItemUpdate);
    agent.on('message:assistant', onMessageAssistant);
    agent.on('error', onError);

    return () => {
      agent.off('thinking:start', onThinkingStart);
      agent.off('item:update', onItemUpdate);
      agent.off('message:assistant', onMessageAssistant);
      agent.off('error', onError);
    };
  }, []);

  const sendMessage = useCallback(async () => {
    if (!input.trim() || isLoading) return;
    const text = input.trim();
    setInput('');
    setMessages((prev) => [...prev, { role: 'user', content: text }]);
    await agent.send(text);
  }, [input, isLoading]);

  return (
    <Box flexDirection="column" padding={1}>
      <Box marginBottom={1}>
        <Text bold color="magenta">🤖 OpenRouter Agent</Text>
        <Text color="gray"> (Esc to exit)</Text>
      </Box>

      <Box flexDirection="column" marginBottom={1}>
        {/* Render completed messages */}
        {messages.map((msg, i) => (
          <ChatMessage key={i} message={msg} />
        ))}

        {/* Render streaming items by type (items-based pattern) */}
        {Array.from(items.values()).map((item) => (
          <ItemRenderer key={item.id} item={item} />
        ))}
      </Box>

      <Box borderStyle="single" borderColor="gray" paddingX={1}>
        <InputField
          value={input}
          onChange={setInput}
          onSubmit={sendMessage}
          disabled={isLoading}
        />
      </Box>
    </Box>
  );
}

render(<App />);
Run TUI:
OPENROUTER_API_KEY=sk-or-... npm start
创建
src/cli.tsx
- 使用基于项的流式传输的美观终端UI:
tsx
import React, { useState, useEffect, useCallback } from 'react';
import { render, Box, Text, useInput, useApp } from 'ink';
import type { StreamableOutputItem } from '@openrouter/sdk';
import { createAgent, type Agent, type Message } from './agent.js';
import { defaultTools } from './tools.js';

// 初始化Agent(独立于UI运行)
const agent = createAgent({
  apiKey: process.env.OPENROUTER_API_KEY!,
  model: 'openrouter/auto',
  instructions: 'You are a helpful assistant. Be concise.',
  tools: defaultTools,
});

function ChatMessage({ message }: { message: Message }) {
  const isUser = message.role === 'user';
  return (
    <Box flexDirection="column" marginBottom={1}>
      <Text bold color={isUser ? 'cyan' : 'green'}>
        {isUser ? '▶ 你' : '◀ 助手'}
      </Text>
      <Text wrap="wrap">{message.content}</Text>
    </Box>
  );
}

// 使用基于项的模式按类型渲染流式项
function ItemRenderer({ item }: { item: StreamableOutputItem }) {
  switch (item.type) {
    case 'message': {
      const textContent = item.content?.find((c: { type: string }) => c.type === 'output_text');
      const text = textContent && 'text' in textContent ? textContent.text : '';
      return (
        <Box flexDirection="column" marginBottom={1}>
          <Text bold color="green">◀ 助手</Text>
          <Text wrap="wrap">{text}</Text>
          {item.status !== 'completed' && <Text color="gray"></Text>}
        </Box>
      );
    }
    case 'function_call':
      return (
        <Text color="yellow">
          {item.status === 'completed' ? '  ✓' : '  🔧'} {item.name}
          {item.status === 'in_progress' && '...'}
        </Text>
      );
    case 'reasoning': {
      const reasoningText = item.content?.find((c: { type: string }) => c.type === 'reasoning_text');
      const text = reasoningText && 'text' in reasoningText ? reasoningText.text : '';
      return (
        <Box flexDirection="column" marginBottom={1}>
          <Text bold color="magenta">💭 思考中</Text>
          <Text wrap="wrap" color="gray">{text}</Text>
        </Box>
      );
    }
    default:
      return null;
  }
}

function InputField({
  value,
  onChange,
  onSubmit,
  disabled,
}: {
  value: string;
  onChange: (v: string) => void;
  onSubmit: () => void;
  disabled: boolean;
}) {
  useInput((input, key) => {
    if (disabled) return;
    if (key.return) onSubmit();
    else if (key.backspace || key.delete) onChange(value.slice(0, -1));
    else if (input && !key.ctrl && !key.meta) onChange(value + input);
  });

  return (
    <Box>
      <Text color="yellow">{'> '}</Text>
      <Text>{value}</Text>
      <Text color="gray">{disabled ? ' ···' : '█'}</Text>
    </Box>
  );
}

function App() {
  const { exit } = useApp();
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  // 使用以项ID为键的Map实现高效React状态更新(基于项的模式)
  const [items, setItems] = useState<Map<string, StreamableOutputItem>>(new Map());

  useInput((_, key) => {
    if (key.escape) exit();
  });

  // 使用基于项的流式传输订阅Agent事件
  useEffect(() => {
    const onThinkingStart = () => {
      setIsLoading(true);
      setItems(new Map()); // 为新响应清空项
    };

    // 基于项的流式传输:按ID替换项,不要累积
    const onItemUpdate = (item: StreamableOutputItem) => {
      setItems((prev) => new Map(prev).set(item.id, item));
    };

    const onMessageAssistant = () => {
      setMessages(agent.getMessages());
      setItems(new Map()); // 清空流式项
      setIsLoading(false);
    };

    const onError = (err: Error) => {
      setIsLoading(false);
    };

    agent.on('thinking:start', onThinkingStart);
    agent.on('item:update', onItemUpdate);
    agent.on('message:assistant', onMessageAssistant);
    agent.on('error', onError);

    return () => {
      agent.off('thinking:start', onThinkingStart);
      agent.off('item:update', onItemUpdate);
      agent.off('message:assistant', onMessageAssistant);
      agent.off('error', onError);
    };
  }, []);

  const sendMessage = useCallback(async () => {
    if (!input.trim() || isLoading) return;
    const text = input.trim();
    setInput('');
    setMessages((prev) => [...prev, { role: 'user', content: text }]);
    await agent.send(text);
  }, [input, isLoading]);

  return (
    <Box flexDirection="column" padding={1}>
      <Box marginBottom={1}>
        <Text bold color="magenta">🤖 OpenRouter Agent</Text>
        <Text color="gray"> (按Esc退出)</Text>
      </Box>

      <Box flexDirection="column" marginBottom={1}>
        {/* 渲染已完成的消息 */}
        {messages.map((msg, i) => (
          <ChatMessage key={i} message={msg} />
        ))}

        {/* 按类型渲染流式项(基于项的模式) */}
        {Array.from(items.values()).map((item) => (
          <ItemRenderer key={item.id} item={item} />
        ))}
      </Box>

      <Box borderStyle="single" borderColor="gray" paddingX={1}>
        <InputField
          value={input}
          onChange={setInput}
          onSubmit={sendMessage}
          disabled={isLoading}
        />
      </Box>
    </Box>
  );
}

render(<App />);
运行TUI版本:
OPENROUTER_API_KEY=sk-or-... npm start

Understanding Items-Based Streaming

理解基于项的流式传输

The OpenRouter SDK uses an items-based streaming model - a key paradigm where items are emitted multiple times with the same ID but progressively updated content. Instead of accumulating chunks, you replace items by their ID.
OpenRouter SDK使用基于项的流式传输模型 - 这是一种关键范式,其中项使用相同ID多次发送,但内容逐步更新。无需累积块,你只需按ID替换项

How It Works

工作原理

Each iteration of
getItemsStream()
yields a complete item with updated content:
typescript
// Iteration 1: Partial message
{ id: "msg_123", type: "message", content: [{ type: "output_text", text: "Hello" }] }

// Iteration 2: Updated message (replace, don't append)
{ id: "msg_123", type: "message", content: [{ type: "output_text", text: "Hello world" }] }
For function calls, arguments stream progressively:
typescript
// Iteration 1: Partial arguments
{ id: "call_456", type: "function_call", name: "get_weather", arguments: "{\"q" }

// Iteration 2: Complete arguments
{ id: "call_456", type: "function_call", name: "get_weather", arguments: "{\"query\": \"Paris\"}", status: "completed" }
getItemsStream()
的每次迭代都会生成一个内容已更新的完整项:
typescript
// 迭代1:部分消息
{ id: "msg_123", type: "message", content: [{ type: "output_text", text: "Hello" }] }

// 迭代2:更新后的消息(替换,不要追加)
{ id: "msg_123", type: "message", content: [{ type: "output_text", text: "Hello world" }] }
对于函数调用,参数会逐步流式传输:
typescript
// 迭代1:部分参数
{ id: "call_456", type: "function_call", name: "get_weather", arguments: "{\"q" }

// 迭代2:完整参数
{ id: "call_456", type: "function_call", name: "get_weather", arguments: "{\"query\": \"Paris\"}", status: "completed" }

Why Items Are Better

基于项的优势

Traditional (accumulation required):
typescript
let text = '';
for await (const chunk of result.getTextStream()) {
  text += chunk;  // Manual accumulation
  updateUI(text);
}
Items (complete replacement):
typescript
const items = new Map<string, StreamableOutputItem>();
for await (const item of result.getItemsStream()) {
  items.set(item.id, item);  // Replace by ID
  updateUI(items);
}
Benefits:
  • No manual chunk management - each item is complete
  • Handles concurrent outputs - function calls and messages can stream in parallel
  • Full TypeScript inference for all item types
  • Natural Map-based state works perfectly with React/UI frameworks
传统方式(需要手动累积):
typescript
let text = '';
for await (const chunk of result.getTextStream()) {
  text += chunk;  // 手动累积
  updateUI(text);
}
基于项的方式(完整替换):
typescript
const items = new Map<string, StreamableOutputItem>();
for await (const item of result.getItemsStream()) {
  items.set(item.id, item);  // 按ID替换
  updateUI(items);
}
优势:
  • 无需手动管理块 - 每个项都是完整的
  • 处理并发输出 - 函数调用和消息可并行流式传输
  • 所有项类型的完整TypeScript推断
  • 基于Map的自然状态与React/UI框架完美兼容

Extending the Agent

扩展Agent

Add Custom Hooks

添加自定义钩子

typescript
const agent = createAgent({ apiKey: '...' });

// Log all events
agent.on('message:user', (msg) => {
  saveToDatabase('user', msg.content);
});

agent.on('message:assistant', (msg) => {
  saveToDatabase('assistant', msg.content);
  sendWebhook('new_message', msg);
});

agent.on('tool:call', (name, args) => {
  analytics.track('tool_used', { name, args });
});

agent.on('error', (err) => {
  errorReporting.capture(err);
});
typescript
const agent = createAgent({ apiKey: '...' });

// 记录所有事件
agent.on('message:user', (msg) => {
  saveToDatabase('user', msg.content);
});

agent.on('message:assistant', (msg) => {
  saveToDatabase('assistant', msg.content);
  sendWebhook('new_message', msg);
});

agent.on('tool:call', (name, args) => {
  analytics.track('tool_used', { name, args });
});

agent.on('error', (err) => {
  errorReporting.capture(err);
});

Use with HTTP Server

与HTTP服务器配合使用

typescript
import express from 'express';
import { createAgent } from './agent.js';

const app = express();
app.use(express.json());

// One agent per session (store in memory or Redis)
const sessions = new Map<string, Agent>();

app.post('/chat', async (req, res) => {
  const { sessionId, message } = req.body;

  let agent = sessions.get(sessionId);
  if (!agent) {
    agent = createAgent({ apiKey: process.env.OPENROUTER_API_KEY! });
    sessions.set(sessionId, agent);
  }

  const response = await agent.sendSync(message);
  res.json({ response, history: agent.getMessages() });
});

app.listen(3000);
typescript
import express from 'express';
import { createAgent } from './agent.js';

const app = express();
app.use(express.json());

// 每个会话一个Agent(存储在内存或Redis中)
const sessions = new Map<string, Agent>();

app.post('/chat', async (req, res) => {
  const { sessionId, message } = req.body;

  let agent = sessions.get(sessionId);
  if (!agent) {
    agent = createAgent({ apiKey: process.env.OPENROUTER_API_KEY! });
    sessions.set(sessionId, agent);
  }

  const response = await agent.sendSync(message);
  res.json({ response, history: agent.getMessages() });
});

app.listen(3000);

Use with Discord

与Discord配合使用

typescript
import { Client, GatewayIntentBits } from 'discord.js';
import { createAgent } from './agent.js';

const discord = new Client({
  intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
});

const agents = new Map<string, Agent>();

discord.on('messageCreate', async (msg) => {
  if (msg.author.bot) return;

  let agent = agents.get(msg.channelId);
  if (!agent) {
    agent = createAgent({ apiKey: process.env.OPENROUTER_API_KEY! });
    agents.set(msg.channelId, agent);
  }

  const response = await agent.sendSync(msg.content);
  await msg.reply(response);
});

discord.login(process.env.DISCORD_TOKEN);
typescript
import { Client, GatewayIntentBits } from 'discord.js';
import { createAgent } from './agent.js';

const discord = new Client({
  intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
});

const agents = new Map<string, Agent>();

discord.on('messageCreate', async (msg) => {
  if (msg.author.bot) return;

  let agent = agents.get(msg.channelId);
  if (!agent) {
    agent = createAgent({ apiKey: process.env.OPENROUTER_API_KEY! });
    agents.set(msg.channelId, agent);
  }

  const response = await agent.sendSync(msg.content);
  await msg.reply(response);
});

discord.login(process.env.DISCORD_TOKEN);

Agent API Reference

Agent API参考

Constructor Options

构造函数选项

OptionTypeDefaultDescription
apiKeystringrequiredOpenRouter API key
modelstring'openrouter/auto'Model to use
instructionsstring'You are a helpful assistant.'System prompt
toolsTool[][]Available tools
maxStepsnumber5Max agentic loop iterations
选项类型默认值描述
apiKeystring必填OpenRouter API密钥
modelstring'openrouter/auto'使用的模型
instructionsstring'You are a helpful assistant.'系统提示词
toolsTool[][]可用工具
maxStepsnumber5Agent循环的最大迭代次数

Methods

方法

MethodReturnsDescription
send(content)
Promise<string>Send message with streaming
sendSync(content)
Promise<string>Send message without streaming
getMessages()
Message[]Get conversation history
clearHistory()
voidClear conversation
setInstructions(text)
voidUpdate system prompt
addTool(tool)
voidAdd tool at runtime
方法返回值描述
send(content)
Promise<string>发送消息并获取流式响应
sendSync(content)
Promise<string>发送消息但不使用流式传输
getMessages()
Message[]获取对话历史
clearHistory()
void清空对话
setInstructions(text)
void更新系统提示词
addTool(tool)
void在运行时添加工具

Events

事件

EventPayloadDescription
message:user
MessageUser message added
message:assistant
MessageAssistant response complete
item:update
StreamableOutputItemItem emitted (replace by ID, don't accumulate)
stream:start
-Streaming started
stream:delta
(delta, accumulated)New text chunk
stream:end
fullTextStreaming complete
tool:call
(name, args)Tool being called
tool:result
(name, result)Tool returned result
reasoning:update
textExtended thinking content
thinking:start
-Agent processing
thinking:end
-Agent done processing
error
ErrorError occurred
事件负载描述
message:user
Message添加用户消息
message:assistant
Message助手响应完成
item:update
StreamableOutputItem发送项(按ID替换,不要累积)
stream:start
-流式传输开始
stream:delta
(delta, accumulated)新文本块
stream:end
fullText流式传输完成
tool:call
(name, args)正在调用工具
tool:result
(name, result)工具返回结果
reasoning:update
text扩展思考内容
thinking:start
-Agent正在处理
thinking:end
-Agent处理完成
error
Error发生错误

Item Types (from getItemsStream)

项类型(来自getItemsStream)

The SDK uses an items-based streaming model where items are emitted multiple times with the same ID but progressively updated content. Replace items by their ID rather than accumulating chunks.
TypePurpose
message
Assistant text responses
function_call
Tool invocations with streaming arguments
function_call_output
Results from executed tools
reasoning
Extended thinking content
web_search_call
Web search operations
file_search_call
File search operations
image_generation_call
Image generation operations
SDK使用基于项的流式传输模型,其中项使用相同ID多次发送,但内容逐步更新。请按ID替换项而非累积块。
类型用途
message
助手文本响应
function_call
带流式参数的工具调用
function_call_output
已执行工具的结果
reasoning
扩展思考内容
web_search_call
网页搜索操作
file_search_call
文件搜索操作
image_generation_call
图片生成操作

Discovering Models

模型发现

Do not hardcode model IDs - they change frequently. Use the models API:
不要硬编码模型ID - 它们会频繁更改。请使用模型API:

Fetch Available Models

获取可用模型

typescript
interface OpenRouterModel {
  id: string;
  name: string;
  description?: string;
  context_length: number;
  pricing: { prompt: string; completion: string };
  top_provider?: { is_moderated: boolean };
}

async function fetchModels(): Promise<OpenRouterModel[]> {
  const res = await fetch('https://openrouter.ai/api/v1/models');
  const data = await res.json();
  return data.data;
}

// Find models by criteria
async function findModels(filter: {
  author?: string;      // e.g., 'anthropic', 'openai', 'google'
  minContext?: number;  // e.g., 100000 for 100k context
  maxPromptPrice?: number; // e.g., 0.001 for cheap models
}): Promise<OpenRouterModel[]> {
  const models = await fetchModels();

  return models.filter((m) => {
    if (filter.author && !m.id.startsWith(filter.author + '/')) return false;
    if (filter.minContext && m.context_length < filter.minContext) return false;
    if (filter.maxPromptPrice) {
      const price = parseFloat(m.pricing.prompt);
      if (price > filter.maxPromptPrice) return false;
    }
    return true;
  });
}

// Example: Get latest Claude models
const claudeModels = await findModels({ author: 'anthropic' });
console.log(claudeModels.map((m) => m.id));

// Example: Get models with 100k+ context
const longContextModels = await findModels({ minContext: 100000 });

// Example: Get cheap models
const cheapModels = await findModels({ maxPromptPrice: 0.0005 });
typescript
interface OpenRouterModel {
  id: string;
  name: string;
  description?: string;
  context_length: number;
  pricing: { prompt: string; completion: string };
  top_provider?: { is_moderated: boolean };
}

async function fetchModels(): Promise<OpenRouterModel[]> {
  const res = await fetch('https://openrouter.ai/api/v1/models');
  const data = await res.json();
  return data.data;
}

// 按条件筛选模型
async function findModels(filter: {
  author?: string;      // 例如:'anthropic', 'openai', 'google'
  minContext?: number;  // 例如:100000表示100k上下文
  maxPromptPrice?: number; // 例如:0.001表示低成本模型
}): Promise<OpenRouterModel[]> {
  const models = await fetchModels();

  return models.filter((m) => {
    if (filter.author && !m.id.startsWith(filter.author + '/')) return false;
    if (filter.minContext && m.context_length < filter.minContext) return false;
    if (filter.maxPromptPrice) {
      const price = parseFloat(m.pricing.prompt);
      if (price > filter.maxPromptPrice) return false;
    }
    return true;
  });
}

// 示例:获取最新Claude模型
const claudeModels = await findModels({ author: 'anthropic' });
console.log(claudeModels.map((m) => m.id));

// 示例:获取上下文长度100k+的模型
const longContextModels = await findModels({ minContext: 100000 });

// 示例:获取低成本模型
const cheapModels = await findModels({ maxPromptPrice: 0.0005 });

Dynamic Model Selection in Agent

在Agent中动态选择模型

typescript
// Create agent with dynamic model selection
const models = await fetchModels();
const bestModel = models.find((m) => m.id.includes('claude')) || models[0];

const agent = createAgent({
  apiKey: process.env.OPENROUTER_API_KEY!,
  model: bestModel.id,  // Use discovered model
  instructions: 'You are a helpful assistant.',
});
typescript
// 创建支持动态模型选择的Agent
const models = await fetchModels();
const bestModel = models.find((m) => m.id.includes('claude')) || models[0];

const agent = createAgent({
  apiKey: process.env.OPENROUTER_API_KEY!,
  model: bestModel.id,  // 使用发现的模型
  instructions: 'You are a helpful assistant.',
});

Using openrouter/auto

使用openrouter/auto

For simplicity, use
openrouter/auto
which automatically selects the best available model for your request:
typescript
const agent = createAgent({
  apiKey: process.env.OPENROUTER_API_KEY!,
  model: 'openrouter/auto',  // Auto-selects best model
});
为简化使用,可使用
openrouter/auto
,它会自动为你的请求选择最佳可用模型:
typescript
const agent = createAgent({
  apiKey: process.env.OPENROUTER_API_KEY!,
  model: 'openrouter/auto',  // 自动选择最佳模型
});

Models API Reference

模型API参考

Resources

资源