事情是这样的
最近在接一个第三方中转 API,OpenAI 兼容格式,文档写得挺正常。非流式请求一切顺利,200 回来得干脆利落。
然后我把 stream: true 一加,画风突变:
1 | {"error":{"message":"Could not satisfy the request Accept header.","type":"api_error"}} |
406 Not Acceptable,稳定复现,一点面子都不给。
我第一反应:Accept 头没写对呗。补上 application/json, text/event-stream,再试。
还是 406。
行吧,那就认真查。
先看现象
测试环境用 curl 直接打,排除客户端 SDK 干扰。
| 请求类型 | Accept 头 | 结果 |
|---|---|---|
非流式 stream:false |
application/json |
✅ 200 |
流式 stream:true |
application/json, text/event-stream |
❌ 406 |
流式 stream:true |
text/event-stream, application/json |
✅ 200 |
流式 stream:true |
text/event-stream(单值) |
✅ 200 |
流式 stream:true |
不发 Accept 头 | ✅ 200 |
按 HTTP 规范,Accept: application/json, text/event-stream 的意思是“这两种格式我都能接受”。
但很多服务端的实现并不规范:解析 Accept 时只取第一个媒体类型,逗号后面的直接忽略。
这类问题在 Java 生态里有明确的 Bug 记录(Spring 的 MediaType 解析在部分版本上只认首位),Go 写的 MCP 客户端库里也有对应注释——“把 text/event-stream 放在 Accept 最前面,避免严格解析首个值的服务器返回 406”。
所以逻辑是这样的:
· 你发 application/json, text/event-stream → 服务器只看到 application/json → 流式请求期望的是 text/event-stream → 判定“不接受” → 406
· 非流式能过,是因为它本来就要返回 application/json,第一个值碰巧对上了
说白了,规范上等价的两行头,在实现上不等价。顺序就是生死。
三种绕法
方式一:把流式类型放第一位(推荐)
1 | Accept: text/event-stream, application/json |
既兼容“只读首值”的严格服务器,又完整声明了客户端的接受能力,符合 MCP Streamable HTTP 协议的要求。我现在默认就这么写。
方式二:只发单一值
1 | Accept: text/event-stream |
既然只读一个,就给它唯一正确的那个。
方式三:干脆不发 Accept
1 | curl -X POST "https://example.com/v1/chat/completions" \ |
Accept 缺失时,服务端不再做严格媒体类型校验,直接走默认协商,流式接口默认返回 text/event-stream,反而能通。
踩坑清单
- 406 先怀疑 Accept,但别只改值,要看顺序。application/json, text/event-stream 和 text/event-stream, application/json 在规范上等价,在实现上不等价。
- 用 curl -i 或 -v 看真实请求头。SDK、代理、CDN(这个站用的是 Caddy)都可能改写或重复发送 Accept。
- model_not_found 是好消息。它说明鉴权、路径、Accept 协商都过了,只剩参数问题,别被不同错误混在一起搞晕。
- 第三方中转站的模型列表以 /v1/models 为准。别凭记忆猜模型名,分组权限可能导致列表里只有少数几个模型。
最终能用的 curl 模板
1 | curl -X POST "https://example.com/v1/chat/completions" \ |
先小结一下
HTTP 头里的“顺序”看起来无关紧要,但遇到解析实现不规范的服务器,顺序就是生与死的区别。
以后调流式接口报 406,第一步先把 text/event-stream 挪到 Accept 最前面试试,能省半天。
番外:另一类“请求失败”,跟 Accept 没关系
排完 406 之后又撞上另一个问题,表现也是“请求失败”,但根因完全两码事。顺手记一下,免得下次把两类问题混在一起查。
现象
有些 AI 中转站 / API Key 会限制只能在指定客户端或 CLI 里用。直接用自己的代码或第三方客户端请求,会返回类似提示:
1 | 本站仅限主流 Coding 客户端/CLI 使用,支持:Codex、Claude Code、OpenCode、Magpie、DeepSeek Harness、ZCode、Pi、Oh My Pi。当前客户端不支持。 |
这类限制通常不是只看 Authorization,而是会检查请求头里的客户端特征,比如:
· User-Agent
· 客户端专用的会话头
· 应用版本头
· 平台 / 产品标识头
· 某些请求体字段或请求路径特征
光有合法 API Key 还不够,服务端还会判断“这个请求是不是白名单客户端发出来的”。
核心思路
让请求在服务端看来,与白名单内官方客户端发出的请求特征尽量一致。
最常见的是改 User-Agent,但很多服务端不会只校验这一个字段,还会联合校验配套请求头。不能只改一个头,最好按目标客户端的真实请求特征补齐。
方式一:用现成插件 / 中间件自动注入
如果客户端本身支持插件,优先用插件,别手改源码。
· DeepSeek Harness (DSH):可装 dsh-custom-header 插件,注入 Codex / Claude Code / OpenCode 的客户端身份头;也可以用 dsh-fetch-header-rewrite 按规则重写 User-Agent。
· Pi Coding Agent:可装 pi-custom-header 伪装成 Claude Code 或 Codex;也可用 pi-zcode-headers 伪装成智谱官方的 ZCode 客户端。
· Oh My Pi (OMP):源码里已有针对 OpenCode Zen 等端点的客户端身份伪装逻辑。
· 通用反向代理 / 网关:部署类似 workbuddy2api 的轻量网关,转发请求时自动模拟官方 IDE 的请求指纹。
适合“客户端本身能装插件”的场景,维护成本最低。
方式二:代码里手动设置请求头
直接用 Python / Node.js 调 API 的话,在 headers 字典里加目标客户端特征就行。
1 | import requests |
注意:上面只是示例。实际要伪装成哪个客户端,就以那个客户端真实发出的请求头为准。
方式三:本地反向代理 / 抓包工具改写
客户端不支持自定义 Header 的话,可以在本地起个反向代理,mitmproxy、Charles,或者自己写个轻量转发服务:
- 客户端把请求发到本地代理
- 代理拦截请求
- 代理注入或替换 User-Agent、会话头、版本头等字段
- 代理转发到真实 API
部分客户端插件本质上也是这个思路:在 fetch 中间件里重写请求头。
常见客户端的请求头参考
不同客户端差异很大,下面是社区逆向 / 抓包整理出的典型特征,仅供排查参考:
| 客户端 | 常见请求头特征 |
|---|---|
| OpenCode | User-Agent: opencode/<ver>;x-opencode-session,格式类似 ses_<12位十六进制><14位字母数字>;可能还有 x-opencode-org-id 等 |
| ZCode | User-Agent: ZCode/<版本> ai-sdk/provider-utils/<4.0.x> ...;X-Title: Z Code@electron 或 Z Code@cli;可能还有 X-ZCode-App-Version、X-ZCode-Agent、X-Platform |
| Claude Code / Codex | 通常需要 User-Agent、Originator,以及会话相关的 X-Claude-Code-Session-Id 等 |
| Magpie | 会话标识可能使用 X-Conversation-ID 等头部 |
再次强调:不要只改 User-Agent。服务端校验严格的话,配套头缺失或格式不对,照样判“当前客户端不支持”。
几个注意事项
- 一致性很重要:服务端可能同时校验 User-Agent、会话 ID 格式、其他配套头,甚至请求体字段。只改一个头很容易被识破。
- 动态字段要动态生成:会话 ID、请求 ID 这类字段不能写死,得按会话或请求生成。
- 注意代理和 CDN 改写:和排查 Accept 一样,用 curl -v 或抓包确认最终发到服务端的请求头,别被代理、CDN 或 SDK 改写。
- 合规风险:这类操作通常属于对抗服务端客户端校验,可能违反 API 提供商的服务条款,存在 API Key 被封禁的风险。先确认自己有权对目标端点做此类操作,比如自建网关、企业内部服务或已获得明确授权。
- 优先走正规渠道:如果服务端提供“自定义客户端标识”或“申请白名单”的正规入口,优先用那个,比伪造请求头稳。
和前面 406 的区别
一句话:406 是内容协商没过,客户端限制是身份校验没过。
· 406 / Could not satisfy the request Accept header → 先看 Accept 的值和顺序
· 当前客户端不支持 / 仅限指定客户端 → 先看 User-Agent 和客户端配套请求头
两个都叫“请求失败”,但排查方向完全不同。记下来,免得下次又混着查。
P.S. 这类中转站的服务端实现五花八门,今天能用的头明天可能就改了。遇到新的报错,先抓包看真实请求,比对着文档猜快得多。