Skip to content

对于发送邮件、支付、删除记录等高风险操作,OpenRouter SDK 提供 requireApproval 审批机制和 StateAccessor 状态持久化,让 agent 在执行敏感 tool 前暂停等待用户确认,即使跨多个 HTTP 请求也能保持完整对话历史。本页包含完整示例和多轮对话实现方案。

为什么需要审批机制?

有些 tool 不应该自动执行——发邮件、付款、删记录等操作需要人工确认。SDK 提供两个机制:

  • requireApproval — 模型调用敏感 tool 时暂停执行,让用户审批或拒绝
  • StateAccessor — 在多次 callModel 调用之间持久化对话状态,审批决定、消息历史、tool 结果都能跨请求保留

两者配合,实现 human-in-the-loop 工作流——用户在 tool 执行前审批每次调用,即使跨 HTTP 请求也没问题。

Tool 级别审批

在 tool 定义上直接设置 requireApproval,接受布尔值或函数:

始终需要审批

typescript
import { tool } from '@openrouter/agent';
import { z } from 'zod';

const sendEmailTool = tool({
  name: 'send_email',
  description: 'Send an email to a recipient',
  inputSchema: z.object({
    to: z.string().email(),
    subject: z.string(),
    body: z.string(),
  }),
  outputSchema: z.object({ sent: z.boolean() }),
  requireApproval: true,
  execute: async (params) => {
    await sendEmail(params);
    return { sent: true };
  },
});

条件审批

用函数按情况决定是否需要审批:

typescript
const deleteRecordTool = tool({
  name: 'delete_record',
  description: 'Delete a record from the database',
  inputSchema: z.object({
    id: z.string(),
    environment: z.enum(['staging', 'production']),
  }),
  outputSchema: z.object({ deleted: z.boolean() }),
  requireApproval: (params, context) => {
    // 只有生产环境的删除操作才需要审批
    return params.environment === 'production';
  },
  execute: async (params) => {
    await deleteRecord(params.id);
    return { deleted: true };
  },
});

Call 级别审批

callModel 上设置 requireApproval 回调,优先级高于 tool 级别设置:

typescript
const result = openrouter.callModel({
  model: 'openai/gpt-4o',
  input: 'Send an email and search for documents',
  tools: [sendEmailTool, searchTool],
  state: myStateAccessor,
  requireApproval: (toolCall, context) => {
    return toolCall.name === 'send_email' || toolCall.name === 'delete_record';
  },
});

审批流程

当带审批门控的 tool 被模型调用时:

  1. 模型生成 tool 调用 — 模型决定调用哪些 tool
  2. SDK 分组 tool 调用 — 按 requireApproval 分成需要审批和可自动执行两组
  3. 自动执行 tool 立即运行 — 不需要审批的 tool 并行执行
  4. 保存带 pending 审批的状态 — 对话状态更新为 status: 'awaiting_approval'
  5. 控制权返回调用方 — 检查 result.requiresApproval(),通过 result.getPendingToolCalls() 查看待审批的调用
  6. 传入决定后恢复 — 携带相同 state 再次调用 callModel,传入 approveToolCalls 和/或 rejectToolCalls 数组
  7. 已批准的 tool 执行 — SDK 运行已批准的 tool,向模型发送结果
  8. 对话继续 — 模型处理 tool 结果,生成下一个响应

StateAccessor 接口

StateAccessor 支持任意存储后端:

typescript
import type { StateAccessor, ConversationState } from '@openrouter/agent';

interface StateAccessor<TTools> {
  /** 加载当前对话状态,不存在时返回 null */
  load: () => Promise<ConversationState<TTools> | null>;
  /** 保存对话状态 */
  save: (state: ConversationState<TTools>) => Promise<void>;
}

内存实现(示例)

typescript
const conversations = new Map<string, ConversationState>();

function createStateAccessor(conversationId: string): StateAccessor {
  return {
    load: async () => conversations.get(conversationId) ?? null,
    save: async (state) => {
      conversations.set(conversationId, state);
    },
  };
}

生产环境请用 Redis、数据库或文件存储等持久化后端,以保证进程重启后状态不丢失。

ConversationState 结构

字段类型说明
idstring唯一对话标识符
messagesOpenResponsesInputUnion完整消息历史
previousResponseIdstring?服务端链式调用的前序响应 ID
pendingToolCallsParsedToolCall[]?待人工审批的 tool 调用
unsentToolResultsUnsentToolResult[]?已执行但未发给模型的结果
statusConversationStatus当前对话状态
createdAtnumber创建时间戳(Unix ms)
updatedAtnumber最后更新时间戳(Unix ms)

Status 值

