OpenRouter Responses API 的错误处理:与 Chat Completions 不是一套
把一段跑了很久的调用代码从 /api/v1/chat/completions 换到 /api/v1/responses,最容易出的事不是请求发不出去,而是请求发出去了、错误也回来了,但你写的那一堆错误分支一条都没命中。日志里只剩一个兜底的 unknown error,重试策略跟着一起失灵。
原因不在网络,也不在模型。OpenRouter 官方文档里,这两个端点的错误响应本来就是两套结构,而且差异不在边角,在最外面那一层。
现象长什么样
从文档能对得上的典型症状有三种:
switch (error.code)一条都不匹配。原来按401、402、429分支的代码,换端点后全落到default。- 明明是凭据问题,日志里记的却是「服务端错误」,于是被重试逻辑当成临时故障反复重试。
- 上下文超了的那种请求,不但没进错误分支,反而当成一次成功的响应被下游消费了。
这三种都指向同一处:错误对象里 code 的类型和取值范围变了,而承载真实原因的字段挪了位置。
第一步:怎么确认自己踩的就是这个
别急着改代码,先做四个可执行的判定动作。
动作一,打印 error.code 的类型。 官方文档《Errors and Debugging》页给出的 OpenRouter 通用错误结构是:
type ErrorResponse = {
error: {
code: number;
message: string;
metadata?: Record<string, unknown>;
};
};
code 是 number。而《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_prompt、rate_limit_exceeded、image_content_policy_violation、server_error。文档明说这是内部 typed error codes 的收窄映射,多个内部类型会塌缩成同一个码——文档举的例子是 context_length_exceeded 和 invalid_request 都会显示成 invalid_prompt。如果你看到的码超出这四个,那问题多半不在这里。
动作四,检查请求里有没有 store 或 previous_response_id。 这一条和错误结构无关,但迁移时经常一起中招:文档在概览页和错误处理页两处都用同一段提示写明,Responses API 是 stateless 的,每次请求必须带完整对话历史,设置了 store: true 或非 null 的 previous_response_id 的请求会被以 400 拒绝。如果你照搬了别处带状态的写法,先排除这一条。
两套结构差在哪:按字段对一遍
| 维度 | Chat Completions | Responses 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 的映射表就明白了:authentication、provider_overloaded、provider_unavailable、timeout、server 这几个内部类型全部塌缩成 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_exceeded 和 invalid_request。同一个词在两层里指的不是一回事。 写映射逻辑时如果拿字符串对拷,方向正好反了。
流式的三种事件形态。 文档写明 Responses 的流式错误会以三种 SSE 事件之一出现:response.failed(终止事件,响应无法完成)、response.error(生成过程中的错误)、error(对齐上游 OpenAI 行为的普通错误事件)。文档同时写明,response.failed 里的 response 对象和非流式 JSON 体一样,都在顶层带规范 error_type。三种事件都要接,只接一种会漏。
还有一类根本不是错误。 文档的「Error code transformations」小节列出四个 error_type——context_length_exceeded、max_tokens_exceeded、token_limit_exceeded、string_too_long——会被转换成成功响应,finish_reason 为 length。这解释了症状三:上下文超了的请求不会走错误分支,它是一次带 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.code 为 server_error、而顶层 error_type 为 authentication 的响应体。能同时看到这两个值,说明你的解析路径接对了。
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_code与openrouter_metadata被省略,但error_type仍然在(值为server)。你没拿到细节不是解析写错了。429或503,且响应带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 的公开内容整理。
该平台闭源,本文只复述官方文档写明的机制,不推断其内部实现;
我们没有对文中涉及的功能做过实测,因此不涉及界面外观与运行表现的任何描述。
该平台的供应商、模型与路由策略随时变动,文中不列具体供应商名单与模型清单;
价格、额度与限流的具体数值请以官方定价页与用量说明为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。