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

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

起因是一个 API 中转站 Agent Router 怎么都连不上。ZCode 里配好了地址和密钥,发请求没反应;换到 9Router 中转,一样失败。密钥是新的,地址也是官方给的,看不出哪里错了。

排查下来发现是请求头 UA 的问题,必须是特定的 UA 才允许访问。

域名能连,但密钥被拒

访问域名 https://ps.air-outer.com ,连通性正常:

1
2
3
curl -s -o /dev/null -w "HTTP:%{http_code} connect:%{time_connect}s\n" \
https://ps.air-outer.com
# HTTP:200 connect:0.420688s

DNS 解析到阿里云新加坡的 ALB,站点标题是「Agent Router — Claude Code / OpenAI Codex / Gemini CLI 公益站」。

但带上密钥请求接口,返回:

1
2
3
4
{
"error": {"message": "unauthorized client detected, contact support..."},
"type": "unauthorized_client_error"
}

关键线索在措辞上——写的是 unauthorized_client,不是 invalid_api_key。它嫌弃的是客户端,不是密钥。

为了确认,做了个对照:换成假密钥、完全不带密钥,返回的错误一字不差。既然带不带密钥结果相同,说明请求在鉴权之前就被拦下了。

定位:服务端在校验 User-Agent

社区里提到这站有 UA 白名单,只放行官方客户端。带上试试:

1
2
3
curl https://ps.air-outer.com/v1/models \
-H "User-Agent: Kilo-Code/7.3.50 ai-sdk/provider-utils/4.0.27 runtime/bun/1.3.14" \
-H "Authorization: Bearer sk-xxx"
1
2
3
4
5
{"data":[
{"id":"claude-opus-4-8","supported_endpoint_types":["anthropic","openai"]},
{"id":"claude-opus-5","supported_endpoint_types":["anthropic","openai"]},
{"id":"gpt-5.6-sol","supported_endpoint_types":["openai"]}
]}

换成 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
2
3
4
5
6
7
{
"kind": "anthropic",
"options": {
"apiKey": "",
"baseURL": "https://open.bigmodel.cn/api/anthropic"
}
}

只有 apiKeybaseURL,没有 headers 相关的任何入口。9Router 那边同理,配置里也没有给上游加自定义头的位置。

命令行能通,客户端不能通,差的就是一个 UA。

解法:让反代来加这个头

思路很直白——中间架一层,客户端把请求发给反代,反代改完 UA 再转给中转站。

1
ZCode → 反代(注入 UA)→ ps.air-outer.com

选 Cloudflare Worker 的原因是它天然支持流式转发。这点很关键,后面会提到。

workers.dev 可能连不上

我这边实测 *.workers.dev 是超时的,*.pages.dev 反而通。如果你也遇到这情况,就得绑自己的域名。域名托管在 Cloudflare 的话,在 Worker 设置里加自定义域即可,DNS 会自动配好。

Worker 代码

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// ============================================================
// ps.air-outer.com 反代 Worker —— 自动注入官方客户端 User-Agent
// 用法:绑定到你的自定义域名(如 api.你的域名.com)
// ZCode/9Router 的 baseURL 填: https://api.你的域名.com (不用带 /v1)
// ============================================================

const TARGET = "https://ps.air-outer.com";

// 官方客户端的 User-Agent(站点白名单校验)
const OFFICIAL_UA = "Kilo-Code/7.3.50 ai-sdk/provider-utils/4.0.27 runtime/bun/1.3.14";

// 转发时剔除的逐跳(Hop-by-hop)请求头,交给 fetch 自动重建
const HOP_HEADERS = [
"host", "connection", "keep-alive", "transfer-encoding",
"upgrade", "proxy-authenticate", "proxy-authorization",
"te", "trailer", "content-length",
];