状态含义
'in_progress'对话正在处理中
'awaiting_approval'已暂停,等待 tool 调用审批/拒绝
'complete'对话正常完成
'interrupted'对话被中断,可恢复

完整示例

typescript
import { OpenRouter, tool } from '@openrouter/agent';
import type { ConversationState, StateAccessor } from '@openrouter/agent';
import { z } from 'zod';

// 1. 定义需要审批的 tool
const sendEmailTool = tool({
  name: 'send_email',
  description: 'Send an email',
  inputSchema: z.object({
    to: z.string().email(),
    subject: z.string(),
    body: z.string(),
  }),
  outputSchema: z.object({ sent: z.boolean(), messageId: z.string() }),
  requireApproval: true,
  execute: async (params) => {
    const result = await sendEmail(params);
    return { sent: true, messageId: result.id };
  },
});

// 2. 创建 state accessor(这里用内存版本)
const store = new Map<string, ConversationState>();
const conversationId = 'conv-123';

const state: StateAccessor = {
  load: async () => store.get(conversationId) ?? null,
  save: async (s) => { store.set(conversationId, s); },
};

const openrouter = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY });

// 3. 第一次 callModel — 模型尝试调用 tool
const result = openrouter.callModel({
  model: 'openai/gpt-4o',
  input: 'Send a welcome email to alice@example.com',
  tools: [sendEmailTool] as const,
  state,
});

// 4. 检查是否需要审批
if (await result.requiresApproval()) {
  const pending = await result.getPendingToolCalls();

  for (const call of pending) {
    console.log(`Tool: ${call.name}`);
    console.log(`To: ${call.arguments.to}`);
    console.log(`ID: ${call.id}`);
  }

  // 5. 展示给用户决定
  const approved = await askUserForApproval(pending);
  const approvedIds = approved.filter(a => a.decision === 'approve').map(a => a.id);
  const rejectedIds = approved.filter(a => a.decision === 'reject').map(a => a.id);

  // 6. 第二次 callModel — 携带审批决定恢复执行
  const resumed = openrouter.callModel({
    model: 'openai/gpt-4o',
    input: [], // 恢复执行不需要新的用户输入
    tools: [sendEmailTool] as const,
    state,
    approveToolCalls: approvedIds,
    rejectToolCalls: rejectedIds,
  });

  // 7. 获取最终响应
  const text = await resumed.getText();
  console.log(text);
  // "I've sent the welcome email to alice@example.com."
} else {
  // 无需审批,tool 已自动执行
  const text = await result.getText();
  console.log(text);
}

恢复模式

从待审批状态恢复

typescript
const loaded = await state.load();

if (loaded?.status === 'awaiting_approval') {
  const pending = loaded.pendingToolCalls ?? [];

  const result = openrouter.callModel({
    model: 'openai/gpt-4o',
    input: [],
    tools: [sendEmailTool] as const,
    state,
    approveToolCalls: pending.map(c => c.id),
  });

  const text = await result.getText();
}

多轮对话

使用相同 StateAccessor 的多次 callModel 调用会自动积累消息历史:

typescript
const state: StateAccessor = createStateAccessor('conv-456');

// 第 1 轮
const r1 = openrouter.callModel({
  model: 'openai/gpt-4o',
  input: 'What is the weather in Tokyo?',
  tools: [weatherTool] as const,
  state,
});
console.log(await r1.getText());
// "The weather in Tokyo is 22°C and sunny."

// 第 2 轮 — state 包含第 1 轮的完整历史
const r2 = openrouter.callModel({
  model: 'openai/gpt-4o',
  input: 'And in Paris?',
  tools: [weatherTool] as const,
  state,
});
console.log(await r2.getText());
// "The weather in Paris is 15°C and cloudy."

// 第 3 轮 — 包含前两轮历史,可以做跨轮推理
const r3 = openrouter.callModel({
  model: 'openai/gpt-4o',
  input: 'Which city is warmer?',
  tools: [weatherTool] as const,
  state,
});
console.log(await r3.getText());
// "Tokyo is warmer at 22°C compared to Paris at 15°C."

常见问题

Q: requireApproval 的 tool 级别和 call 级别设置冲突时谁优先?

A: Call 级别(callModel 上的 requireApproval 回调)优先级更高,会覆盖 tool 定义上的 requireApproval 设置。

Q: 用户拒绝一个 tool 调用后,模型如何响应?

A: SDK 会向模型发送一条错误消息,说明 tool 调用被拒绝。模型通常会相应地调整回复,例如告知用户操作未执行。

Q: StateAccessor 必须用数据库实现吗?

A: 不强制。StateAccessor 只是一个包含 loadsave 方法的接口,内存、文件、Redis、数据库都可以作为后端。生产环境建议用持久化存储,保证进程重启后状态不丢失。