OpenRouter 报错 500 是谁的问题?上游与网关错误的区分

2026-08-31
站内工具 AI 编程工具报错分诊器 → 把报错原文贴进去,先分清是网络、额度、配置还是上游故障,再决定往哪个方向查。

数据截至 2026-08,价格与限额以各官网为准。本文只讲计费与接入机制,不列具体价格数字。

先把结论说完:OpenRouter 的 500 是唯一一个「官方主动删证据」的错误码。官方文档写得很直白——当一个请求以 500 失败时,message 会被替换成一个通用字符串,provider_codeopenrouter_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 一个timeoutunmappedprovider_overloadedprovider_unavailable 这四个,官方的描述里都明确指向了上游供应商。所以当你拿到一个 5xx,第一件事不是重试,是把 error_type 打出来。如果它不是 server,你其实已经知道责任方了。

另外,官方在 Embeddings 那一节单独列了一份常见错误清单,其中把 529 描述为供应商临时过载,并建议开启 allow_fallbacks: true 让请求自动走备用供应商。这条是写在 embeddings 端点语境下的,别直接当成全站通用结论,但它说明了同一个思路:过载类错误官方给的答案是换供应商,不是干等。

为什么 500 的报错内容看起来什么都没说

这不是 bug,是官方明写的掩码规则。原文的意思是:请求以 500 失败时,message 被替换为通用串,provider_codeopenrouter_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_codeopenrouter_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.codeserver_error、message 是 Invalid credentials,而顶层 error_typeauthentication

所以在 Responses API 上看到 500 / server_error先读顶层 error_type 再下判断,否则你会把一堆自己的配置问题误判成平台故障。Anthropic Messages 那一层也有同类损耗:provider_unavailableserverunmapped 会一起塌缩成原生的 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_reasonerror,用来正常终止这个流;发完这个事件流就结束。

对监控的启示很直接:如果你只统计 HTTP 状态码,流式请求里 mid-stream 的失败你一个都看不到,因为它们全是 200(首 token 之前失败的仍然会给正常的 4xx/5xx)。要统计的是 finish_reason === 'error' 加上 error.metadata.error_type。同理,那句「我这边 5xx 曲线是平的」在流式场景下根本不构成「没出错」的证据。

还有一个反直觉的细节:Responses API 上,几类 token 与长度相关的错误(上下文超长、max_tokens 触顶、OpenRouter 侧 token 预算触顶、单字段字符串过长)会被转换成成功的完成,finish_reasonlength,官方的解释是让这类限额问题能被优雅处理而不必当成失败。所以你的重试逻辑如果只看「是不是报错」,这几种情况会被静悄悄地放过去。

500 之后,怎么把「到底是谁」查出来

响应体里没有的东西,得从别的地方拿。

第一步:留住 X-Generation-Id 官方说明这个响应头在所有端点(chat completions、completions、responses、messages)上都会返回,用于调试和请求关联。缓存命中时它也在,而且是那次命中独有的新 ID,不会复用原始响应的。

第二步:用它去取 generation 记录。 官方在 router metadata 一节里给的路径是:如果你需要一次「已经越过 API 边缘、但路由状态还没成型」的请求的事后路由上下文,就用 X-Generation-IdGET /api/v1/generation 把 generation 记录取回来。对 500 来说这几乎是唯一可用的路子,因为 500 的信封被官方按设计剥掉了 metadata。

第三步:对非 500 的 5xx,直接开 router metadata。X-OpenRouter-Metadata: enabled 请求头(取值大小写不敏感,只认 enableddisabled,其他任何值都退回 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。注意一旦你设了 sortorder负载均衡就被关闭了,路由改为按你给的顺序试——这等于你自己接管了容错顺序。

模型这一层还有 models 数组:按优先级给一串模型 ID,第一个报错就自动试下一个。官方对失败收尾的描述很实在——如果 fallback 的那个模型也挂了或报错,OpenRouter 就把那个错误返回给你。所以模型级 fallback 不是保险箱,只是多一次机会。

最后提醒一个常被算到 500 头上的邻居:把 provider.only 设得过窄,会导致连一个可用端点都挑不出来,这时官方返回的是一条讲明「哪些供应商在服务这个模型、而你的 provider.only 只允许哪些」的报错,责任在配置不在服务端。这类路由侧的分层排查可以看OpenRouter 路由失败的五层定位法

最容易栽的坑

把这篇压成一张排查卡的话,是这么四条:

  1. 拿到 5xx 先打 error_type,不要先重试。 不是 server 的那几个,官方描述里已经点名了上游,你不需要再猜。
  2. 500 的 message 是掩码结果,别拿它做告警匹配,也别据此写归因报表。 要证据就走 X-Generation-Id + generation 记录。
  3. 流式场景不能只监控 HTTP 状态码。 mid-stream 错误一律是 200,藏在 finish_reason: "error" 里;而且这时候 OpenRouter 已经没法替你静默换供应商了。
  4. Responses API 上的 server_error 不等于平台故障。 鉴权、超时、过载都会塌缩到这个码上,精确原因在顶层 error_type 里。

如果你的 500 其实是密集重试打出来的,那问题的性质就变了,处理方式参考API 429 限流的通用处理——官方给的 Retry-After 示例代码判断的是 429 和 503 两个状态码,并不包含 500,所以对 500 做固定间隔的重试,既没有官方依据,也可能把情况变得更糟。

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

留言讨论

评论发布后会被人工复核,违规内容将被删除。

    还没有人评论,来说说你的看法

    如果发表没有反应,可以前往联系我们告诉我们。

    OpenRouter 充值不方便?

    国内直连的 OpenAI 兼容端点,一期提供 DeepSeek,注册送 ¥5。

    看替代方案

    这个页面有问题?

    提交时会附带当前页面地址和浏览器信息,帮助我们定位问题。不填联系方式即为匿名。