OpenRouter Responses API 的错误处理:与 Chat Completions 不是一套

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

把一段跑了很久的调用代码从 /api/v1/chat/completions 换到 /api/v1/responses,最容易出的事不是请求发不出去,而是请求发出去了、错误也回来了,但你写的那一堆错误分支一条都没命中。日志里只剩一个兜底的 unknown error,重试策略跟着一起失灵。

原因不在网络,也不在模型。OpenRouter 官方文档里,这两个端点的错误响应本来就是两套结构,而且差异不在边角,在最外面那一层。

现象长什么样

从文档能对得上的典型症状有三种:

  1. switch (error.code) 一条都不匹配。原来按 401402429 分支的代码,换端点后全落到 default
  2. 明明是凭据问题,日志里记的却是「服务端错误」,于是被重试逻辑当成临时故障反复重试。
  3. 上下文超了的那种请求,不但没进错误分支,反而当成一次成功的响应被下游消费了。

这三种都指向同一处:错误对象里 code类型和取值范围变了,而承载真实原因的字段挪了位置。

第一步:怎么确认自己踩的就是这个

别急着改代码,先做四个可执行的判定动作。

动作一,打印 error.code 的类型。 官方文档《Errors and Debugging》页给出的 OpenRouter 通用错误结构是:

type ErrorResponse = {
  error: {
    code: number;
    message: string;
    metadata?: Record<string, unknown>;
  };
};

codenumber。而《Error Handling》(openrouter.ai/docs/api_reference/responses/error-handling)页给出的 Responses API 错误体是:

{
  "error": {
    "code": "invalid_prompt",
    "message": "Detailed error description"
  },
  "metadata": null
}

code 是字符串。如果你在 Responses 端点上拿到的 typeof error.code === 'string',那你的数字分支永远不会命中——这就是症状一的全部解释。顺带注意 metadata 的位置也不一样:通用结构里它挂在 error 里面,这段 Responses 示例里它是响应体的顶层字段。

动作二,看 error_type 在第几层。 文档写明 error_type 是跨格式稳定的规范字段,但每个 skin 放的位置不同:

  • Chat Completions:error.metadata.error_type
  • Anthropic Messages:error.error_type(在 error 对象里面)
  • Responses:顶层 error_type,在原生 error 对象外面

所以从 Chat Completions 迁过来的代码如果去读 error.metadata.error_type,在 Responses 上永远是 undefined

动作三,看 error.code 的取值有没有落在那四个里。 《Error Handling》页只列了四个 Responses 错误码:invalid_promptrate_limit_exceededimage_content_policy_violationserver_error。文档明说这是内部 typed error codes 的收窄映射,多个内部类型会塌缩成同一个码——文档举的例子是 context_length_exceededinvalid_request 都会显示成 invalid_prompt。如果你看到的码超出这四个,那问题多半不在这里。

动作四,检查请求里有没有 storeprevious_response_id 这一条和错误结构无关,但迁移时经常一起中招:文档在概览页和错误处理页两处都用同一段提示写明,Responses API 是 stateless 的,每次请求必须带完整对话历史,设置了 store: true 或非 null 的 previous_response_id 的请求会被以 400 拒绝。如果你照搬了别处带状态的写法,先排除这一条。

两套结构差在哪:按字段对一遍

维度Chat CompletionsResponses API
error.code 类型数字(HTTP 状态码)字符串(四个码之一)
规范 error_type 位置error.metadata.error_type响应体顶层 error_type
生成中途出错(非流式)嵌在 choices[].error,同级带 finish_reason: "error"响应对象上 status: "failed" + 顶层 error_type
流式出错chat.completion.chunk 上带顶层 error三种 SSE 事件之一(下节)

这张表只列了本文要用到的四行,字段全集以官方文档为准。

有一处特别容易被忽略:文档写明,Chat Completions 在非流式请求里遇到 provider 报错时,错误是连着已生成的部分内容一起塞进最终响应的:

{
  "choices": [{
    "message": { "role": "assistant", "content": "partial output..." },
    "finish_reason": "error",
    "error": {
      "code": 502,
      "message": "Provider disconnected mid-stream",
      "metadata": { "error_type": "provider_unavailable" }
    }
  }]
}

也就是说这类错误的 HTTP 状态并不是 5xx。文档在通用错误一节说得很直白:只有在请求本身无效、或账户额度不足这两类情况下,HTTP 状态码才等于 error.code其它情况 HTTP 会是 200,错误在响应体里或作为 SSE 事件发出。只看 HTTP 状态码做判断,这类错误会被整段吞掉。

处置:认 error_type,别认 code

文档给出的处置口径只有一句话——error.code 在各 skin 里是 best-effort 且有损的,error_type 才是跨格式稳定、可以用来做程序分支的那个值。

为什么必须换。 看 Responses 的映射表就明白了:authenticationprovider_overloadedprovider_unavailabletimeoutserver 这几个内部类型全部塌缩成 server_error。文档给的例子就是凭据错误:

{
  "id": "resp_abc123",
  "status": "failed",
  "error": { "code": "server_error", "message": "Invalid credentials" },
  "error_type": "authentication"
}

一个 API key 的问题,原生 code 显示为 server_error。这就是症状二——如果你按 code 分支,它看起来像一次服务端抖动,重试多少次都不会好;按 error_type 分支,你拿到的是 authentication,该停下来查密钥。

