OpenRouter 报错 500 是谁的问题?上游与网关错误的区分
数据截至 2026-08,价格与限额以各官网为准。本文只讲计费与接入机制,不列具体价格数字。
先把结论说完:OpenRouter 的 500 是唯一一个「官方主动删证据」的错误码。官方文档写得很直白——当一个请求以 500 失败时,message 会被替换成一个通用字符串,provider_code 和 openrouter_metadata 都被省略,只有 error_type 还在,值是 server。也就是说,你想从 500 的响应体里读出「是上游模型挂了还是网关自己出问题了」,从设计上就读不出来。 而同为 5xx 的 502、503、504、529,只要你开了 router metadata,路由快照照样返回。所以排查 500 的正确动作不是反复盯着响应体看,而是立刻换一条信息通道:拿 X-Generation-Id 去查那一次调用的 generation 记录。这篇按这个思路走一遍。
先分清:500 和其他 5xx 在 OpenRouter 这里不是一回事
很多人把 5xx 笼统当成「服务端的锅」,但 OpenRouter 有一套自己的规范化词表,叫 typed error codes。官方的说法是:无论错误是从非流式响应体里来的,还是以流中的 SSE 事件形式来的,都会被归一成同一套 error_type 取值;并且明确建议用这个字段而不是单看 HTTP 状态码来区分错误类别,因为原生协议码(Anthropic 的 error.type、Responses 的 error.code)是尽力而为的,在不同格式之间会不一致。
跟「服务端出错」相关的取值分散在两组里,含义并不相同(取值以官方文档为准):
error_type | 官方描述 | 责任落点 |
|---|---|---|
server | 一个意料之外的内部错误,这一类型上上游的错误信息会被掩码 | 无法从响应体判断 |
timeout | 供应商在允许的时间内没有响应 | 上游 |
unmapped | 上游错误对不上任何已知类别,error.metadata.provider_code 里可能带着原始码 | 上游 |
provider_overloaded | 上游供应商临时过载,稍后重试 | 上游 |
provider_unavailable | 上游返回了无效或空响应,若开了 fallback 路由,OpenRouter 可能自动换供应商重试 | 上游 |
看这张表就能明白一件事:真正「说不清是谁」的只有 server 一个。timeout、unmapped、provider_overloaded、provider_unavailable 这四个,官方的描述里都明确指向了上游供应商。所以当你拿到一个 5xx,第一件事不是重试,是把 error_type 打出来。如果它不是 server,你其实已经知道责任方了。
另外,官方在 Embeddings 那一节单独列了一份常见错误清单,其中把 529 描述为供应商临时过载,并建议开启 allow_fallbacks: true 让请求自动走备用供应商。这条是写在 embeddings 端点语境下的,别直接当成全站通用结论,但它说明了同一个思路:过载类错误官方给的答案是换供应商,不是干等。
为什么 500 的报错内容看起来什么都没说
这不是 bug,是官方明写的掩码规则。原文的意思是:请求以 500 失败时,message 被替换为通用串,provider_code 与 openrouter_metadata 一并省略,error_type 保留为 server。而对非 500 的错误,上游供应商自己的错误码会在可获得时暴露在 error.metadata.provider_code 里。
流式路径上是同一套规则。官方给出的 mid-stream 错误结构里,provider_code 那个字段的注释直接写着「原始上游错误码(500 时省略)」;正文也补了一句:500 一类的错误上,error.message 会被替换成通用字符串、provider_code 被省略,目的是防止泄漏上游细节。
这条规则有两个非常实际的后果,值得单独拎出来:
- 不要用
message字符串做告警匹配。 500 上的 message 本来就是官方替换过的通用串,你匹配到的是掩码结果,不是真实原因;哪天官方换一版文案,你的告警规则就集体失效。要匹配就匹配error_type。 - 不要指望在 500 上做「归因到某个供应商」的报表。
provider_code和openrouter_metadata都不在,这一条请求在响应体层面是没有供应商身份的。想归因只能走后面第五节讲的 generation 记录。
顺带说一个容易混的邻居:403。官方对 permission_denied 的定义是 key 有效但缺少所需权限,或者请求被 guardrail 拦截,而且 guardrail 拦截时响应里会带 patterns 这类元数据,信息量比 500 大得多。把 403 和 500 一起塞进「服务端问题」桶里排查,方向从一开始就偏了。鉴权侧的分层可以参考OpenRouter 报错 401 的排查顺序。
你看到的 500 可能根本不是 OpenRouter 返回的
这一节是最容易踩的坑,尤其是用 Responses API 的人。
第一种情况:状态码和 error.code 不一定一致。 官方对错误响应的说明是,HTTP 响应的状态码与 error.code 相同,前提是形成了一个「请求错误」——即你的原始请求非法,或者你的 key / 账户额度用尽。除此之外的情形,返回的 HTTP 状态另有规定,而 LLM 在产出过程中发生的任何错误,会放在响应体里或者以 SSE data 事件发出。官方 JavaScript 示例的注释把这层说得更明白:打印出来的 request.status 会是一个错误码,除非模型已经开始处理你的请求。
第二种情况:Responses API 的原生错误码词表本来就很窄。 官方给的映射表里,server_error 这一个码对应的 HTTP 状态是 500+,而它覆盖的内部类型包括内部错误、鉴权失败、供应商过载或不可用、以及超时。也就是说在 Responses API 上,一个 key 写错了的鉴权失败,同样会以 server_error 的面目出现。官方为此专门在响应对象顶层加了一个 error_type 字段来保留精确原因,文档里的示例就是:error.code 是 server_error、message 是 Invalid credentials,而顶层 error_type 是 authentication。
所以在 Responses API 上看到 500 / server_error,先读顶层 error_type 再下判断,否则你会把一堆自己的配置问题误判成平台故障。Anthropic Messages 那一层也有同类损耗:provider_unavailable、server、unmapped 会一起塌缩成原生的 api_error,官方同样在 error 对象里额外补了 error_type。
第三种情况:SDK 的异常类型。 OpenRouter 官方 SDK 的接口文档里,每个操作后面都跟着一张 Errors 表,500 对应一个专门的内部服务错误异常类型,另外还有一个覆盖 4XX、5XX 的兜底异常类型。你的代码如果只 catch 了兜底那个,日志里就只剩一句「请求失败」,白白丢掉了状态码这一层信息。
流式请求里的 500:为什么 HTTP 状态还是 200
官方把流式错误明确分成了两段,这个划分本身就是排查线索。
Pre-stream(还没吐出任何 token):HTTP 响应尚未提交,所以 OpenRouter 可以返回一个正常的 4xx/5xx 状态、可以在开启 fallback 路由时静默换一个供应商端点重试、也可以在任何工作开始前做限流与鉴权检查。官方举的例子是 key 无效、请求格式错误、或者所有可用端点在流开始前就被耗尽。
Mid-stream(第一个 token 已经写给客户端了):200 OK 和响应头已经提交,改不了了。这时候供应商失败,OpenRouter 无法再静默切到另一个供应商,因为部分内容已经交付给你的应用。错误只能作为 SSE 事件带内返回。官方列的常见成因有五条:供应商断连(网络问题、供应商崩溃、负载均衡器超时)、供应商超时(模型生成到一半停止响应、读取超时到期)、生成中途触到 token 上限(max_tokens 或上下文窗口被填满)、输出内容过滤(已经流出去一部分之后才被内容审核标记)、以及供应商过载。
mid-stream 错误的结构是一个 chat.completion.chunk,顶层带 error 对象,同时带一个 choices 数组、finish_reason 为 error,用来正常终止这个流;发完这个事件流就结束。
对监控的启示很直接:如果你只统计 HTTP 状态码,流式请求里 mid-stream 的失败你一个都看不到,因为它们全是 200(首 token 之前失败的仍然会给正常的 4xx/5xx)。要统计的是 finish_reason === 'error' 加上 error.metadata.error_type。同理,那句「我这边 5xx 曲线是平的」在流式场景下根本不构成「没出错」的证据。
还有一个反直觉的细节:Responses API 上,几类 token 与长度相关的错误(上下文超长、max_tokens 触顶、OpenRouter 侧 token 预算触顶、单字段字符串过长)会被转换成成功的完成,finish_reason 为 length,官方的解释是让这类限额问题能被优雅处理而不必当成失败。所以你的重试逻辑如果只看「是不是报错」,这几种情况会被静悄悄地放过去。
500 之后,怎么把「到底是谁」查出来
响应体里没有的东西,得从别的地方拿。
第一步:留住 X-Generation-Id。 官方说明这个响应头在所有端点(chat completions、completions、responses、messages)上都会返回,用于调试和请求关联。缓存命中时它也在,而且是那次命中独有的新 ID,不会复用原始响应的。
第二步:用它去取 generation 记录。 官方在 router metadata 一节里给的路径是:如果你需要一次「已经越过 API 边缘、但路由状态还没成型」的请求的事后路由上下文,就用 X-Generation-Id 去 GET /api/v1/generation 把 generation 记录取回来。对 500 来说这几乎是唯一可用的路子,因为 500 的信封被官方按设计剥掉了 metadata。
第三步:对非 500 的 5xx,直接开 router metadata。 发 X-OpenRouter-Metadata: enabled 请求头(取值大小写不敏感,只认 enabled 与 disabled,其他任何值都退回 disabled),响应里会多出一个 openrouter_metadata 对象。它在失败信封里也会出现,位置是顶层、与 error 平级。几个对定责最有用的字段:
attempt:1 起数的、成功的那次尝试序号。大于 1 说明前面的尝试失败过并且发生了 fallback;等于 0 说明请求压根没到过任何供应商,通常是候选被过滤光了。attempts:路由对 fallback 重试时,每一次的供应商、模型和状态。endpoints:考虑过的候选端点快照,以及哪个被选中;失败时没有任何端点会被标成selected。strategy:这次用的路由策略;is_byok:是不是走了自带供应商 key。
三条边界要记住:500 的信封不带它(官方原话是不在原因已经被掩盖的错误上暴露内部路由细节),而 502、503、504、529 在客户端已 opt-in 时仍然带;鉴权失败、限流失败以及 API 边缘的校验拒绝,在路由拿到可用状态之前就返回了,也不带;缓存命中永远不带,官方的理由是缓存回放时的路由信息可能已经不反映真实情况。
第四步:怀疑参数被转换坏了,就开 debug。 debug.echo_upstream_body 会让 OpenRouter 把实际发给上游的请求体回显给你。三个硬限制必须知道:它只在 stream: true 时生效,非流式请求会直接忽略这个参数;回显作为流式响应的第一个 chunk 返回,choices 为空数组;官方明确警告不要在生产环境用,因为它可能返回请求里本不该被看到的敏感信息。它有一个专门针对本文场景的用法:启用供应商 fallback 时,每一个被尝试过的供应商都会发一个 debug chunk,于是你能看出路由试了谁、给每一个发的参数长什么样。
一次 500 会不会把钱扣掉
会不会计费,官方有明文规则,叫 zero completion insurance,对所有账户自动生效、不需要任何配置。规则是:响应满足「零 completion token 且 finish reason 为空或 null」,或者「finish reason 是 error」这两个条件之一时,模型的 prompt token、completion token、reasoning token 都不扣额度——即使上游供应商向 OpenRouter 收取了 prompt 处理费用。在 Activity 页上,被这条规则保护的请求会显示 token 用量零扣减。
但它有明确的覆盖边界:已经跑完的辅助服务不在保护范围内。官方点名的是 web search(按次的检索费用)、文件解析 / PDF OCR、以及 web fetch——即使模型响应随后以错误收场,这些仍可能按实际完成的工作量计费,并出现在该请求的用量明细里;只有在某些完全没有产生 token 的上游失败情形下,这些费用也会一并跳过。
所以「500 一定不花钱」这个说法是不准确的。带插件的请求要按明细看,成本口径的通用做法可以参考API 成本监控怎么做。
配置层能做的事:把 500 的暴露面缩小
排查之外,还有几个官方参数直接影响你有多大概率撞上供应商级故障。
provider.allow_fallbacks 默认为 true(默认值以官方文档当前版本为准),含义是主供应商不可用时允许启用备用供应商;置为 false 就是在主供应商之后停下来,不再尝试其他合格端点。默认的负载均衡策略本身也带健康度考量:官方描述的第一步就是优先选择近期没有出现明显故障的供应商(时间窗以官方文档为准),然后在稳定的候选里按价格做加权选择,剩下的作为 fallback。注意一旦你设了 sort 或 order,负载均衡就被关闭了,路由改为按你给的顺序试——这等于你自己接管了容错顺序。
模型这一层还有 models 数组:按优先级给一串模型 ID,第一个报错就自动试下一个。官方对失败收尾的描述很实在——如果 fallback 的那个模型也挂了或报错,OpenRouter 就把那个错误返回给你。所以模型级 fallback 不是保险箱,只是多一次机会。
最后提醒一个常被算到 500 头上的邻居:把 provider.only 设得过窄,会导致连一个可用端点都挑不出来,这时官方返回的是一条讲明「哪些供应商在服务这个模型、而你的 provider.only 只允许哪些」的报错,责任在配置不在服务端。这类路由侧的分层排查可以看OpenRouter 路由失败的五层定位法。
最容易栽的坑
把这篇压成一张排查卡的话,是这么四条:
- 拿到 5xx 先打
error_type,不要先重试。 不是server的那几个,官方描述里已经点名了上游,你不需要再猜。 - 500 的 message 是掩码结果,别拿它做告警匹配,也别据此写归因报表。 要证据就走
X-Generation-Id+ generation 记录。 - 流式场景不能只监控 HTTP 状态码。 mid-stream 错误一律是 200,藏在
finish_reason: "error"里;而且这时候 OpenRouter 已经没法替你静默换供应商了。 - Responses API 上的
server_error不等于平台故障。 鉴权、超时、过载都会塌缩到这个码上,精确原因在顶层error_type里。
如果你的 500 其实是密集重试打出来的,那问题的性质就变了,处理方式参考API 429 限流的通用处理——官方给的 Retry-After 示例代码判断的是 429 和 503 两个状态码,并不包含 500,所以对 500 做固定间隔的重试,既没有官方依据,也可能把情况变得更糟。
留言讨论
评论发布后会被人工复核,违规内容将被删除。
如果发表没有反应,可以前往联系我们告诉我们。