调用第三方大模型api接口被一个406报错卡了半天,最后发现是Accept头的顺序问题 0 次阅读

事情是这样的

最近在接一个第三方中转 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
2
3
4
curl -X POST "https://example.com/v1/chat/completions" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <KEY>" \
-d '{"model":"...","messages":[...],"stream":true}'

Accept 缺失时,服务端不再做严格媒体类型校验,直接走默认协商,流式接口默认返回 text/event-stream,反而能通。

踩坑清单

  1. 406 先怀疑 Accept,但别只改值,要看顺序。application/json, text/event-stream 和 text/event-stream, application/json 在规范上等价,在实现上不等价。
  2. 用 curl -i 或 -v 看真实请求头。SDK、代理、CDN(这个站用的是 Caddy)都可能改写或重复发送 Accept。
  3. model_not_found 是好消息。它说明鉴权、路径、Accept 协商都过了,只剩参数问题,别被不同错误混在一起搞晕。
  4. 第三方中转站的模型列表以 /v1/models 为准。别凭记忆猜模型名,分组权限可能导致列表里只有少数几个模型。

最终能用的 curl 模板

1
2
3
4
5
6
7
8
9
curl -X POST "https://example.com/v1/chat/completions" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <你的KEY>" \
-H "Accept: text/event-stream, application/json" \
-d '{
"model": "deepseek-ai/DeepSeek-V4.1-Flash-1017",
"messages": [{"role": "user", "content": "测试"}],
"stream": true
}' -i

先小结一下

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import requests

headers = {
"Authorization": f"Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",

# 核心:伪装成白名单内的客户端
"User-Agent": "opencode/1.18.31",

# 以 OpenCode 为例,可能还需要配套会话头
"x-opencode-session": "ses_0123456789abABCDEFGHIJKLMN",

# 根据实际服务端校验情况补充
# "x-opencode-org-id": "...",
# "Originator": "...",
# "X-Title": "...",
}

response = requests.post(
"https://example.com/v1/chat/completions",
headers=headers,
json={
"model": "deepseek-ai/DeepSeek-V4.1-Flash-1017",
"messages": [{"role": "user", "content": "测试"}],
"stream": True,
},
stream=True,
)

print(response.status_code)
print(response.text)

注意:上面只是示例。实际要伪装成哪个客户端,就以那个客户端真实发出的请求头为准。

方式三:本地反向代理 / 抓包工具改写

客户端不支持自定义 Header 的话,可以在本地起个反向代理,mitmproxy、Charles,或者自己写个轻量转发服务:

  1. 客户端把请求发到本地代理
  2. 代理拦截请求
  3. 代理注入或替换 User-Agent、会话头、版本头等字段
  4. 代理转发到真实 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。服务端校验严格的话,配套头缺失或格式不对,照样判“当前客户端不支持”。

几个注意事项

  1. 一致性很重要:服务端可能同时校验 User-Agent、会话 ID 格式、其他配套头,甚至请求体字段。只改一个头很容易被识破。
  2. 动态字段要动态生成:会话 ID、请求 ID 这类字段不能写死,得按会话或请求生成。
  3. 注意代理和 CDN 改写:和排查 Accept 一样,用 curl -v 或抓包确认最终发到服务端的请求头,别被代理、CDN 或 SDK 改写。
  4. 合规风险:这类操作通常属于对抗服务端客户端校验,可能违反 API 提供商的服务条款,存在 API Key 被封禁的风险。先确认自己有权对目标端点做此类操作,比如自建网关、企业内部服务或已获得明确授权。
  5. 优先走正规渠道:如果服务端提供“自定义客户端标识”或“申请白名单”的正规入口,优先用那个,比伪造请求头稳。

和前面 406 的区别

一句话:406 是内容协商没过,客户端限制是身份校验没过。

· 406 / Could not satisfy the request Accept header → 先看 Accept 的值和顺序
· 当前客户端不支持 / 仅限指定客户端 → 先看 User-Agent 和客户端配套请求头

两个都叫“请求失败”,但排查方向完全不同。记下来,免得下次又混着查。

P.S. 这类中转站的服务端实现五花八门,今天能用的头明天可能就改了。遇到新的报错,先抓包看真实请求,比对着文档猜快得多。

下一篇 用 AI Agent 把桌面宠物搬进 Android,dsh-pet-android 顺手记了点踩坑笔记
感谢您的支持!
微信赞赏码 微信赞赏
支付宝赞赏码 支付宝赞赏