create-agent
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseBuild 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 tsxbash
npm install @openrouter/sdk zod eventemitter3
npm install ink react # 可选:仅用于TUI
npm install -D typescript @types/react tsxStep 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 examplebash
src/
├── agent.ts # 带钩子的独立Agent核心
├── tools.ts # 工具定义
├── cli.tsx # Ink TUI(可选界面)
└── headless.ts # 无界面使用示例Step 1: Agent Core with Hooks
步骤1:带钩子的Agent核心
Create - the standalone agent that can run anywhere:
src/agent.tstypescript
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);
}创建 - 可在任意环境运行的独立Agent:
src/agent.tstypescript
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.tstypescript
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.tstypescript
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 - use the agent programmatically:
src/headless.tstypescript
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创建 - 程序化使用Agent:
src/headless.tstypescript
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:headlessStep 4: Ink TUI (Optional Interface)
步骤4:Ink TUI(可选界面)
Create - a beautiful terminal UI that uses the agent with items-based streaming:
src/cli.tsxtsx
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创建 - 使用基于项的流式传输的美观终端UI:
src/cli.tsxtsx
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 startUnderstanding 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 yields a complete item with updated content:
getItemsStream()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
构造函数选项
| Option | Type | Default | Description |
|---|---|---|---|
| apiKey | string | required | OpenRouter API key |
| model | string | 'openrouter/auto' | Model to use |
| instructions | string | 'You are a helpful assistant.' | System prompt |
| tools | Tool[] | [] | Available tools |
| maxSteps | number | 5 | Max agentic loop iterations |
| 选项 | 类型 | 默认值 | 描述 |
|---|---|---|---|
| apiKey | string | 必填 | OpenRouter API密钥 |
| model | string | 'openrouter/auto' | 使用的模型 |
| instructions | string | 'You are a helpful assistant.' | 系统提示词 |
| tools | Tool[] | [] | 可用工具 |
| maxSteps | number | 5 | Agent循环的最大迭代次数 |
Methods
方法
| Method | Returns | Description |
|---|---|---|
| Promise<string> | Send message with streaming |
| Promise<string> | Send message without streaming |
| Message[] | Get conversation history |
| void | Clear conversation |
| void | Update system prompt |
| void | Add tool at runtime |
| 方法 | 返回值 | 描述 |
|---|---|---|
| Promise<string> | 发送消息并获取流式响应 |
| Promise<string> | 发送消息但不使用流式传输 |
| Message[] | 获取对话历史 |
| void | 清空对话 |
| void | 更新系统提示词 |
| void | 在运行时添加工具 |
Events
事件
| Event | Payload | Description |
|---|---|---|
| Message | User message added |
| Message | Assistant response complete |
| StreamableOutputItem | Item emitted (replace by ID, don't accumulate) |
| - | Streaming started |
| (delta, accumulated) | New text chunk |
| fullText | Streaming complete |
| (name, args) | Tool being called |
| (name, result) | Tool returned result |
| text | Extended thinking content |
| - | Agent processing |
| - | Agent done processing |
| Error | Error occurred |
| 事件 | 负载 | 描述 |
|---|---|---|
| Message | 添加用户消息 |
| Message | 助手响应完成 |
| StreamableOutputItem | 发送项(按ID替换,不要累积) |
| - | 流式传输开始 |
| (delta, accumulated) | 新文本块 |
| fullText | 流式传输完成 |
| (name, args) | 正在调用工具 |
| (name, result) | 工具返回结果 |
| text | 扩展思考内容 |
| - | Agent正在处理 |
| - | Agent处理完成 |
| 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.
| Type | Purpose |
|---|---|
| Assistant text responses |
| Tool invocations with streaming arguments |
| Results from executed tools |
| Extended thinking content |
| Web search operations |
| File search operations |
| Image generation operations |
SDK使用基于项的流式传输模型,其中项使用相同ID多次发送,但内容逐步更新。请按ID替换项而非累积块。
| 类型 | 用途 |
|---|---|
| 助手文本响应 |
| 带流式参数的工具调用 |
| 已执行工具的结果 |
| 扩展思考内容 |
| 网页搜索操作 |
| 文件搜索操作 |
| 图片生成操作 |
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 which automatically selects the best
available model for your request:
openrouter/autotypescript
const agent = createAgent({
apiKey: process.env.OPENROUTER_API_KEY!,
model: 'openrouter/auto', // Auto-selects best model
});为简化使用,可使用,它会自动为你的请求选择最佳可用模型:
openrouter/autotypescript
const agent = createAgent({
apiKey: process.env.OPENROUTER_API_KEY!,
model: 'openrouter/auto', // 自动选择最佳模型
});Models API Reference
模型API参考
- Endpoint:
GET https://openrouter.ai/api/v1/models - Response:
{ data: OpenRouterModel[] } - Browse models: https://openrouter.ai/models
- 端点:
GET https://openrouter.ai/api/v1/models - 响应:
{ data: OpenRouterModel[] } - 浏览模型: https://openrouter.ai/models
Resources
资源
- OpenRouter Docs: https://openrouter.ai/docs
- Models API: https://openrouter.ai/api/v1/models
- Ink Docs: https://github.com/vadimdemedes/ink
- Get API Key: https://openrouter.ai/settings/keys
- OpenRouter文档: https://openrouter.ai/docs
- 模型API: https://openrouter.ai/api/v1/models
- Ink文档: https://github.com/vadimdemedes/ink
- 获取API密钥: https://openrouter.ai/settings/keys