给账号池配一个会自己换 IP 的出口层。
new-api 和 LiteLLM 都能给一个渠道配一个代理,但都是一次性静态值:填什么就是什么。 出口一挂,整条渠道跟着挂,得手工去改。zenproxy 补的就是这块——它把出口做成一个池子, 探活、隔离、退避、报错自动换下一个,并且保持会话粘性(同一个账号在一次会话里不会中途换 IP)。
客户端 ──▶ new-api / LiteLLM ──▶ zenproxy ──▶ 出口池(Resin / 静态代理) ──▶ 上游 LLM
│
└─ 探活 · 隔离 · 退避 · 失败分类 · 换出口重试
它不代理模型、不做计费、不改写请求体。它只负责一件事:把请求从一个可用的出口送出去,坏了就换。
zenproxy 在同一个监听端口上同时干三件事,靠请求形态自动区分:
| 模式 | 怎么用 | 谁在用 |
|---|---|---|
| 统一入口 | http://zenproxy:9000/v1/chat/completions + Authorization: Bearer <key> |
任何 OpenAI 兼容客户端;LiteLLM / CLIProxyAPI / sub2api 直接当上游导入(api_base / base-url 写 http://zenproxy:9000/v1) |
| 反代 | http://zenproxy:9000/<账号ID>/v1/chat/completions(账号名写进路径) |
需要把请求固定到某个账号时。new-api 的渠道 Base URL 填 http://zenproxy:9000/<账号ID>——它自己会补 /v1/... |
| 正向代理 | http://<账号ID>:x@zenproxy:9000 |
new-api 的渠道「代理」字段、sub2api 的 proxies 记录、任何只支持代理设置的工具 |
★ 填 base URL 前先跑
scripts/check-api-base.sh。 少写或多写一个/v1都不会得到「找不到路径」,而是掉进反向入口、报成「账号不存在」—— 三个聚合器有三副面孔,没有一句会提到 base URL。见 GATEWAY.md。
统一入口是唯一不需要调用方知道账号名的形状。 它对外只有一个 URL 和一个 key, 账号与出口都由 zenproxy 在内部决定——所以「上游按 IP 限额就自动换」这件事不用调用方配合:
POST /v1/chat/completions (Bearer <key>)
│ key → 池
▼
账号 A ─ 出口 X → 429 ← 上游说这个 IP 花完了
账号 A ─ 出口 Y → 429 ← 还是它,那问题更像在账号上
账号 B ─ 出口 Z → 200 ✓ 调用方只看到这个
反代模式下 zenproxy 终止 TLS,能看到上游的明文响应,因此能按状态码决定换出口;
正向代理模式下 https 目标是一条 CONNECT 隧道,它只能按"连不上"换出口。
要"报错自动换 IP"就用统一入口或反代模式。 详见 docs/GATEWAY.md
与 docs/NEW-API.md。
go build -o build/zenproxy ./cmd/zenproxy
cp config.example.yaml config.local.yaml # 填好 ZEN_ADMIN_TOKEN / RESIN_PROXY_TOKEN
ZEN_ADMIN_TOKEN=dev-token ./build/zenproxy -config config.local.yaml不需要真出口也能看到东西:
curl -s localhost:9000/healthz # {"status":"ok"}
curl -s -H 'X-Admin-Token: dev-token' localhost:9000/admin/api/status控制台在 http://127.0.0.1:9000/admin/?token=dev-token。
完整字段见 config.example.yaml,internal/config/config.go 是权威定义。
配置里写 ${VAR} 会从环境变量展开;密钥不要写进配置文件。
值得单独说的几个开关:
| 配置 | 默认 | 说明 |
|---|---|---|
policy.max_attempts |
3 | 单次请求最多尝试几个出口 |
policy.rotate_on_status |
[407,429,5xx…] |
命中这些状态码就换出口重试 |
policy.rotate_on_transport |
true | 连不上就换出口 |
policy.allowed_hosts |
[] |
正向代理模式的目标白名单。上游域名自动加入,不用手写。留空只应出现在监听回环时 |
health.failure_threshold |
3 | 连续失败几次开始隔离 |
health.quarantine / max_backoff |
5m / 30m | 隔离时长与指数退避上限 |
health.max_latency |
0(关闭) | 上一轮探活超过这个耗时的出口被降为备用:仍在池子里、仍算健康,但不再被优先选中。用公共/免费节点拼的池子建议设 5s——实测这类池子里"能用"的出口常常要 20 秒才回。未测量过的出口永远算合格,新成员不会被饿死 |
health.retire_after |
20 | 永久淘汰:连续失败到这个次数、中间一次都没成功过,就把出口彻底移出轮换。任何一次成功清零计数,所以"时好时坏但还能用"的不会被淘汰。0 = 关闭 |
stream_idle_timeout |
5m | 上游收了流式请求后一字节不发,多久掐断 |
upstream_timeout |
10m | 单次上游请求硬上限 |
启动即校验,不合规直接退出,不会带着半个错误配置跑起来。缺 ${VAR}、启用的账号没有 key、
账号引用了不存在的上游、admin token 为空——都会在启动时明确报出是哪一个变量/账号有问题。
- 出口(exit):一个能出网的代理端点。池子成员来自
proxies.resin和proxies.static。 - 账号(account):一把上游密钥 + 出口策略(
auto/direct/ 指定出口 ID)。 - 绑定:账号与出口的粘性关系,持久化在状态文件里,重启不丢。
几个刻意的设计取舍:
- 亲和,不是身份槽位。 一个账号绑定到一个出口,是为了让长会话不中途换 IP,
而不是为了给每个账号伪造一个"独立家庭"身份。出口挂了、或用
auto时从池子里挑,都是共享的可用性池。 - 429 不判出口死刑。 上游限流是账号级配额问题,出口把请求送达并带回了响应,说明出口是好的。
所以这类失败只移动绑定(
soft_failures计数),不隔离出口。否则一次账号限流就能把整个 IP 池耗干。 tier_gate单独归类。 上游按"客户端身份"拒绝时,换 IP 是无效的。 这类响应既不算出口的错,也不浪费重试预算,原样透传给客户端,并在日志里明确说清"换出口解决不了"。- 指定出口是首选,不是无期徒刑。
proxy: <出口ID>在该出口健康时生效; 一旦它被隔离就自动退回池子里挑——否则最需要轮换的账号反而永远轮换不了。 - 隔离是打盹,淘汰是出门。 隔离有退避上限,到点自动复活——这适合"网络抖一下"。
但对一个已经死透的出口,反复复活只是永远占着位置、永远浪费重试预算。
所以
retire_after一到就永久淘汰,只能手动恢复。清出来的是位置,不是数据: 状态文件里仍留着它的历史,控制台上也看得到,只是不再参与轮换。 - 池子空掉时降级,不是崩掉。 所有出口都被淘汰时,
Pick会退回到失败最少的那个已淘汰出口, 并在日志里喊一嗓子。宁可带着最不坏的出口苟着,也不要给网关回 502—— 但这一嗓子必须喊,否则运维会以为池子还好好的。
控制台右上角有个**「清理垃圾出口」**按钮,对应 POST /admin/api/proxies/prune。
它把当前「隔离中 / 降级 / 延迟超过 max_latency」的出口一次性永久移出轮换,并返回做了什么:
{"report":{"retired":["static-01"],"already_retired":null,"usable_after":2,"pool_empty":false}}- 幂等:再点一次不会重复动作,
already_retired会列出已淘汰的。 - 不手软:如果清完池子是空的,它会照清不误,但
pool_empty: true会明确告诉你这一点。 - 可逆:误清了就在表格里点该行的「恢复」(
.../revive), 恢复会连失败计数一起清零,给它一个干净的起点而不是下一次抖动又被淘汰。 - 表格里状态列会预先标出「可清理」,所以点按钮前就能看到会动到谁。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /healthz |
存活探测,免鉴权 |
| GET | /metrics |
Prometheus 文本格式 |
| GET | /admin/ |
控制台(令牌可走 ?token=):总览卡、免费额度区、运行配置(只读)、出口表、账号表;账号支持编辑回填、启用/禁用、换出口、删;出口支持退休/恢复 |
账号行上的「最近一次」按这次请求本身来说,不把客户端多等的时间算进来:试了几次、前面没走通用了多久、剩下多少秒不是这条出口在拨号、写出了多少 token、大约每秒多少个、缓存命中多少、要的是整包、这次是直接连上的。失败且等了至少 10 秒写「这次没走通」。成功之后仍留着「上一回没走通用了多久」。逐条记录在 CHANGELOG.md 的 v0.3.0。这个仓库没有单独的文档站,接入说明在 docs/。
| GET | /admin/api/status | 概览(含 free_tier、policy、health 只读块与 exits_retired) |
| GET/POST | /admin/api/accounts | 列出 / 新增或修改账号 |
| DELETE | /admin/api/accounts/{id} | 删除账号 |
| POST | /admin/api/accounts/{id}/rotate | 手动换出口 |
| GET | /admin/api/proxies | 出口健康、真实出口 IP、延迟、隔离/退休状态 |
| POST | /admin/api/proxies/probe | 触发一轮探活 |
| POST | /admin/api/proxies/prune | 一键清理:把当前不可用的出口(隔离中 / 降级 / 延迟超 max_latency)永久移出轮换 |
| POST | /admin/api/proxies/{id}/retire | 手动退休一个出口 |
| POST | /admin/api/proxies/{id}/revive | 恢复一个已退休的出口 |
鉴权用 X-Admin-Token、Authorization: Bearer 或 ?token=。
接口永远不回显完整密钥,只返回 sk-a…rect 这样的提示串。
opencode 的 *-free 模型只认官方客户端,别的调用方一律 403 FreeTierError。
zenproxy 会把免费模型的请求补齐成官方客户端的样子再发出去,于是这些额度能直接用:
free_tier:
enabled: true # 默认开。关掉 = 一个字节都不改写
models: # 预期在免费额度里的模型;列表外的模型第一次被拒后会自己学会
- big-pickle
- deepseek-v4-flash-free
user_agent: "" # 留空 = 镜像抓包得到的官方 UA行为要点:
- 只碰免费模型。 付费模型(列表外的)一个字节都不改,原样转发。
- 不写
stream的请求会被折叠。 免费额度只回 SSE,调用方要的是单个 JSON 时, 代理读完整条流再折回一个chat.completion(内容、tool_calls、usage 全部保留)。 - 带
tools的请求也能过。 调用方自己的工具照常可用,实测模型会正确调用它; 为了过门禁,代理会在工具列表后面补几个"占位"名字。 - 第一次被拒不用管:403 会被就地转成"补壳重试",并把该模型记下来
(
/admin/api/status的free_tier.learned),下一次直接套壳。 - 正向代理模式改写不了(CONNECT 是盲隧道),超大 body 同理。
判据、阈值、复现方法和代价见 docs/FREE-TIER.md。
上游改了判据也不会静默失败:日志会打 tier_gate 警告并指向那篇文档。
make test # 单元测试 + 端到端
make vet
make smoke # 只跑端到端端到端测试会在 httptest 上起一个假上游和一个假出口,覆盖这些行为:
- 账号被钉在死出口上 → 传输失败 → 换到活出口 → 200,且
rotations == 1 tier_gate403 → 原样透传、不换出口、不消耗重试预算- 429 → 换出口后成功,但不隔离出口本身
- SSE 逐块送达:上游发第一块后阻塞,客户端必须在阻塞解除前收到它(证明不是缓冲整包)
- 上游收了流式请求后静默 → 被
stream_idle_timeout掐断,而不是挂到硬超时 - 正向代理模式:从
Proxy-Authorization用户名解析账号;无凭据 407;白名单外 403 - SOCKS5 客户端对着一个真的 RFC1928 服务端跑(不是 mock 接口),含密码错误路径
- 免费额度:被拒的免费模型被就地补壳重试 → 200,且第二次调用一次到位(不再浪费一次 403)
- 免费额度:非流式调用拿到的是折叠好的单个 JSON;
accept-encoding不会把br透传给上游 (透传会让 403 变成密文、错误分类失灵)
真实 socket 冒烟(需要 Node):
node scripts/mock-upstream.js & # 假上游 :28081
node scripts/mock-exit.js & # 假出口 :28082
./build/zenproxy -config config.smoke.yaml见 deploy/README.md 与 docs/NEW-API.md。
接 new-api 时尤其要看 docs/NEW-API.md 第七节:
- OpenCode Zen 的
*-free模型按客户端身份拒绝(HTTP 403FreeTierError), 换 IP、换账号都无法改变这个结果——这一条 zenproxy 现在自己解决了: 见 免费额度,实测判据与代价在docs/FREE-TIER.md。 - new-api 的
STREAMING_TIMEOUT默认 120s,会先于 zenproxy 掐断长流式响应。 - 出口池全挂时返回 502,new-api 的
auto_ban会把它记在渠道头上。 - 下游必须和 zenproxy 在同一个 docker 网络。 它默认只接
new-api_default+resin_default,而 CLIProxyAPI、sub2api 之类往往在别的网络上, 不接的话容器名根本解析不了:docker network connect <网络> zenproxy。 另外docker compose up -d重建容器会丢掉手工接的网络,要重接。 allowed_hosts默认只放行opencode.ai。 有些下游用api64.ipify.org之类做代理健康探针,不放行的话那条代理永远显示 failed, 而实际转发是好的。
关于出口池本身的质量,有一组实测数据值得先看:docs/RESIN.md。
要点:池子规模 ≠ 可用出口数量。你服务器上的 Resin 动态池有 1435 个节点,
但实测 4 个成员里只有 1 个能通,而且它花了 20.8 秒。这类池子必须开
health.max_latency,并把 max_attempts 给够。同一篇文档里也记录了粘性身份的实测边界:
Account 到出口的映射只在节点存活期间稳定。
cmd/zenproxy/ 入口:路由分发、优雅退出、请求日志
internal/config/ 配置加载、${VAR} 展开、启动期校验
internal/model/ 共享类型:账号、上游、错误分类、统计
internal/proxypool/ 出口池:HTTP/SOCKS5 拨号、探活、隔离与退避
internal/registry/ 账号注册表:绑定、轮换、统计、状态持久化
internal/gateway/ 统一入口的凭据与模型映射解析(key→池、model_name→上游名)
internal/forward/ 转发核心:统一入口、反代模式、正向代理模式、失败分类与轮换重试;免费额度套壳(freegate.go)与 SSE 折叠(ssefold.go)
internal/admin/ 管理 API + 内嵌控制台
deploy/ 容器部署产物
docs/ 接入与运维文档
scripts/ 本机冒烟用的假上游 / 假出口 + 部署与运维自检脚本
.github/workflows/ CI:gofmt / vet / test -race / 静态构建
CHANGELOG.md 版本变更记录
发布版本与二进制见 Releases,
版本变更见 CHANGELOG.md。
scripts/ 里这几个是给已经跑起来的部署用的,都在部署机上 sudo 执行:
| 脚本 | 干什么 |
|---|---|
zenproxy-status.sh |
一次性打印容器状态、概览、出口池、账号绑定(不回显任何密钥) |
probe-free-models.sh |
逐个实测免费模型是否真的可用;MT= 给 token 预算(推理模型要给大) |
refresh-free-models.py |
把 free_tier.models 换成实测可用的清单(逐行改写,不动注释排版) |
probe-resin-slots.sh |
实测 Resin 粘性身份的真实可用率,用来决定池子该开多大 |
set-resin-slots.py |
把 proxies.resin.accounts 扩到 N 个 slot |
e2e-free-tier.sh |
端到端:经代理调一次免费模型,验证套壳与 SSE 折叠 |
redact.py |
读任何含密钥的文件/输出都必须过它;--test 跑历史泄露形状 |
set-newapi-channel-status.sh |
改 new-api 渠道启用状态,并列出每个模型还剩几条渠道 |
rotate-cpa-inbound-key.py |
轮换 CLIProxyAPI 入站密钥(定点行替换,保注释,带结构断言) |
check-exit-spread.sh |
查账号是否落在不同出口、有无幽灵占用(只读,多次采样,有冲突退出 1) |
sweep-stale-credential.py |
轮换后扫出还留着旧凭据的配置并替换(只报路径,从不打印凭据) |
免费模型名会随上游漂移,清单过期必须人工换(表现是 400 Model is unavailable,
不是 403),换法见 docs/FREE-TIER.md 第五节。
不要手写 sed/grep 正则去脱敏。 这个仓库的历史里有六次凭据被打印进终端/日志,
六次都是同一个原因:脱敏者枚举了自己以为存在的模式——漏掉 YAML 列表项、
漏掉 nvapi-/<hex>.<base64>/base64/URL 内嵌密码这些形状、被 ZEN_KEY_01= 里的
_01 骗过、用 len(v) > 12 当过滤器。
唯一认可的做法是 scripts/redact.py(按键名与值形状两个独立信号判定,
支持 JSON/YAML/.env/env dump/纯文本,可接管道):
sudo scripts/redact.py /opt/zenproxy/data/state.json
sudo docker exec zenproxy env | scripts/redact.py -六次事故的具体形状、这个部署里密钥都在哪、以及漏了之后按「能不能自己换」分类的处理
步骤,见 docs/SECRETS.md。