OpenRouter 响应里的路由元数据怎么读:这次到底走了谁

2026-08-18

同一个 model slug,昨天调好好的,今天忽然慢了一截,或者输出风格明显不一样了。你翻响应体,只看到 idmodelchoicesusage,看不出这次请求到底落到哪一家 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_metadatadisabled 表示不带、等价于不发这个 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 事件的一部分。如果你的流式客户端习惯于拿到内容就丢掉尾帧,这个字段就会在你的代码里凭空消失——不是没返回,是被自己吃掉了。

字段怎么读,各自能定位什么问题

官方文档给的字段表如下(这里保留原文的字段名与类型描述):

字段类型文档描述要点
requestedstring客户端发来的 model slug(或 alias)。可能与实际服务该请求的 provider/model 不同
strategystring使用的路由策略:directautofreelatestaliasfallbackparetobodybuilderfusion
regionstring | null处理该请求的边缘 region,有的话给出
summarystring一句人类可读的路由决策描述(如候选数量、选中的 provider)
attemptinteger成功的那次尝试的序号,从 1 开始。大于 1 意味着前面的尝试失败并发生了回退
is_byokboolean该请求是否使用了 BYOK 的 provider key
endpointsEndpointsMetadata被考虑的候选 endpoint 快照,以及哪一个被选中
paramsRouterParams可选。影响选择的 router 级参数(如 quality_floorthroughput_floor
attemptsAttempt[]可选。router 对 fallback 重试时的逐次 provider/model/status
pipelinePipelineStage[]可选。实质改变了请求或响应的插件(压缩、guardrail、healing、服务端工具等)

排障时真正用得上的读法,是把其中几个放在一起看:

requested 和实际服务方对不上,通常说明你发的是 alias 或者走了非 direct 的策略。strategy 那一列的取值就是用来区分这件事的——是你显式指定的直连,还是 alias 解析、自动选择、回退等等。文档只给了取值枚举,没有逐个解释每种策略的判定过程,所以别在这上面自行脑补,看到 strategy 不是 direct 就知道「这次不是我以为的那条路」,然后去查对应的路由配置。

attempt 大于 1,说明前面有尝试失败了。这时候 attempts 数组才是重点:文档写明它逐次记录了 provider、model 与 status。一次请求的耗时如果明显偏离平时,这个数组值得先看一眼——前面那次是以什么状态码结束的,会直接写在里面。文档没有说明每次失败尝试各自花了多久,所以别指望从这里读出时间分解,它给的是「发生过几次、分别是什么结果」。

endpoints 是候选快照加选中标记(totalavailable[].selected),回答「router 当时手上有几个候选、最后挑了哪个」。这份候选是运行时算出来的,是这次请求的现场记录,不是一张固定名单。params 是可选字段,文档举的例子是 quality_floorthroughput_flooris_byok 不起眼但对账时关键:它直接告诉你这次是不是走的自带 key。

pipeline:没出现不等于没配置

pipeline 数组是这一页里最值得慢慢读的部分。文档写明,它记录的是每一个实质影响了请求的插件,而且明确说了一句关键的话:插件只有真正跑了才会产出一个 stage,no-op 的插件会被省略——文档举的例子是上下文压缩发现输入本来就放得下预算,那这个 stage 就不出现。

这一条直接决定了你怎么解释「我在 pipeline 里没看到压缩」:它的意思是这次压缩没有产生实质动作,不是你的压缩配置没生效。把这两件事搞混,会让人往完全错误的方向查配置。

文档列出的「今天的」stage 类型是这几类,并明说这份清单会随时间增长:

typename 取值文档说它告诉你什么
guardrailcontent-filtermoderationflagged: bool,加上引擎特定的判定(decisionconfidence_levelmatched_entity_types 等)
pluginweb-searchfile-parser插件特定的遥测(如 web search 的结果数、文件解析的页数)
server_toolsserver-tools模式(native / sdk)与被调用的工具列表
response_healingresponse-healing模式(json_schema / json_object)、healing 有没有改善响应、长度
context_compressioncontext-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」,此时 attempt0endpoints.available[].selectedfalse。另一个是 403 guardrail 拦截,pipeline 里会带出跑过的 guardrail 阶段,包括拦下它的那一个,示例中这个 stage 除了 typename 还带了 guardrail_idguardrail_scopesummary 以及 data 里的 actiondetectedenginespatterns。文档同时说明,因为 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(502503504529)在客户端选择加入时仍然带元数据。
  • 有些失败模式根本带不出它。 认证与限流失败,以及其它在 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.aius.openrouter.ai 路由的请求——开了 in-region routing 的话,走这两个区域端点的请求会正常工作,但输入输出日志会被跳过。也就是说,区域路由下你仍然能拿到路由元数据,却拿不到 prompt 正文日志。这两页各说各的,放到一起才看得出这个组合的实际后果。

一条可以照着走的排查顺序

把上面这些串起来,遇到「这次到底走了谁」的时候大致是这样一条线:

  1. 请求里加 X-OpenRouter-Metadata: enabled,值逐字核对,别写成别的词被回落成 disabled
  2. 流式请求确认自己的客户端没有丢掉最后一个 chunk / message_stop
  3. 拿到 openrouter_metadata 后先看 strategyattempt:策略对不对、有没有回退;
  4. 有回退就看 attempts 里每次的 status;
  5. 行为异常但 provider 正常,就看 pipeline 里有没有 stage,记住 no-op 的插件不出现;
  6. 完全没有这个字段,先怀疑缓存命中,再怀疑 header 值;
  7. 错误响应记得在顶层取它,不要去 error 里面找;500 拿不到是设计如此;
  8. 什么都拿不到时,用 X-Generation-Id 走 generation 记录兜底。

以上为按官方文档中的字段语义组合的示例,未经实测,以官方文档与 API 的实际响应为准。该平台迭代频繁,文中涉及的 header 名、字段与 pipeline stage 类型都随版本变动,文档本身也明说 stage 类型清单会继续增长,请以官方文档最新内容为准。


本文依据 OpenRouter 官方文档(openrouter.ai/docs)于 2026-08-18 的公开内容整理。 该平台闭源,本文只复述官方文档写明的机制,不推断其内部实现我们没有对文中涉及的功能做过实测,因此不涉及界面外观与运行表现的任何描述。 该平台的供应商、模型与路由策略随时变动,文中不列具体供应商名单与模型清单; 价格、额度与限流的具体数值请以官方定价页与用量说明为准。

安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。