OpenRouter 响应里的路由元数据怎么读:这次到底走了谁
同一个 model slug,昨天调好好的,今天忽然慢了一截,或者输出风格明显不一样了。你翻响应体,只看到 id、model、choices、usage,看不出这次请求到底落到哪一家 provider、中间有没有被压缩过上下文、有没有插件动过手脚。
OpenRouter 官方文档《Router Metadata》页(openrouter.ai/docs/guides/features/router-metadata)对这个默认行为写得很直白:router 会把每个请求过一条多阶段流水线——挑 provider、可能压缩上下文、可能跑 guardrail、可能调用服务端工具、可能对 fallback 重试——而这些默认在响应上都不可见。
要把它变可见,得自己按一下开关。
开关只有一个 header,取值只有两个
官方文档写明的开启方式是在请求里带上 X-OpenRouter-Metadata 头,值为 enabled。以下是该页 cURL 示例的原文:
curl https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer <OPENROUTER_API_KEY>" \
-H "Content-Type: application/json" \
-H "X-OpenRouter-Metadata: enabled" \
-d '{
"model": "openai/gpt-4o-mini",
"messages": [{ "role": "user", "content": "Hello" }]
}'
同页还给了 Python 版本:
import requests
response = requests.post(
'https://openrouter.ai/api/v1/chat/completions',
headers={
'Authorization': f'Bearer <OPENROUTER_API_KEY>',
'Content-Type': 'application/json',
'X-OpenRouter-Metadata': 'enabled',
},
json={
'model': 'openai/gpt-4o-mini',
'messages': [{'role': 'user', 'content': 'Hello'}],
},
)
示例里的 openai/gpt-4o-mini 只是官方文档当时用来演示的模型值,平台上有哪些模型随时在变,不要把它当清单看。
Windows 侧提醒:官方文档的 cURL 那段是 bash 写法,用单引号包 JSON,在 PowerShell 与 cmd 里转义规则不同,照贴容易在 -d 的 JSON 上报错。省事的做法是在 Windows 上直接用官方文档给的 Python 或 TypeScript 示例,避开 shell 引号问题——这一句是通用的命令行常识提醒,不是官方文档里的内容,官方那页并没有给 Windows 专门的写法。
header 的取值,文档给了一张表,只有两行:enabled 表示在响应上带出 openrouter_metadata,disabled 表示不带、等价于不发这个 header。匹配是大小写不敏感的。这里有个容易踩的点:其它任何值——包括拼错的、空字符串、你以为存在的什么「详细级别」——都会回落成 disabled。也就是说你写了个 X-OpenRouter-Metadata: true,不会报错,只会静悄悄地什么都不给你。排查「开了怎么没有」的时候,先把这个值逐字对一遍。
不带 header 时的默认行为,文档写明是 disabled。
哪些路由拿得到,流式在哪一帧
文档写明这个字段接进了所有公开的 completion 路由:/api/v1/chat/completions(OpenAI Chat Completions)、/api/v1/messages(Anthropic Messages)、/api/v1/responses(OpenAI Responses)、/api/v1/completions(legacy text completions)。
流式和非流式都会带。流式的位置需要单独记一下:Chat Completions 与 Responses 是在 data: [DONE] 之前的最后一个 chunk 上给出,Anthropic Messages 则是作为终止的 message_stop 事件的一部分。如果你的流式客户端习惯于拿到内容就丢掉尾帧,这个字段就会在你的代码里凭空消失——不是没返回,是被自己吃掉了。
字段怎么读,各自能定位什么问题
官方文档给的字段表如下(这里保留原文的字段名与类型描述):
| 字段 | 类型 | 文档描述要点 |
|---|---|---|
requested | string | 客户端发来的 model slug(或 alias)。可能与实际服务该请求的 provider/model 不同 |
strategy | string | 使用的路由策略:direct、auto、free、latest、alias、fallback、pareto、bodybuilder、fusion |
region | string | null | 处理该请求的边缘 region,有的话给出 |
summary | string | 一句人类可读的路由决策描述(如候选数量、选中的 provider) |
attempt | integer | 成功的那次尝试的序号,从 1 开始。大于 1 意味着前面的尝试失败并发生了回退 |
is_byok | boolean | 该请求是否使用了 BYOK 的 provider key |
endpoints | EndpointsMetadata | 被考虑的候选 endpoint 快照,以及哪一个被选中 |
params | RouterParams | 可选。影响选择的 router 级参数(如 quality_floor、throughput_floor) |
attempts | Attempt[] | 可选。router 对 fallback 重试时的逐次 provider/model/status |
pipeline | PipelineStage[] | 可选。实质改变了请求或响应的插件(压缩、guardrail、healing、服务端工具等) |
排障时真正用得上的读法,是把其中几个放在一起看:
requested 和实际服务方对不上,通常说明你发的是 alias 或者走了非 direct 的策略。strategy 那一列的取值就是用来区分这件事的——是你显式指定的直连,还是 alias 解析、自动选择、回退等等。文档只给了取值枚举,没有逐个解释每种策略的判定过程,所以别在这上面自行脑补,看到 strategy 不是 direct 就知道「这次不是我以为的那条路」,然后去查对应的路由配置。
attempt 大于 1,说明前面有尝试失败了。这时候 attempts 数组才是重点:文档写明它逐次记录了 provider、model 与 status。一次请求的耗时如果明显偏离平时,这个数组值得先看一眼——前面那次是以什么状态码结束的,会直接写在里面。文档没有说明每次失败尝试各自花了多久,所以别指望从这里读出时间分解,它给的是「发生过几次、分别是什么结果」。
endpoints 是候选快照加选中标记(total 与 available[].selected),回答「router 当时手上有几个候选、最后挑了哪个」。这份候选是运行时算出来的,是这次请求的现场记录,不是一张固定名单。params 是可选字段,文档举的例子是 quality_floor 和 throughput_floor。is_byok 不起眼但对账时关键:它直接告诉你这次是不是走的自带 key。
pipeline:没出现不等于没配置
pipeline 数组是这一页里最值得慢慢读的部分。文档写明,它记录的是每一个实质影响了请求的插件,而且明确说了一句关键的话:插件只有真正跑了才会产出一个 stage,no-op 的插件会被省略——文档举的例子是上下文压缩发现输入本来就放得下预算,那这个 stage 就不出现。
这一条直接决定了你怎么解释「我在 pipeline 里没看到压缩」:它的意思是这次压缩没有产生实质动作,不是你的压缩配置没生效。把这两件事搞混,会让人往完全错误的方向查配置。
文档列出的「今天的」stage 类型是这几类,并明说这份清单会随时间增长:
type | name 取值 | 文档说它告诉你什么 |
|---|---|---|
guardrail | content-filter、moderation | flagged: bool,加上引擎特定的判定(decision、confidence_level、matched_entity_types 等) |
plugin | web-search、file-parser | 插件特定的遥测(如 web search 的结果数、文件解析的页数) |
server_tools | server-tools | 模式(native / sdk)与被调用的工具列表 |
response_healing | response-healing | 模式(json_schema / json_object)、healing 有没有改善响应、长度 |
context_compression | context-compression | 使用的引擎、输入类型(messages / prompt)、压缩前后的计数 |
写解析代码时有两个坑,文档都点到了。一是多个插件可以共享同一个 type,所以要定位某个具体的 guardrail(比如内容过滤),必须同时匹配 type === 'guardrail' 和 name === 'content-filter';只匹配 type 会捞到一堆别的。二是反过来,想把所有 guardrail 级插件一起筛出来,恰恰可以只按 type 过滤,文档给的写法是 pipeline.filter(s => s.type === 'guardrail'),不用把插件名一个个列出来。
至于没见过的 type,文档的要求是当作不透明对象处理。data 被设计成自由格式的记录,就是为了让插件挂自己的遥测而不用改 schema。配合文末「Stability」那一节的说法——这个响应形状是追加式的,新的可选字段和新的 stage 类型可能不经过 deprecation cycle 就出现,但已有字段稳定——写解码逻辑就一句话:宽松解码,忽略不认识的字段和 stage 类型,否则平台一加东西你就崩。
明明开了,却没有这个字段
有一种情况会让人反复怀疑自己 header 写错了:缓存命中永远不带 openrouter_metadata。文档写明,流式与非流式的缓存重放都会把这个字段剥掉,理由是文档自述的——防止客户端把行为钉死在过期的路由数据上,因为你在缓存未命中时看到的元数据,未必反映产出那份缓存内容时的路由。
所以「开了却没有」的排查顺序应该是:先确认 header 值逐字正确(不是回落成了 disabled),再确认不是缓存命中,最后才怀疑别的。
出错的时候,它在 error 的旁边而不是里面
这是最容易写错解析代码的一处。文档写明,选择加入后,错误响应的 openrouter_metadata 出现在错误信封的顶层,与 error 是兄弟关系,而不是嵌在 error 里面。这个位置与成功路径保持一致,四个路由、流式与非流式都适用,opt-in 规则也一样。
文档给了两个错误示例。一个是 404 「没有可用 provider」,此时 attempt 是 0,endpoints.available[].selected 为 false。另一个是 403 guardrail 拦截,pipeline 里会带出跑过的 guardrail 阶段,包括拦下它的那一个,示例中这个 stage 除了 type 和 name 还带了 guardrail_id、guardrail_scope、summary 以及 data 里的 action、detected、engines、patterns。文档同时说明,因为 guardrail 拦截发生在 provider 调用完成之前,所以没有 endpoint 被标记为 selected,可选的 attempts 数组也不存在。
围绕失败场景,那一页还列了几条需要背下来的规则:
attempt反映 router 走到了哪一步。 值为0表示请求从未到达 provider,通常是所有候选在提交前就被过滤光了(文档举的例子:provider.only排除了最后一个 endpoint,或者 allowed-providers / max-price 之类的过滤把候选全否了)。值≥ 1则表示每个尝试过的 provider 都失败了、fallback 也用尽了。这一个整数就把「压根没发出去」和「发出去了全挂了」分开了,值得优先看。- 失败时没有 endpoint 被标记
selected,因为没有 endpoint 真的返回过 200。 - 内部错误的遮蔽仍然生效。
500状态的响应会被抹成通用消息,openrouter_metadata按设计从这类信封里省略;其它 5xx(502、503、504、529)在客户端选择加入时仍然带元数据。 - 有些失败模式根本带不出它。 认证与限流失败,以及其它在 router 拿到可用路由状态之前就触发的错误(文档举例:API 边缘的校验拒绝),不会包含这个字段。
最后这条留了一条兜底路径:如果请求已经越过 API 边缘、但 router 还没形成状态,需要事后拿到路由上下文时,文档写明可以用响应头 X-Generation-Id 去取 generation 记录(GET /api/v1/generation)。把这个响应头顺手记进日志,成本极低,出事时省一大截。
旧 header 还在,但建议迁移
同页「Legacy Header」一节写明,之前的 header 名 X-OpenRouter-Experimental-Metadata 仍然被接受以保持向后兼容,官方建议在方便时迁移到 X-OpenRouter-Metadata。老项目里翻到那个名字不用慌,但新写的代码没理由再用它。
它不会给你 prompt 正文
有个边界值得说清楚:openrouter_metadata 讲的是「这次怎么走的」,不是「这次说了什么」。想回看请求与响应的完整内容,是另一个功能——官方文档《Input & Output Logging》页(openrouter.ai/docs/guides/features/input-output-logging)写明该功能当前处于 Beta,在 Observability 设置里用开关启用;对组织账号,文档写明只有 admin 能查看和切换这个设置,非管理员成员看不到已存储的内容。同页还写明:只有启用之后产生的 generation 才会有存储内容,之前的补不回来。
这里有一处和路由元数据能对上的地方,值得放在一起记:openrouter_metadata 里有 region 字段告诉你哪个边缘 region 处理了请求;而《Input & Output Logging》页写明,该功能目前不适用于经由 eu.openrouter.ai 或 us.openrouter.ai 路由的请求——开了 in-region routing 的话,走这两个区域端点的请求会正常工作,但输入输出日志会被跳过。也就是说,区域路由下你仍然能拿到路由元数据,却拿不到 prompt 正文日志。这两页各说各的,放到一起才看得出这个组合的实际后果。
一条可以照着走的排查顺序
把上面这些串起来,遇到「这次到底走了谁」的时候大致是这样一条线:
- 请求里加
X-OpenRouter-Metadata: enabled,值逐字核对,别写成别的词被回落成disabled; - 流式请求确认自己的客户端没有丢掉最后一个 chunk /
message_stop; - 拿到
openrouter_metadata后先看strategy和attempt:策略对不对、有没有回退; - 有回退就看
attempts里每次的 status; - 行为异常但 provider 正常,就看
pipeline里有没有 stage,记住 no-op 的插件不出现; - 完全没有这个字段,先怀疑缓存命中,再怀疑 header 值;
- 错误响应记得在顶层取它,不要去
error里面找;500拿不到是设计如此; - 什么都拿不到时,用
X-Generation-Id走 generation 记录兜底。
以上为按官方文档中的字段语义组合的示例,未经实测,以官方文档与 API 的实际响应为准。该平台迭代频繁,文中涉及的 header 名、字段与 pipeline stage 类型都随版本变动,文档本身也明说 stage 类型清单会继续增长,请以官方文档最新内容为准。
本文依据 OpenRouter 官方文档(openrouter.ai/docs)于 2026-08-18 的公开内容整理。
该平台闭源,本文只复述官方文档写明的机制,不推断其内部实现;
我们没有对文中涉及的功能做过实测,因此不涉及界面外观与运行表现的任何描述。
该平台的供应商、模型与路由策略随时变动,文中不列具体供应商名单与模型清单;
价格、额度与限流的具体数值请以官方定价页与用量说明为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。