用 CF Worker 反代中转站,解决客户端改不了请求头的问题

用 CF Worker 反代中转站,解决客户端改不了请求头的问题
雨天狂奔用 CF Worker 反代中转站,解决客户端改不了请求头的问题
起因是一个 API 中转站 Agent Router 怎么都连不上。ZCode 里配好了地址和密钥,发请求没反应;换到 9Router 中转,一样失败。密钥是新的,地址也是官方给的,看不出哪里错了。
排查下来发现是请求头 UA 的问题,必须是特定的 UA 才允许访问。
域名能连,但密钥被拒
访问域名 https://ps.air-outer.com ,连通性正常:
1 | curl -s -o /dev/null -w "HTTP:%{http_code} connect:%{time_connect}s\n" \ |
DNS 解析到阿里云新加坡的 ALB,站点标题是「Agent Router — Claude Code / OpenAI Codex / Gemini CLI 公益站」。
但带上密钥请求接口,返回:
1 | { |
关键线索在措辞上——写的是 unauthorized_client,不是 invalid_api_key。它嫌弃的是客户端,不是密钥。
为了确认,做了个对照:换成假密钥、完全不带密钥,返回的错误一字不差。既然带不带密钥结果相同,说明请求在鉴权之前就被拦下了。
定位:服务端在校验 User-Agent
社区里提到这站有 UA 白名单,只放行官方客户端。带上试试:
1 | curl https://ps.air-outer.com/v1/models \ |
1 | {"data":[ |
换成 claude-opus-5 再请求,正常返回内容和计费明细。问题定位完成。
顺带确认:接口只挂在 /v1 下面
配置 baseURL 之前先摸清路由。测了几个路径:
| 路径 | 结果 |
|---|---|
/models |
返回前端 HTML(SPA 兜底路由) |
/messages |
返回前端 HTML |
/v1/models |
正常 JSON |
所以 API 全在 /v1 前缀下,根路径只有网页。另外这网关不会自动折叠多层 /v1,如果客户端自己会拼 /v1,baseURL 就不能再带一个。
卡点:客户端根本没有改 UA 的地方
模型、密钥、路径都清楚了,剩下一个死结——ZCode 不支持自定义请求头。
翻它的配置文件 ~/.zcode/v2/config.json,provider 段只有这么几个字段:
1 | { |
只有 apiKey 和 baseURL,没有 headers 相关的任何入口。9Router 那边同理,配置里也没有给上游加自定义头的位置。
命令行能通,客户端不能通,差的就是一个 UA。
解法:让反代来加这个头
思路很直白——中间架一层,客户端把请求发给反代,反代改完 UA 再转给中转站。
1 | ZCode → 反代(注入 UA)→ ps.air-outer.com |
选 Cloudflare Worker 的原因是它天然支持流式转发。这点很关键,后面会提到。
workers.dev 可能连不上
我这边实测
*.workers.dev是超时的,*.pages.dev反而通。如果你也遇到这情况,就得绑自己的域名。域名托管在 Cloudflare 的话,在 Worker 设置里加自定义域即可,DNS 会自动配好。
Worker 代码
1 | // ============================================================ |
几个地方值得说一下。
只动 UA,别的一概不碰。 鉴权头、anthropic-version、请求体全部原样转发。改得越少,出问题的可能越小。
逐跳头必须删掉。 host、connection、content-length 这些是描述单次连接的,硬带到下一跳去会出错,让 fetch 自己重新算。
响应体直接传 ReadableStream。 这是流式能不能用的分界线。如果写成 await resp.text() 再返回,SSE 就变成了一次性响应,客户端会一直转圈等到结束才吐字。
双 /v1 折叠是防呆。 不同客户端拼路径的习惯不一样,加这几行两种写法都不会坏。
部署
- Cloudflare Dashboard → Workers & Pages → 创建 Worker,粘代码,部署
- Worker 设置 → 域和路由 → 添加自定义域
- 客户端 baseURL 填你的域名,不用带
/v1
验证
部署完逐项测过来。
模型列表:
1 | curl https://你的域名/v1/models -H "Authorization: Bearer sk-xxx" |
注意这里没带 UA,返回照样正常——说明 Worker 那边确实把头注进去了。
对话接口:
1 | curl -X POST https://你的域名/v1/messages \ |
流式:
1 | curl -N -X POST https://你的域名/v1/messages \ |
message_start、content_block_delta、message_stop 依次推过来,没有卡顿也没有一次性吐完。
工具调用这块单独测了完整往返:模型发出 tool_use、参数正确生成 {"city":"Beijing"}、stop_reason 是 tool_use、把 tool_result 传回去之后能拿到最终回答。编程工具靠的就是这条链路,必须确认能走通。
汇总:
| 测试项 | Anthropic 格式 | OpenAI 格式 |
|---|---|---|
| 基础对话 | 通过 | 通过 |
| 流式 SSE | 通过 | 通过,但混了 data: null |
| 工具调用 | 通过 | 通过 |
| tool_result 往返 | 通过 | 未测 |
| usage 统计 | 准确 | 字段错乱 |
| prompt caching | 支持 | 无此概念 |
两种格式选哪个
如果模型是 Claude,选 Anthropic 格式。三个理由。
usage 统计能看。 OpenAI 那边返回过 prompt_tokens: 2 配 cached_tokens: 43001 这种自相矛盾的数,input_tokens 还恒为 0。Anthropic 格式的数字是自洽的。
prompt caching 保得住。 Anthropic 格式会返回 cache_creation_input_tokens 和 cache_read_input_tokens。编程工具反复读同一批文件,缓存命中能省下大部分输入费用,OpenAI 格式没这个概念,转换时直接丢了。
少一层转换。 Claude 的原生协议就是 Anthropic,走 OpenAI 格式等于多一次字段映射,tool_use、stop_reason 这些结构容易失真。实测 OpenAI 流里混进了 data: null——标准协议里 data: 后面只能是合法 JSON 或 [DONE],严格的解析器碰上这个可能直接中断。
只有一种情况必须用 OpenAI 格式:调 gpt-5.6-sol,它的 supported_endpoint_types 里只有 openai。
最终配置
1 | 类型:Anthropic |
9Router 上游填一样的地址就行,不用再折腾自定义头。
一点提醒
测的过程中撞见几次 token 统计异常。一句 “Say OK” 被算成 1448 个输入 token,claude-opus-4-8 同样的请求只用 10 个;还有一次很短的 tool_result 请求被记成 7264 token。
Worker 只改 UA、不动请求体,所以不是反代的问题。大概率是上游转发时注入了 system prompt 并计进了你的账。用之前建议先在后台对几笔单次请求的账,别等月底才发现对不上。
关于这类中转站
用 UA 白名单认客户端本身就说明服务不太稳定。域名可能说换就换,UA 规则也可能哪天就变了。真变了的话,把代码里
OFFICIAL_UA那行改掉重新部署一次即可,别的不用动。
#cloudflare #反代 #AI #API