这里有个反直觉的命名坑,值得单拎出来。 映射表的最后一行写的是「All others(including invalid_prompt)→ server_error」。也就是说,内部那个叫 invalid_prompt 的 error_type,在 Responses 上显示成 server_error;而 Responses 上显示为 invalid_prompt 的,来自内部的 context_length_exceededinvalid_request同一个词在两层里指的不是一回事。 写映射逻辑时如果拿字符串对拷,方向正好反了。

流式的三种事件形态。 文档写明 Responses 的流式错误会以三种 SSE 事件之一出现:response.failed(终止事件,响应无法完成)、response.error(生成过程中的错误)、error(对齐上游 OpenAI 行为的普通错误事件)。文档同时写明,response.failed 里的 response 对象和非流式 JSON 体一样,都在顶层带规范 error_type。三种事件都要接,只接一种会漏。

还有一类根本不是错误。 文档的「Error code transformations」小节列出四个 error_type——context_length_exceededmax_tokens_exceededtoken_limit_exceededstring_too_long——会被转换成成功响应finish_reasonlength。这解释了症状三:上下文超了的请求不会走错误分支,它是一次带 length 结束原因的正常响应,你得在成功路径里判断 finish_reason

顺便记一处文档内部的口径差异,我们只指出、不做解释:《Error Handling》页的错误码表、以及《Errors and Debugging》页的内部类型映射表,都把 context_length_exceeded 归到 invalid_prompt(对应 400);而紧挨着映射表的这张转换表说它被转成成功响应。两处都白纸黑字,文档没有说明哪一条在什么条件下优先,稳妥做法是两条路径都写上处理。

处置后怎么验证

验证点一:故意制造一个凭据错误,看 error_type 回没回来。 官方文档概览页给的 cURL 示例原样是:

curl -X POST https://openrouter.ai/api/v1/responses \
  -H "Authorization: Bearer YOUR_OPENROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/o4-mini",
    "input": "Hello, world!"
  }'

(示例里的 model 是官方文档当时写的示例值,平台上可用的模型随时在变,别把它当清单用;真实调用时把 key 换成你自己的 <YOUR_API_KEY>。)把 Authorization 换成一个无效值再发一次,按文档语义,你应该拿到 error.codeserver_error、而顶层 error_typeauthentication 的响应体。能同时看到这两个值,说明你的解析路径接对了。

Windows 侧要改写法。 上面这段是 Linux/macOS shell 的写法:反斜杠续行、单引号包 JSON。Windows 的 PowerShell 里反斜杠不是续行符、单引号也不按这个规则处理,直接粘会报错。可行做法是把请求体存成 body.json 再用 curl.exe ... -d "@body.json",或者在 WSL / Git Bash 里执行原样命令。这一段是 shell 层面的通用差异,不是 OpenRouter 官方文档的内容,文档里只给了这一种 shell 写法。

验证点二:用 debug 看请求到底被转成了什么。 文档写明 debug 参数在 Chat Completions 和 Responses 两个端点都能用,形状是:

type DebugOptions = {
  echo_upstream_body?: boolean;
};

在 Responses 上,调试数据以 response.debug 这个 SSE 事件回来(Chat Completions 是把 debug 挂在流的第一个 chunk 上,且那个 chunk 的 choices 是空数组)。两条文档明写的限制必须照抄:debug 只在 stream: true 时生效,非流式请求会忽略它;文档同时以警告框写明不要在生产环境使用,因为它可能返回请求中本不该外泄的敏感信息。

以上为按官方文档中的字段语义组合的示例,未经实测,以官方文档与 API 的实际响应为准。

什么情况说明不是这个原因

最后这一段比前面都重要——下面几种症状长得像,但根子不在两套错误结构的差异上,照着改会白改。

  • error.code 拿到的是数字。 那你请求打的多半还是 /api/v1/chat/completions,先去核对 base URL,Responses 的路径是 https://openrouter.ai/api/v1/responses
  • 403 且 message 写着被拦截。 文档把这归为 guardrail 拦截:请求在到达 provider 之前就被内容过滤或提示注入检测挡下,两个端点上都是同样的 OpenRouter 形状的 403,不是 skin 差异。
  • 500 的 message 是一句笼统的话。 文档写明这是有意的掩码行为:500 时 message 被替换成通用字符串、provider_codeopenrouter_metadata 被省略,但 error_type 仍然在(值为 server)。你没拿到细节不是解析写错了。
  • 429503,且响应带 Retry-After 文档说明这两种状态下 OpenRouter 可能返回标准 Retry-After 响应头(单位为秒),并写明 OpenAI SDK、Anthropic SDK、Vercel AI SDK 与 OpenRouter SDK 都已经遵守它;直接用 fetch 的话需要自己遵守。这是限流/可用性问题,与错误结构无关。
  • 响应完全没有内容。 文档单列了这种情况,归因于模型冷启动预热或系统扩容,并说明这类情况下仍可能被上游按提示词处理计费。它不会以你期待的错误形态出现。
  • 两个不同的 metadata 请求头名对不上。 同一页里,guardrail 一节写的是 X-OpenRouter-Experimental-Metadata: enabled注意这个头名里带 Experimental),而掩码一节写的是「请求设置了 X-OpenRouter-Metadata」。两处都在官方文档正文里逐字出现,文档没有说明二者是什么关系。如果你按其中一个写没生效,先试另一个,别往错误结构上归因。

这一层的东西迭代很快,本文只复述文档语义,实际以官方文档最新内容为准。


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

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

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