export default {
async fetch(request) {
const url = new URL(request.url);

// 容错:若客户端 baseURL 带 /v1 且又自动拼 /v1,折叠成单层
let path = url.pathname;
if (path.startsWith("/v1/v1")) path = path.replace(/^\/v1\/v1/, "/v1");

const targetUrl = TARGET + path + url.search;

// 复制请求头并强制替换 UA
const headers = new Headers(request.headers);
headers.set("User-Agent", OFFICIAL_UA);
for (const h of HOP_HEADERS) headers.delete(h);

const init = {
method: request.method,
headers,
redirect: "manual",
};

// 携带请求体(GET/HEAD 不带)
if (request.method !== "GET" && request.method !== "HEAD") {
init.body = request.body;
}

// 转发并原样返回(Resp.body 是 ReadableStream,SSE 流式不会被打断)
const resp = await fetch(targetUrl, init);
const respHeaders = new Headers(resp.headers);
respHeaders.delete("content-length"); // 流式响应长度由 CF 重建

return new Response(resp.body, {
status: resp.status,
statusText: resp.statusText,
headers: respHeaders,
});
},
};

几个地方值得说一下。

只动 UA,别的一概不碰。 鉴权头、anthropic-version、请求体全部原样转发。改得越少,出问题的可能越小。

逐跳头必须删掉。 hostconnectioncontent-length 这些是描述单次连接的,硬带到下一跳去会出错,让 fetch 自己重新算。

响应体直接传 ReadableStream。 这是流式能不能用的分界线。如果写成 await resp.text() 再返回,SSE 就变成了一次性响应,客户端会一直转圈等到结束才吐字。

/v1 折叠是防呆。 不同客户端拼路径的习惯不一样,加这几行两种写法都不会坏。

部署

  1. Cloudflare Dashboard → Workers & Pages → 创建 Worker,粘代码,部署
  2. Worker 设置 → 域和路由 → 添加自定义域
  3. 客户端 baseURL 填你的域名,不用带 /v1

验证

部署完逐项测过来。

模型列表:

1
curl https://你的域名/v1/models -H "Authorization: Bearer sk-xxx"

注意这里没带 UA,返回照样正常——说明 Worker 那边确实把头注进去了。

对话接口:

1
2
3
4
5
curl -X POST https://你的域名/v1/messages \
-H "x-api-key: sk-xxx" \
-H "anthropic-version: 2023-06-01" \
-H "Content-Type: application/json" \
-d '{"model":"claude-opus-5","messages":[{"role":"user","content":"hi"}],"max_tokens":20}'

流式:

1
2
3
4
5
curl -N -X POST https://你的域名/v1/messages \
-H "x-api-key: sk-xxx" \
-H "anthropic-version: 2023-06-01" \
-H "Content-Type: application/json" \
-d '{"model":"claude-opus-5","stream":true,"messages":[{"role":"user","content":"count 1 to 3"}],"max_tokens":40}'

message_startcontent_block_deltamessage_stop 依次推过来,没有卡顿也没有一次性吐完。

工具调用这块单独测了完整往返:模型发出 tool_use、参数正确生成 {"city":"Beijing"}stop_reasontool_use、把 tool_result 传回去之后能拿到最终回答。编程工具靠的就是这条链路,必须确认能走通。

汇总:

测试项 Anthropic 格式 OpenAI 格式
基础对话 通过 通过
流式 SSE 通过 通过,但混了 data: null
工具调用 通过 通过
tool_result 往返 通过 未测
usage 统计 准确 字段错乱
prompt caching 支持 无此概念

两种格式选哪个

如果模型是 Claude,选 Anthropic 格式。三个理由。

usage 统计能看。 OpenAI 那边返回过 prompt_tokens: 2cached_tokens: 43001 这种自相矛盾的数,input_tokens 还恒为 0。Anthropic 格式的数字是自洽的。

prompt caching 保得住。 Anthropic 格式会返回 cache_creation_input_tokenscache_read_input_tokens。编程工具反复读同一批文件,缓存命中能省下大部分输入费用,OpenAI 格式没这个概念,转换时直接丢了。

少一层转换。 Claude 的原生协议就是 Anthropic,走 OpenAI 格式等于多一次字段映射,tool_usestop_reason 这些结构容易失真。实测 OpenAI 流里混进了 data: null——标准协议里 data: 后面只能是合法 JSON 或 [DONE],严格的解析器碰上这个可能直接中断。

只有一种情况必须用 OpenAI 格式:调 gpt-5.6-sol,它的 supported_endpoint_types 里只有 openai

最终配置

1
2
3
4
类型:Anthropic
Base URL:https://你的域名
API Key:你的密钥
模型:claude-opus-5

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