0. 起因:一个订阅,养活一群 app
事情是这样的。
手上有一个 ChatGPT 订阅,平时用 Codex 写代码用得挺爽。但人一旦闲下来就容易犯错:我手上还躺着好几个半成品 app,每个都想接个大模型。去官方开 API key 吧,按 token 计费,钱包隐隐作痛;不开吧,订阅里那堆额度每周刷新,不用白不用。
于是一个危险的念头冒了出来:能不能把订阅转成 API,部署在一台 Linux 服务器上,给我所有 app 的后端统一调用?
紧接着第二个念头:订阅额度总有用完的时候,到时候总不能所有 app 一起躺平吧? 得有个便宜的备胎顶上,还得让用户知道"现在是省钱模式",也得让我自己知道"该充钱了"。
一搜,订阅转 API 社区里早就有两条路:
- sub2api:走 Codex 官方客户端的 OAuth 通道,做成 API 网关
- web2api(以及 chat2api、webchat2api 这一大家子):走网页版 ChatGPT,把浏览器会话包装成 API
本文分两部分:前半部分是两者的选型,后半部分是我自己写的一个降级网关,订阅用完自动切 DeepSeek,顺便到处哭穷。先剧透结论:多 app 后端场景选 sub2api,前面再套一层降级网关。
1. 先搞清楚两者到底在"转"什么
很多人把它们当成同一类东西,其实上游完全不同。
1.1 sub2api:伪装成"官方客户端"
ChatGPT 订阅(Plus / Pro)登录 Codex CLI 或 Codex Desktop 之后,走的是一条 OAuth + Responses 的专用通道,额度按订阅的窗口(5 小时 / 7 天)计算。sub2api 做的事情,就是拿着这个 OAuth 凭据,在服务端代替官方客户端去请求上游,然后对外暴露一个标准的 OpenAI 兼容接口。
它的本质是一个API 网关:多账号号池、按 key 分发、计量、故障切换,这些都是网关该干的活。
1.2 web2api:远程遥控一个浏览器
web2api 这一派的思路更"物理":你在 Chrome 里登录 ChatGPT,工具通过扩展或者逆向网页接口,把你的提问塞进网页、再把回答抠出来。
注意这里的上游是 chatgpt.com 网页,不是 Codex 通道。它消耗的是网页对话那份额度,全程要过网页那一套风控,而且需要一个真实的、登录着的浏览器。
2. 正面对比
| 维度 | sub2api | web2api 一派 |
|---|---|---|
| 上游 | Codex OAuth 通道 | ChatGPT 网页会话 |
| 部署 | Docker / 单二进制,适合扔服务器 | 依赖一个开着的 Chrome,更像本机工具 |
| 并发 | 号池调度 + 故障转移 | 基本靠标签页,近似串行 |
| 多 app 管理 | 每个 app 发独立 key,分组、限额、统计 | 基本没有 |
| 流式 / 工具调用 | 原生 Responses 链路 | 网页模拟,部分实现把 tool call 拼成普通文本 |
| 风控压力 | 跟随官方客户端特征 | Turnstile、网页改版,随时可能挂 |
| 适合谁 | 后端服务、多应用共享 | 个人本机脚本、尝鲜 |
几个点展开说一下。
部署形态决定了生死。 后端服务要的是 7×24 在线、能重启、能水平扩。web2api 依赖一个真实的浏览器会话,你总不能在 Linux 服务器上常驻一个登录状态的 Chrome 还指望它不出事。sub2api 一个 docker compose up 就完事。
工具调用是硬伤。 我的 app 后端大量依赖 function calling 和结构化输出。网页上根本没有"工具调用"这个概念,web 方案只能把 tool_calls 和 role=tool 的消息转成一段普通文本喂给模型,再从回答里解析。玩具场景能跑,生产环境里这就是一颗定时炸弹。
多 app 共享需要网关能力。 几个 app 共用一份额度,最怕某个 app 写了个死循环把额度一口气烧光,其他 app 全部陪葬。这需要按 key 限额、按 app 统计,这恰恰是 sub2api 的本职工作。
3. 决策树
4. 在 Linux 上部署 sub2api
4.1 用 Docker 部署
sub2api 依赖 PostgreSQL 和 Redis。官方的一键脚本只装 sub2api 本体,数据库要你自己准备;Docker 方案会把 PostgreSQL 和 Redis 一起拉起来,省心很多,我选后者:
mkdir -p /opt/sub2api && cd /opt/sub2api
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh | bash脚本会生成 docker-compose.yml 和 .env,并自动生成 JWT、TOTP、数据库密码这些密钥。启动之前先改一个地方:
# .env
BIND_HOST=127.0.0.1 # 默认是 0.0.0.0,务必改掉
SERVER_PORT=8080为什么必须改?这是 Linux 上一个经典大坑:Docker 发布的端口会绕过 ufw。你以为 ufw deny 8080 就安全了,实际上 Docker 自己改了 iptables,8080 照样对公网敞开。绑到 127.0.0.1,从根上解决。
docker compose up -d
docker compose logs -f sub2api⚠️ GitHub 上 fork 一大堆,有的还在 README 里夹带各种中转站广告。认准 Wei-Shaw/sub2api,官方声明只用 sub2api.org 和 pincc.ai 两个域名。4.2 通过 SSH 隧道进后台
管理后台不对公网开放,本机开个隧道访问:
ssh -L 8080:127.0.0.1:8080 user@your-server
# 然后浏览器打开 http://127.0.0.1:8080首次进入是设置向导(数据库、Redis、管理员账号)。之后大致三步:
- 添加上游账号:选 Codex / OpenAI OAuth,用 ChatGPT 账号完成授权。
- 建分组:把账号放进分组,分组决定可用模型和调度策略。
- 给每个 app 发一个 key:一个 app 一个 key,各自设限额。千万别所有 app 共用一个 key,出事了你都不知道是谁干的。
到这里,sub2api 已经能用了。但我的 app 不直接连 sub2api,中间还要再隔一层,下面细说。
5. 订阅用完了怎么办:穷鬼网关 qiong-gateway
5.1 需求
ChatGPT 订阅的额度是窗口制的,5 小时窗口或者 7 天窗口用完,sub2api 也变不出额度来。我想要的是:
- 自动降级:订阅额度用完,自动切到便宜的官方 API(DeepSeek),业务不中断
- 提示用户:app 能知道当前是"省钱模式",给用户展示一句说明
- 提示自己:手机上收到通知"订阅用完了""DeepSeek 余额也快没了"
- 体面停机:DeepSeek 也没钱了,返回一个明确的错误,而不是一堆莫名其妙的 502
- 自动恢复:订阅窗口重置后,自动切回来
5.2 为什么不让每个 app 自己 try/catch
最直觉的做法是每个 app 自己捕获 429,然后改调 DeepSeek。我想了想,放弃了:
- 重复实现:N 个 app 写 N 遍降级逻辑,Java、Go、Python 各来一遍
- 状态不共享:订阅用完之后,每个 app 都要自己撞一次墙才知道,每次请求都先白跑一趟
- 通知轰炸:N 个 app 各发一遍"没钱了",手机直接炸了
- 密钥扩散:DeepSeek key 要分发到每个 app,这是在花真金白银的 key
所以集中处理:在 sub2api 前面加一层很薄的网关,所有 app 只认这一个地址。
5.3 三档状态
整个网关的核心就是一个三档状态机:
- normal:一切正常,走订阅
- budget:订阅用完,DeepSeek 顶班
- broke:DeepSeek 也没钱了,所有 AI 功能暂停
每次档位变化发一条通知,只在变化时发,不会每个请求都轰炸一次。
5.4 怎么判断"订阅用完了"?我去翻了 sub2api 源码
降级网关最关键的问题是:sub2api 返回一个错误,到底是订阅用完了,还是网络抖了一下,还是请求本身有问题?判断错了,要么白花 DeepSeek 的钱,要么该降级的时候没降级。
我翻了一下 sub2api 的源码(2026 年 10 月的 main 分支),总结出这张表:
| sub2api 返回 | 含义 | 网关动作 |
|---|---|---|
503 + No available accounts | 号池里的账号全被限流或不可用,也就是订阅用完了 | 全局降级到 budget |
429 + Upstream rate limit exceeded | OpenAI 上游限流,带 Retry-After | 全局降级,冷却时间取 Retry-After |
502 + Upstream access forbidden / authentication failed | 上游拒绝访问,账号可能出事了 | 全局降级 + 发告警 |
| 其他 429 | 某个 app 自己的 key 限额到了,或者排队满了 | 仅本次请求走 DeepSeek |
| 其他 5xx / 连不上 | sub2api 自己出问题 | 仅本次走 DeepSeek,连续 5 次发告警 |
| 其他 4xx | 请求本身有问题 | 原样返回,不降级 |
最后一条很重要:请求本身错了,换个模型也救不了,别浪费钱。
对应的代码:
// classify 依据 sub2api 的错误响应判断该怎么降级。
// 文案匹配基于 2026 年 10 月的 sub2api 源码,升级后记得复查。
func classify(resp *http.Response, errBody []byte, err error) attempt {
if err != nil {
return upstream
}
if resp.StatusCode < 400 {
return ok
}
msg := string(errBody)
switch {
case strings.Contains(msg, "No available accounts"),
strings.Contains(msg, "Upstream rate limit exceeded"):
return quota
case strings.Contains(msg, "Upstream access forbidden"),
strings.Contains(msg, "Upstream authentication failed"):
return forbidden
case resp.StatusCode == 429:
return overflow
case resp.StatusCode >= 500:
return upstream
default:
return client
}
}靠错误文案做判断确实不优雅,但 sub2api 没有给出更结构化的信号,只能这样。好在兜底逻辑是安全的:哪怕哪天文案变了匹配不上,5xx 也会走"仅本次降级",业务不会断,只是不会切档位、不会发通知。每次升级 sub2api 后,回来看一眼这张表。
5.5 一次请求的完整路径
不再白跑 sub2api
几个实现细节:
- 鉴权:app 用的是自己在 sub2api 里的 key,网关维护一份白名单(
APP_KEYS)。主通道把这个 key 原样透传,sub2api 那边的按 app 统计照常有效;降级通道换成网关自己的 DeepSeek key,DeepSeek key 永远不出服务器。 - 冷却与半开:判定订阅用完后,冷却期(默认 15 分钟,或者上游给的 Retry-After)内所有请求直接走 DeepSeek。冷却期过了,下一个请求会再试一次主通道,成功就切回 normal。这就是熔断器的半开状态,写过 Sentinel 的应该很眼熟。
- 流式:错误都发生在响应头之前,所以降级可以无缝进行;一旦开始往客户端吐 SSE 了,就不能再切换了。
- 请求改写:发给 DeepSeek 前把
model换成FALLBACK_MODEL,用UseNumber()解析 JSON,避免大整数被转成 float64。如果某些 OpenAI 专有字段被 DeepSeek 拒绝(400/422),加到STRIP_FIELDS里删掉。
完整代码约 500 行,只用标准库,go build 出来一个二进制直接扔服务器,随文附上 main.go。
5.6 提示用户:响应头 + 状态接口
网关不改响应体,只加响应头,app 自己决定怎么展示:
| 信号 | 含义 |
|---|---|
X-LLM-Tier: normal | 订阅正常 |
X-LLM-Tier: budget + X-LLM-Model: deepseek-flash | 这次是 DeepSeek 回答的 |
HTTP 503 + code: budget_exhausted | 彻底没钱了 |
GET /status | 当前档位 + 一段现成的中文提示语,给前端横幅用 |
// GET /status
{
"tier": "budget",
"message": "作者的 ChatGPT 订阅额度用完了,现在由更便宜的 DeepSeek 临时顶班,回答风格和质量可能略有不同。",
"since": "2026-10-09T17:25:22Z"
}app 侧(Python 为例,with_raw_response 能拿到响应头):
from openai import OpenAI, APIStatusError
client = OpenAI(
base_url="https://llm.example.com/v1",
api_key="sk-app-a-xxxx", # app 在 sub2api 里的 key
)
def ask(prompt: str) -> tuple[str, str | None]:
"""返回 (回答, 给用户看的提示)"""
try:
raw = client.chat.completions.with_raw_response.create(
model="gpt-5.5", # 以 sub2api 后台模型列表为准
messages=[{"role": "user", "content": prompt}],
)
except APIStatusError as e:
if e.status_code == 503 and "budget_exhausted" in str(e.body):
return "", "作者的额度和余额都花光了,AI 功能暂停一下,等作者发工资 🥲"
raise
notice = None
if raw.headers.get("x-llm-tier") == "budget":
notice = "当前为省钱模式:由 DeepSeek 临时顶班,回答可能和平时略有不同"
return raw.parse().choices[0].message.content, notice流式请求同理,响应头在第一个 chunk 之前就到了,用 with_streaming_response 读即可。
5.7 提示自己:只在状态变化时叫我
通知支持 ntfy 和 Telegram,配哪个用哪个。会收到的消息一共就这几种:
| 场景 | 通知 |
|---|---|
| normal → budget | 💸 ChatGPT 订阅额度用完了,已切到 DeepSeek 顶班。省着点用。 |
| → broke | 🪦 DeepSeek 余额也没了,所有 app 的 AI 功能已暂停。该充钱了。 |
| → normal | 🎉 ChatGPT 订阅额度恢复,已切回主通道。 |
| 余额低于阈值 | 🪫 DeepSeek 余额只剩 X 了,记得充值。 |
| 上游拒绝访问 | ⚠️ ChatGPT 账号可能出问题了,去后台看看。 |
| sub2api 连续报错 | 🔧 sub2api 可能挂了。 |
余额预警靠 DeepSeek 的 GET /user/balance 接口,网关每 30 分钟查一次,余额跌破阈值时通知一次,回到阈值以上再重新布防,不会反复刷屏。
用 ntfy.sh 公共服务的话,topic 名字取一个猜不到的随机串,否则谁都能订阅你的"哭穷频道"。
5.8 为什么备胎选 DeepSeek
选 DeepSeek 有三个理由:
- 便宜。下表是 2026 年 10 月官方定价页的数字(美元 / 百万 token):
| 模型 | 输入(缓存命中) | 输入(缓存未命中) | 输出 |
|---|---|---|---|
deepseek-flash(V4.1-Flash) | 0.006 | 0.30 | 1.20 |
deepseek-v4-pro | 0.044 | 1.32 | 3.96 |
以上是高峰价。非高峰时段直接半价,高峰时段是工作日 UTC 01:00–04:00 和 06:00–10:00(北京时间 09:00–12:00、14:00–18:00),周末和中国法定节假日全天都按非高峰算。顶班用 deepseek-flash 就够了。
- 协议兼容。OpenAI Chat Completions 格式直接兼容,支持工具调用和 JSON 输出,base URL 就是
https://api.deepseek.com。官方文档也列了 Responses API 支持,不过为了两边都稳,我让 app 统一用 Chat Completions,这是两个上游的最大公约数。 - 预付费,天然有上限。DeepSeek 是先充值后扣费,余额用完返回
402 Insufficient Balance。也就是说,最坏情况下我只会亏掉充进去的那点钱,不会某天早上醒来收到一张天价账单。这对一个"穷鬼网关"来说是最重要的特性。
两个注意事项:
- 模型名一直在变。
deepseek-chat和deepseek-reasoner已经在 2026 年 7 月 24 日下线,deepseek-v4-flash9 月 10 日也退役了(旧名字暂时还能用,背后是新模型)。所以模型名一定要放配置里,不要写死在代码里。 - 思考模式默认开启。思考过程也按输出 token 计费,如果顶班场景不需要深度推理,可以按官方 Thinking Mode 文档在请求里关掉,更省。
5.9 部署 qiong-gateway
编译(本地交叉编译,传到服务器):
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o qiong-gateway .
scp qiong-gateway user@your-server:/usr/local/bin/配置 /etc/qiong-gateway.env(记得 chmod 600):
LISTEN=127.0.0.1:9000
PRIMARY_BASE=http://127.0.0.1:8080
FALLBACK_BASE=https://api.deepseek.com
FALLBACK_KEY=sk-deepseek-xxxx
FALLBACK_MODEL=deepseek-flash
# app 在 sub2api 里的 key:app 名称,逗号分隔
APP_KEYS=sk-s2a-aaaa:appA,sk-s2a-bbbb:appB
COOLDOWN=15m
BALANCE_THRESHOLD=10 # 单位是 DeepSeek 账户币种
BALANCE_CHECK=30m
NTFY_URL=https://ntfy.sh/qiong-a8f3k2x9
# TG_BOT_TOKEN=
# TG_CHAT_ID=
# STRIP_FIELDS=store,service_tiersystemd /etc/systemd/system/qiong-gateway.service:
[Unit]
Description=qiong-gateway (ChatGPT subscription -> DeepSeek fallback)
After=network-online.target docker.service
Wants=network-online.target
[Service]
EnvironmentFile=/etc/qiong-gateway.env
ExecStart=/usr/local/bin/qiong-gateway
Restart=always
RestartSec=3
DynamicUser=yes
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now qiong-gateway
journalctl -u qiong-gateway -fNginx,只对外暴露 /v1/ 和 /status,其他一律 404:
server {
listen 443 ssl http2;
server_name llm.example.com;
ssl_certificate /etc/letsencrypt/live/llm.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/llm.example.com/privkey.pem;
location /v1/ {
proxy_pass http://127.0.0.1:9000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off; # SSE 流式必须关,否则一坨一坨地吐
proxy_read_timeout 600s; # 非流式长回答要等很久
client_max_body_size 32m;
}
location = /status {
proxy_pass http://127.0.0.1:9000;
}
location / {
return 404;
}
}sub2api README 里提醒过:如果 Codex CLI 直接经 Nginx 连 sub2api,要在http块加underscores_in_headers on;,因为 Nginx 默认会丢掉带下划线的请求头(比如session_id),多账号粘性会话就失效了。我们的 app 走 qiong-gateway + Chat Completions,不涉及这个问题;但如果你自己的 Codex CLI 也要从外网连 sub2api,记得加上。
最终的端口布局:
127.0.0.1:9000] QG --> S2A[sub2api
127.0.0.1:8080] S2A --> PG[(PostgreSQL)] S2A --> RD[(Redis)] ME((我)) -->|SSH 隧道| S2A NET -.-|禁止直连| S2A
5.10 演练一次降级
上线前务必演练一次,不然真到没额度那天才发现通知没配好,就尴尬了:
- SSH 隧道进 sub2api 后台,把 ChatGPT 账号临时停用,号池空了,sub2api 就会返回
No available accounts - 发一个请求,确认响应头是
X-LLM-Tier: budget,手机收到 💸 通知 - 访问
/status,确认档位和提示语 - 重新启用账号,等冷却期过去再发一个请求,确认切回 normal,收到 🎉 通知
我自己用假上游把七种情况(未知 key、正常、请求错误透传、app 限额溢出、号池耗尽、余额不足、恢复)都跑了一遍,行为和预期一致。
6. 踩坑预警(从入门到放弃的"放弃"部分)
6.1 升级!升级!升级!
今年 8 月 sub2api 修了一个高危账号接管漏洞:攻击者只需要知道受害者邮箱,就能借 OAuth 登录补全流程把自己的第三方身份绑到别人账号上直接登录。
所以:版本别躺着不动,订阅一下 release;管理后台绝不暴露公网,SSH 隧道是最省心的方案。另外,升级之后记得回去对一下 5.4 节那张错误文案表。
6.2 额度是窗口制,不是按量计费
订阅额度按 5 小时和 7 天两个窗口算。多个 app 共用时,跟按量计费的心智模型完全不同:不是"花多少付多少",而是"窗口内用完就没了"。某个 app 跑批量任务,可能直接把其他 app 的额度挤没。
建议:批量 / 离线任务单独一个 key,在 sub2api 里把它的限额压低。
6.3 app 限额"溢出"会花真钱
注意 5.4 节表里的"其他 429":某个 app 撞到了自己在 sub2api 的 key 限额,网关会让这次请求走 DeepSeek。这个设计的语义是"订阅份额用完,超出部分自费"。
如果你设 key 限额的本意是"这个 app 到此为止",那就要改一下 overflow 分支,直接把 429 原样返回给 app。好在 DeepSeek 是预付费的,最坏也就是把余额烧光,触发 broke。
6.4 指纹收敛开关
8 月中旬的版本有一个破坏性变更:Codex OAuth 账号的"指纹收敛"默认值改成了关闭,之前没显式配置的账号会恢复透传客户端原始标识。
多个 app 从同一个账号出去时,上游看到的到底是"一个客户端"还是"一堆奇怪的客户端",这件事值得想清楚。在账号编辑里显式选一个档位,别吃默认值。
6.5 降级不是无感的
DeepSeek 和 GPT 毕竟是两个模型。针对 GPT 调好的 prompt,换到 DeepSeek 上输出风格、格式遵循程度都可能不一样。依赖严格 JSON 输出的 app,降级后的解析一定要做好兜底。这也是为什么要明明白白告诉用户"现在是省钱模式",而不是假装什么都没发生。
6.6 能力边界
主通道只能用 Codex 通道开放的模型。embedding、语音这类能力,别指望从这里拿。
7. 丑话说在前面
最后必须严肃一下。
把个人订阅转成 API 给多个后端调用,并不在订阅条款预期的使用方式之内。 不管是 sub2api 还是 web2api,本质上都是在灰色地带折腾,账号被限流甚至封禁的风险由你自己承担。社区里这类项目的作者自己也是这么写免责声明的。
我的原则是:
- 个人项目、实验性 app:sub2api + 穷鬼网关,挂了就挂了
- 要对外正式服务、或者在关键链路上的 app:老老实实用官方 API key
而 qiong-gateway 这一层,恰好让"转正"变得很容易:app 永远只认网关地址,哪天要正式上线,把 PRIMARY_BASE 指向官方 API(或者把官方 key 接进 sub2api),业务代码一行不用改。先用订阅验证想法,跑通了再无痛切到正规军。
8. 总结
- 多 app 后端共享、要稳定、要工具调用:sub2api
- 只有网页订阅、本机玩玩:web2api
- 订阅会用完:前面套一层降级网关,DeepSeek 顶班,响应头告诉用户,手机通知告诉自己
- 正式对外的服务:官方 API key,别省这个钱
折腾到这里,入门是入门了。至于什么时候放弃——等手机弹出 🪦 那条通知的时候吧。