服务端工具体系:让工具跑在 OpenRouter 那边而不是你这边
接工具调用接久了会养成一个下意识的假设:模型只负责”提议”,真正跑代码的永远是我自己的进程。OpenRouter 的服务端工具(Server Tools)把这个假设打破了一半——有些工具它替你跑完再把结果塞回模型,有些工具它只帮你检查一遍语法,坚决不动手。麻烦的是这两类长得几乎一样,都写在同一个 tools 数组里,都带 openrouter: 前缀。
所以这篇只回答一个问题:一次请求里,某个工具到底是谁执行的? 顺着官方文档《Server Tools》那一页给的路径走一遍,把分界线所在的字段一个个标出来。
先把最重要的一条放前面:官方文档在这一整组页面的顶部都挂着 Beta 标记,并写明「Server tools are currently in beta. The API and behavior may change.」——API 与行为都可能变。下面所有字段名都以官方文档最新内容为准。
三类东西挤在同一个请求里
文档开篇给了一张对照表,这张表基本上就是本文的地基:
| Server Tools | Plugins | User-Defined Tools | |
|---|---|---|---|
| Who decides to use it | The model | Always runs | The model |
| Who executes it | OpenRouter | OpenRouter | Your application |
| Call frequency | 0 to N times per request | Once per request | 0 to N times per request |
| Specified via | tools array | plugins array | tools array |
| Type prefix | openrouter:* | N/A | function |
值得盯住的是第二行和第四行的组合。服务端工具和你自己的函数工具放在同一个 tools 数组里,靠 type 区分:openrouter:* 前缀的由 OpenRouter 执行,function 类型的由你的应用执行。插件则完全另起炉灶走 plugins 数组,而且不是模型决定要不要用——文档写的是 always runs,每次请求跑一次。
文档给的混用示例是这样的(openrouter:web_search、openrouter:datetime 与一个自定义的 get_stock_price 同框):
{
"model": "openai/gpt-5.2",
"messages": [...],
"tools": [
{ "type": "openrouter:web_search", "parameters": { "max_results": 3 } },
{ "type": "openrouter:datetime" },
{
"type": "function",
"function": {
"name": "get_stock_price",
"description": "Get the current stock price for a ticker symbol",
"parameters": {
"type": "object",
"properties": {
"ticker": { "type": "string" }
},
"required": ["ticker"]
}
}
}
]
}
示例里的 model slug 只是官方文档当时的示例值,平台上有哪些模型随时在变,不要当清单用。
执行流程文档写了四步:你把服务端工具放进 tools;模型自己决定要不要调;OpenRouter 拦下这次调用、在服务端执行、把结果返回给模型;模型拿结果继续,必要时再调一次。你的代码在第三步是完全不参与的——这既是省事的地方,也是排查问题时最容易迷路的地方,因为中间那一段不在你的日志里。
用量方面,文档写明服务端工具的调用次数会体现在响应的 usage 对象下的 server_tool_use 里(示例中的字段是 web_search_requests)。这是你在自己进程之外唯一能拿到的执行痕迹。
外层还有一圈 step budget
服务端工具一旦启用,整个请求就跑在一个带步数预算的 agent loop 里。文档写明有两个顶层请求字段控制外圈,它们是 messages 和 tools 的兄弟字段,不是工具的 parameters:
max_tool_calls:整个请求允许的服务端工具步数总额,跨所有服务端工具共享;文档写明有默认值,且该默认值同时就是上限。stop_server_tools_when:一组停止条件(文档举例包括步数、花费上限等)。一旦设置,它会覆盖max_tool_calls。
预算耗尽时会发生什么,文档也写了:模型会被要求用已经收集到的上下文产出最终答案。文档在这里只写了这一句,至于此时响应里还会不会带别的信号,官方文档没有说明这一点。另外 Fusion、Advisor、Subagent 这三个自带内层循环的工具,各有一份配置在工具 parameters 里的独立预算——同名字段 max_tool_calls 在顶层和工具内层语义不同,放错层是很容易犯的错。具体的默认值与上限我不抄进来,这类数值改起来毫无成本,请以官方文档为准。
分水岭一:apply_patch 只验语法,绝不落盘
openrouter:apply_patch 是这套体系里最反直觉的一个。它挂着 openrouter: 前缀,看起来像”服务端执行”,但文档明确称它是 human-in-the-loop 工具,原文写得很硬:OpenRouter validates the diff but never applies it,你的应用负责执行文件操作。
文档描述的路径是:模型生成一份 V4A diff → OpenRouter 校验补丁语法(行前缀是否正确、标记是否合法、路径是否非空)→ 校验通过则作为 apply_patch_call 输出项返回给你的应用 → 你的应用把补丁打到文件系统上 → 下一轮用 apply_patch_call_output 把结果回声给模型;校验失败则把错误返回给模型,让它自己改。
回声那一步的字段表很短,但漏一个就接不上:
| 字段 | 类型 | 说明 |
|---|---|---|
call_id | string | 必须与 apply_patch_call 里的 call_id 一致 |
status | "completed" 或 "failed" | 补丁是否成功应用 |
output | string(可选) | 人类可读的操作日志 |
操作类型文档列了三种,都挂在 apply_patch_call 的 operation 字段上:create_file(diff 里每一行内容都必须以 + 开头)、update_file(带空格前缀的上下文行、+ 增、- 删)、delete_file(不需要 diff,只要路径)。
边界要照实标:文档写明 apply patch 只在 Responses API 上可用,Chat Completions API 不支持。另外它的 engine 参数有三档(auto / native / openrouter),文档说明 native passthrough 会通过 response.apply_patch_call_operation_diff.delta 事件增量流式输出 diff,而这条路径目前只在 OpenAI 端点上支持;其它模型走 HITL 校验器,把完整 diff 缓冲成单个原子输出项返回。
一句话记住这个工具的位置:它把”写文件”这个动作留在了你的机器上,只把”这份 diff 格式对不对”这件事挪到了服务端。
分水岭二:shell 反过来,压根没有本地模式
openrouter:shell 是另一个极端。文档写得也很直白:Unlike the Bash tool, the shell tool has no client-side execution mode——命令永远在托管环境里跑,要么是 OpenAI 自己的原生 shell,要么是 OpenRouter 的 sandbox。
它的配置块原文如下:
{
"type": "openrouter:shell",
"parameters": {
"engine": "openrouter",
"environment": { "type": "container_auto" }
}
}
engine 取 openrouter 表示在 OpenRouter 的 sandbox 里跑;取 auto 则在供应商有原生托管 shell 时(文档点名 OpenAI)沿用原生的,其它供应商上路由到 OpenRouter sandbox。environment 用 { "type": "container_auto" } 拿一个由 OpenRouter 托管的临时容器,或者用 { "type": "container_reference", "container_id": "..." } 复用已有容器。这一栏里有一句必须划重点:local environments are not supported——你不能让它在本机跑。想在本机执行,那就得走 function 类型的自定义工具,不是这条路。
模型生成的调用参数镜像 OpenAI hosted shell 的 shell_call.action:commands(按顺序执行,每条一次独立调用)、timeout_ms(作用于每条命令)、max_output_length(每条流的字符上限,stdout 和 stderr 各自受限)。返回则是每条命令一个条目,带 stdout、stderr 和 outcome,后者是 { "type": "exit", "exit_code": <int> } 或 { "type": "timeout" }。
响应形状随 API 而变,这一点很坑:文档写明 shell 只在 Responses API 与 Messages API 上可用,在 Chat Completions API 上请求它会返回 400。而且两个 API 的呈现方式不同——Responses API 上它是 openrouter:shell 输出项(若你发的是 OpenAI 的原生工具形状,则是原生的 shell_call);Messages API 上它是一个名为 openrouter:shell 的 server_tool_use 内容块,配一个 openrouter_shell_tool_result 块承载输出。文档还专门解释了后者为什么带命名空间:Anthropic 没有定义原生的 shell result block,所以输出落在这个 OpenRouter 自定义的块上(这是文档自述)。解析代码按 API 分叉,别指望一套解析器通吃。
关于安全,只说文档能核到的
文档的 Security 一节写了这么几条:命令在隔离容器中执行,不在 OpenRouter 基础设施上,也不在你的机器上;container_auto 是临时容器,container_reference 会跨请求持续存在;容器按账户与 workspace 划分,不会跨租户共享;执行时长由 timeout_ms 约束并受服务端最大值 clamp;stdout 与 stderr 各自按 max_output_length 截断,该值同样受服务端上限 clamp。
这些是文档写明的机制边界,不等于”开了就没事”。有几点值得你自己掂量:container_reference 是持久的,上一次请求在容器里留下的文件与缓存会被下一次请求看到;命令由模型生成,意味着提示注入的内容有机会变成真实执行的命令;容器能不能访问外网、能访问到什么,官方文档在这一页里没有说明这一点,别默认它是封闭的。
以下为通用运维做法,不是 OpenRouter 官方文档的内容:任何要进容器的凭证都当作已泄露来管理,用一次性的、权限最小的密钥;把 container_reference 的复用范围限制在同一个信任域内的任务上。安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。
Windows 侧要多留一句:文档写明 sandbox 是 isolated Linux container,所以不管你的开发机是 Windows 还是 macOS,模型生成的命令都会按 Linux 语义执行,不要按 PowerShell 或 cmd 的习惯去预期路径分隔符和命令名。另外在 Windows 上照抄文档里的 cURL 示例时,PowerShell 与 cmd 对单引号、内嵌双引号的处理和 bash 不同,把请求体写进 .json 文件再用 -d @<你的文件名>.json 是更省事的做法——这一条同样属于通用做法,不是官方文档内容。
分水岭三:subagent 那条把执行权交回来的窄路
openrouter:subagent 和 openrouter:advisor 又是一种切法:任务在服务端由另一个模型执行,但它们能用的工具被卡死了。两页文档口径一致——嵌套 tools 里只允许 OpenRouter 服务端工具,直接放 { "type": "function" } 会被 400 拒绝,理由文档写得很明确:这些子调用没有客户端执行器,函数工具的调用永远不可能被兑现。
上下文的切分也要注意。subagent 文档写明每个任务都是独立的:worker 只看得到 task_description,看不到父对话,任务之间也不保留记忆;所以模型被要求把全部上下文和期望的输出格式都写进 task_description 里。advisor 那边则是 forward_transcript 这个开关,文档写明的默认值是 false——这是文档写明的默认值,随版本可能变动:为 false 时顾问只看到 prompt,为 true 时整个父对话会被转发过去。你以为顾问”知道”的事情,可能它根本没见过。
真正有意思的是 subagent 上那条例外路径。文档给了两个字段:inherit_functions 与 inherited_function_names,作用是把请求顶层的 function 工具复制进 worker 的工具表。文档把它们标为 Experimental — subject to change without notice,且仅支持 Responses API,其它 API 会以 400 拒绝。继承之后会发生什么:worker 调用某个 client function 时会挂起,待处理的调用以 function_call 项浮到响应上、带着 subagent_id 交给你的客户端去执行。
这是整套体系里唯一一处”服务端跑着跑着把执行权还给你”的路径,也正因为它是实验性的,别把生产链路押在上面。
顺带一提凭证的归属也有一处切分:web search 工具的 engine 参数里,文档单独给 firecrawl 标了 BYOK(bring your own key),并写明这条路径直接消耗你自己 Firecrawl 账号的额度、OpenRouter 不额外计费。其它引擎文档没有标 BYOK,至于它们的 key 由谁持有、怎么保管,官方文档没有说明这一点,别自己脑补。执行在服务端、账单却可能落在你这边,这个错位在排查费用时值得记一笔。可选引擎与它们各自的能力随时在变,以官方文档最新内容为准。
还有一条容易踩:文档写明旧的 web search 插件(plugins: [{ id: "web" }])与 :online 变体都已 deprecated,官方指向的替代是 openrouter:web_search 服务端工具。两者的关键差别恰好就是本文的主题——插件是 always searches once,服务端工具是模型自己决定搜不搜、搜几次。
以上为按官方文档中的字段语义组合的示例,未经实测,以官方文档与 API 的实际响应为准。
落到一句可执行的判断
真要动手时,问三个问题就够了:
- 这个动作能不能在别人的机器上发生? 能,就看有没有对应的
openrouter:*工具;不能(要碰你的仓库、你的内网、你的密钥),那它必须是function类型,由你的进程执行。 - 它是不是要写东西? apply_patch 的定位摆在那里——服务端只校验,落盘是你的事;shell 则相反,
local环境明说不支持。 - 我用的是哪个 API? apply_patch 只在 Responses API 上;shell 在 Responses 与 Messages API 上、Chat Completions 上返回
400;subagent 的函数继承只在 Responses API 上且是实验性的。选错入口,你会先撞上一个400,而不是一个功能缺失的提示。
整组服务端工具目前都标着 beta,字段与行为都可能变。上面每一处字段名都能在 openrouter.ai/docs/guides/features/server-tools 及其子页面里翻到原文,接入前对着最新版本核一遍。
本文依据 OpenRouter 官方文档(openrouter.ai/docs)于 2026-08-18 的公开内容整理。
该平台闭源,本文只复述官方文档写明的机制,不推断其内部实现;
我们没有对文中涉及的功能做过实测,因此不涉及界面外观与运行表现的任何描述。
该平台的供应商、模型与路由策略随时变动,文中不列具体供应商名单与模型清单;
价格、额度与限流的具体数值请以官方定价页与用量说明为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。