WSL中sub2api OpenAI 账号轮询与动态代理配置

WSL中sub2api OpenAI 账号轮询与动态代理配置

本文档记录一套可复现的 sub2api 部署增强方案,用于实现:

  • WSL 默认网关 IP 自动获取
  • sub2api 容器代理 IP 自动更新
  • OpenAI 账号自动绑定代理
  • 新导入 OpenAI 账号自动加入账号池
  • 近 3 天低用量账号优先轮询
  • 5h/7d 额度用完账号自动降权
  • 本地 Web 控制面板查看和开关轮询

示例环境:

  • Windows 11
  • WSL2 Ubuntu
  • sub2api 部署目录:/root/sub2api/sub2api-deploy
  • sub2api Web:http://127.0.0.1:6780
  • 代理端口:7897
  • PostgreSQL 容器名:sub2api-postgres
  • sub2api 容器名:sub2api

如果你的目录、容器名或代理端口不同,请按实际情况替换。

1. 前置检查

进入 WSL root:

1
wsl.exe -d Ubuntu -u root

确认 systemd 已启用:

1
ps -p 1 -o comm=

期望输出:

1
systemd

确认 Docker 服务和 sub2api 容器正常:

1
2
cd /root/sub2api/sub2api-deploy
docker compose ps

确认当前 WSL 默认网关:

1
ip route | grep default

示例:

1
default via 192.168.176.1 dev eth0 proto kernel

这个网关 IP 会随网络、热点、WSL NAT 变化,所以后续不能写死。

2.动态代理配置

1. 创建 WSL DNS 自动更新脚本

创建脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
cat > /usr/local/bin/update-wsl-dns <<'EOF'
#!/bin/bash
set -e

gw=$(ip route | grep default | awk '{print $3; exit}')
if [ -z "$gw" ]; then
echo "No default gateway found" >&2
exit 1
fi

{
echo "nameserver $gw"
echo "nameserver 8.8.8.8"
echo "nameserver 8.8.4.4"
} > /etc/resolv.conf
EOF

chmod +x /usr/local/bin/update-wsl-dns

创建 systemd service:

1
2
3
4
5
6
7
8
9
10
11
12
13
cat > /etc/systemd/system/update-wsl-dns.service <<'EOF'
[Unit]
Description=Update WSL DNS from Windows Gateway
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/update-wsl-dns

[Install]
WantedBy=multi-user.target
EOF

启用并运行:

1
2
3
4
systemctl daemon-reexec
systemctl daemon-reload
systemctl enable update-wsl-dns.service
systemctl start update-wsl-dns.service

验证:

1
cat /etc/resolv.conf

应该看到:

1
2
3
nameserver 当前WSL网关IP
nameserver 8.8.8.8
nameserver 8.8.4.4

2. 修改 docker-compose 使用动态代理变量

进入部署目录:

1
cd /root/sub2api/sub2api-deploy

编辑 docker-compose.yaml,将 sub2api 服务中的 DNS 和代理改为变量:

1
2
3
4
5
6
7
8
9
10
11
services:
sub2api:
dns:
- ${WSL_GATEWAY}
- 8.8.8.8
- 8.8.4.4
environment:
- HTTP_PROXY=${WSL_PROXY}
- HTTPS_PROXY=${WSL_PROXY}
- http_proxy=${WSL_PROXY}
- https_proxy=${WSL_PROXY}

不要写死类似 192.168.176.1 的 IP。

3. 创建 sub2api 动态代理同步脚本

这个脚本做三件事:

  1. 自动读取 WSL 默认网关
  2. 生成 /root/sub2api/sub2api-deploy/.env
  3. 同步 sub2api 数据库中的应用级代理 wsl-auto

创建脚本:

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
cat > /usr/local/bin/update-sub2api-proxy <<'EOF'
#!/bin/bash
set -e

cd /root/sub2api/sub2api-deploy

gw=$(ip route | grep default | awk '{print $3; exit}')
if [ -z "$gw" ]; then
echo "No default gateway found" >&2
exit 1
fi

case "$gw" in
*[!0-9.]* ) echo "Unexpected gateway: $gw" >&2; exit 1 ;;
esac

cat > .env <<EOF_ENV
WSL_GATEWAY=$gw
WSL_PROXY=http://$gw:7897
EOF_ENV

docker compose up -d

if docker compose ps postgres >/dev/null 2>&1; then
docker exec sub2api-postgres psql -U sub2api -d sub2api -v ON_ERROR_STOP=1 \
-c "UPDATE proxies SET protocol = 'http', host = '$gw', port = 7897, status = 'active', updated_at = now(), deleted_at = NULL WHERE name = 'wsl-auto';" \
-c "INSERT INTO proxies (name, protocol, host, port, status, created_at, updated_at) SELECT 'wsl-auto', 'http', '$gw', 7897, 'active', now(), now() WHERE NOT EXISTS (SELECT 1 FROM proxies WHERE name = 'wsl-auto');" \
-c "UPDATE accounts SET proxy_id = (SELECT id FROM proxies WHERE name = 'wsl-auto' AND deleted_at IS NULL ORDER BY id LIMIT 1), updated_at = now(), temp_unschedulable_until = NULL, temp_unschedulable_reason = NULL WHERE platform = 'openai' AND deleted_at IS NULL;"
fi
EOF

chmod +x /usr/local/bin/update-sub2api-proxy

创建 systemd service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cat > /etc/systemd/system/update-sub2api-proxy.service <<'EOF'
[Unit]
Description=Refresh sub2api proxy from WSL gateway and start containers
After=network-online.target docker.service update-wsl-dns.service
Wants=network-online.target docker.service update-wsl-dns.service

[Service]
Type=oneshot
WorkingDirectory=/root/sub2api/sub2api-deploy
ExecStart=/usr/local/bin/update-sub2api-proxy
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
EOF

启用并运行:

1
2
3
systemctl daemon-reload
systemctl enable update-sub2api-proxy.service
systemctl start update-sub2api-proxy.service

验证:

1
cat /root/sub2api/sub2api-deploy/.env

示例:

1
2
WSL_GATEWAY=192.168.176.1
WSL_PROXY=http://192.168.176.1:7897

检查 sub2api 应用级代理:

1
2
docker exec sub2api-postgres psql -U sub2api -d sub2api \
-c "select id,name,protocol,host,port,status from proxies order by id;"

应看到 wsl-auto

4. 给 root shell 增加动态代理环境

如果希望在 WSL root 里直接执行:

1
curl -I https://chatgpt.com

也能走代理,则创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cat > /etc/profile.d/wsl-proxy.sh <<'EOF'
# Dynamically route WSL shell traffic through the Windows-side proxy.
wsl_gw=$(ip route | awk '/^default / {print $3; exit}' 2>/dev/null)
if [ -n "$wsl_gw" ]; then
export HTTP_PROXY="http://$wsl_gw:7897"
export HTTPS_PROXY="http://$wsl_gw:7897"
export http_proxy="http://$wsl_gw:7897"
export https_proxy="http://$wsl_gw:7897"
fi
unset wsl_gw

# Do not proxy local services.
export NO_PROXY=localhost,127.0.0.1,::1
export no_proxy=localhost,127.0.0.1,::1
EOF

确保 root bash 会加载它:

1
2
3
4
5
grep -q '/etc/profile.d/wsl-proxy.sh' /root/.bashrc || cat >> /root/.bashrc <<'EOF'

# Load dynamic WSL proxy for interactive root shells
[ -f /etc/profile.d/wsl-proxy.sh ] && . /etc/profile.d/wsl-proxy.sh
EOF

当前 shell 立即生效:

1
source /root/.bashrc

验证:

1
2
env | grep -i proxy
curl -I -L https://chatgpt.com --connect-timeout 10

如果返回:

1
2
HTTP/1.1 200 Connection established
HTTP/2 403

说明代理链路已连通。403 是 Cloudflare 页面挑战,不是 TCP 超时。

5. 自动绑定新 OpenAI 账号到 wsl-auto 代理

sub2api 的账号测试和调度不只看 Docker 环境变量,还会看数据库中的 proxies 表和 accounts.proxy_id

创建触发器:

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
cat > /tmp/default-wsl-proxy-trigger.sql <<'EOF'
CREATE OR REPLACE FUNCTION set_default_wsl_proxy_for_openai_accounts()
RETURNS trigger AS $$
BEGIN
IF NEW.platform = 'openai' AND NEW.proxy_id IS NULL THEN
SELECT id INTO NEW.proxy_id
FROM proxies
WHERE name = 'wsl-auto' AND deleted_at IS NULL
ORDER BY id
LIMIT 1;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS trg_default_wsl_proxy_for_openai_accounts ON accounts;

CREATE TRIGGER trg_default_wsl_proxy_for_openai_accounts
BEFORE INSERT OR UPDATE OF platform, proxy_id ON accounts
FOR EACH ROW
EXECUTE FUNCTION set_default_wsl_proxy_for_openai_accounts();
EOF

docker cp /tmp/default-wsl-proxy-trigger.sql sub2api-postgres:/tmp/default-wsl-proxy-trigger.sql
docker exec sub2api-postgres psql -U sub2api -d sub2api -v ON_ERROR_STOP=1 \
-f /tmp/default-wsl-proxy-trigger.sql

验证:

1
2
docker exec sub2api-postgres psql -U sub2api -d sub2api \
-c "select tgname from pg_trigger where tgrelid='accounts'::regclass and not tgisinternal;"

应看到:

1
trg_default_wsl_proxy_for_openai_accounts

6. 换热点或网络后的行为

如果只是换热点但 WSL 没重启,可以手动执行:

1
2
update-wsl-dns
update-sub2api-proxy

如果执行:

1
wsl --shutdown

然后重新进入 WSL,则 systemd 会自动执行:

  • update-wsl-dns.service
  • update-sub2api-proxy.service

网关 IP、DNS、Docker .env​、sub2api 应用级代理都会刷新。

3.账号轮询

1. 建立 OpenAI 账号池

将账号管理中的所有账号导入OpenAI分组

image

2. 新导入 OpenAI 账号自动加入账号池

创建触发器:

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
cat > /tmp/openai-group-trigger.sql <<'EOF'
CREATE OR REPLACE FUNCTION add_openai_account_to_openai_group()
RETURNS trigger AS $$
BEGIN
IF NEW.platform = 'openai' AND NEW.deleted_at IS NULL THEN
INSERT INTO account_groups (account_id, group_id, priority, created_at)
SELECT NEW.id, 2, COALESCE(NEW.priority, 1), now()
WHERE EXISTS (SELECT 1 FROM groups WHERE id = 2 AND platform = 'openai' AND deleted_at IS NULL)
ON CONFLICT DO NOTHING;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS trg_add_openai_account_to_openai_group ON accounts;

CREATE TRIGGER trg_add_openai_account_to_openai_group
AFTER INSERT OR UPDATE OF platform, deleted_at, priority ON accounts
FOR EACH ROW
EXECUTE FUNCTION add_openai_account_to_openai_group();
EOF

docker cp /tmp/openai-group-trigger.sql sub2api-postgres:/tmp/openai-group-trigger.sql
docker exec sub2api-postgres psql -U sub2api -d sub2api -v ON_ERROR_STOP=1 \
-f /tmp/openai-group-trigger.sql

验证:

1
2
docker exec sub2api-postgres psql -U sub2api -d sub2api \
-c "select tgname from pg_trigger where tgrelid='accounts'::regclass and not tgisinternal order by tgname;"

应至少看到:

1
2
trg_add_openai_account_to_openai_group
trg_default_wsl_proxy_for_openai_accounts

3. 创建低用量优先轮询脚本

轮询策略:

  1. 统计近 N 天 usage_logs,默认 N=3
  2. 请求数和 token 数越少,priority 越小
  3. priority 越小越优先
  4. Codex 5h 或 7d 使用率达到 100% 的账号设置为 priority=99
  5. 额度用完账号标记 temp_unschedulable_until

创建脚本:

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
cat > /usr/local/bin/rebalance-sub2api-openai-accounts <<'EOF'
#!/bin/bash
set -euo pipefail

DAYS="${1:-3}"
case "$DAYS" in
*[!0-9]* ) echo "DAYS must be an integer" >&2; exit 1 ;;
esac

docker exec -i sub2api-postgres psql -U sub2api -d sub2api -v ON_ERROR_STOP=1 -v days="$DAYS" <<'SQL'
WITH exhausted AS (
SELECT
id,
coalesce((extra->>'codex_5h_used_percent')::numeric, 0) >= 100 AS exhausted_5h,
coalesce((extra->>'codex_7d_used_percent')::numeric, 0) >= 100 AS exhausted_7d,
nullif(coalesce(extra->>'codex_7d_reset_at', extra->>'codex_5h_reset_at', ''), '') AS reset_at
FROM accounts
WHERE platform = 'openai' AND deleted_at IS NULL
), marked AS (
UPDATE accounts a
SET priority = 99,
temp_unschedulable_until = CASE WHEN e.reset_at IS NOT NULL THEN e.reset_at::timestamptz ELSE temp_unschedulable_until END,
temp_unschedulable_reason = CASE WHEN e.exhausted_7d THEN 'codex 7d quota exhausted' WHEN e.exhausted_5h THEN 'codex 5h quota exhausted' ELSE temp_unschedulable_reason END,
updated_at = now()
FROM exhausted e
WHERE a.id = e.id AND (e.exhausted_5h OR e.exhausted_7d)
RETURNING a.id
), cleared AS (
UPDATE accounts a
SET temp_unschedulable_until = NULL,
temp_unschedulable_reason = NULL,
updated_at = now()
FROM exhausted e
WHERE a.id = e.id
AND NOT (e.exhausted_5h OR e.exhausted_7d)
AND a.temp_unschedulable_reason IN ('codex 7d quota exhausted', 'codex 5h quota exhausted')
RETURNING a.id
), usage AS (
SELECT
a.id,
COALESCE(COUNT(u.id), 0) AS req_count,
COALESCE(SUM(COALESCE(u.input_tokens, 0) + COALESCE(u.output_tokens, 0) + COALESCE(u.cache_creation_tokens, 0) + COALESCE(u.cache_read_tokens, 0) + COALESCE(u.image_output_tokens, 0)), 0) AS token_count,
a.last_used_at
FROM accounts a
LEFT JOIN usage_logs u ON u.account_id = a.id AND u.created_at >= now() - (:days::int * interval '1 day')
LEFT JOIN exhausted e ON e.id = a.id
WHERE a.platform = 'openai'
AND a.deleted_at IS NULL
AND a.status = 'active'
AND a.schedulable = true
AND NOT (e.exhausted_5h OR e.exhausted_7d)
GROUP BY a.id, a.last_used_at
), ranked AS (
SELECT id, NTILE(10) OVER (ORDER BY req_count ASC, token_count ASC, last_used_at ASC NULLS FIRST, id ASC) AS priority_bucket
FROM usage
)
UPDATE accounts a
SET priority = r.priority_bucket,
load_factor = NULL,
updated_at = now()
FROM ranked r
WHERE a.id = r.id;

UPDATE account_groups ag
SET priority = a.priority
FROM accounts a
WHERE a.id = ag.account_id
AND ag.group_id = 2
AND a.platform = 'openai'
AND a.deleted_at IS NULL;

SELECT priority, count(*) AS accounts
FROM accounts
WHERE platform = 'openai' AND deleted_at IS NULL
GROUP BY priority
ORDER BY priority;
SQL
EOF

chmod +x /usr/local/bin/rebalance-sub2api-openai-accounts

手动执行:

1
/usr/local/bin/rebalance-sub2api-openai-accounts 3

验证 priority 分布:

1
2
docker exec sub2api-postgres psql -U sub2api -d sub2api \
-c "select priority, count(*) from accounts where platform='openai' and deleted_at is null group by priority order by priority;"

如果存在额度用完账号,应看到:

1
priority 99

4. 创建定时重平衡 timer

创建 service:

1
2
3
4
5
6
7
8
9
10
cat > /etc/systemd/system/rebalance-sub2api-openai-accounts.service <<'EOF'
[Unit]
Description=Rebalance sub2api OpenAI accounts by recent usage
After=docker.service
Wants=docker.service

[Service]
Type=oneshot
ExecStart=/usr/local/bin/rebalance-sub2api-openai-accounts 3
EOF

创建 timer:

1
2
3
4
5
6
7
8
9
10
11
12
cat > /etc/systemd/system/rebalance-sub2api-openai-accounts.timer <<'EOF'
[Unit]
Description=Run sub2api OpenAI account usage rebalancer periodically

[Timer]
OnBootSec=2min
OnUnitActiveSec=10min
Persistent=true

[Install]
WantedBy=timers.target
EOF

启用:

1
2
systemctl daemon-reload
systemctl enable --now rebalance-sub2api-openai-accounts.timer

查看:

1
systemctl list-timers rebalance-sub2api-openai-accounts.timer --no-pager

5. 创建本地轮询控制面板

控制面板监听:

1
http://127.0.0.1:6781

功能:

  • 查看轮询是否开启
  • 开启/关闭轮询
  • 立即重算
  • 查看 priority 分布
  • 查看当前优先账号
  • 查看 5h/7d 额度用完账号
  • 查看异常、限流、临时不可调度账号

创建 Python 服务文件:

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
cat > /usr/local/bin/sub2api-polling-panel <<'EOF'
#!/usr/bin/env python3
from http.server import BaseHTTPRequestHandler, HTTPServer
import html
import json
import subprocess
import urllib.parse

HOST = "127.0.0.1"
PORT = 6781
TIMER = "rebalance-sub2api-openai-accounts.timer"
REBALANCE = "/usr/local/bin/rebalance-sub2api-openai-accounts"

def run(cmd, check=False):
p = subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
if check and p.returncode != 0:
raise RuntimeError(p.stderr.strip() or p.stdout.strip() or f"command failed: {cmd}")
return p

def psql(sql):
p = run(["docker", "exec", "sub2api-postgres", "psql", "-U", "sub2api", "-d", "sub2api", "-At", "-F", "\t", "-c", sql])
if p.returncode != 0:
return []
return [line.split("\t") for line in p.stdout.splitlines() if line.strip()]

def status():
enabled = run(["systemctl", "is-enabled", TIMER]).stdout.strip() == "enabled"
active = run(["systemctl", "is-active", TIMER]).stdout.strip() == "active"
timers = run(["systemctl", "list-timers", TIMER, "--no-pager"]).stdout
advanced = psql("select value from settings where key='openai_advanced_scheduler_enabled';")
priority_rows = psql("select priority::text, count(*)::text from accounts where platform='openai' and deleted_at is null group by priority order by priority;")
health_rows = psql("""
select
count(*)::text,
count(*) filter (where status = 'active' and schedulable = true and rate_limited_at is null and rate_limit_reset_at is null and overload_until is null and temp_unschedulable_until is null and coalesce(error_message, '') = '' and coalesce((extra->>'codex_5h_used_percent')::numeric,0) < 100 and coalesce((extra->>'codex_7d_used_percent')::numeric,0) < 100)::text,
count(*) filter (where rate_limited_at is not null or rate_limit_reset_at is not null or coalesce((extra->>'codex_5h_used_percent')::numeric,0) >= 100 or coalesce((extra->>'codex_7d_used_percent')::numeric,0) >= 100)::text,
count(*) filter (where overload_until is not null or temp_unschedulable_until is not null)::text,
count(*) filter (where status <> 'active' or schedulable = false or coalesce(error_message, '') <> '')::text
from accounts
where platform='openai' and deleted_at is null;
""")
issue_rows = psql("""
select
id::text,
name,
status,
schedulable::text,
case
when coalesce((extra->>'codex_7d_used_percent')::numeric,0) >= 100 then '7d 额度已用完'
when coalesce((extra->>'codex_5h_used_percent')::numeric,0) >= 100 then '5h 额度已用完'
when rate_limited_at is not null or rate_limit_reset_at is not null then '限流/疑似额度耗尽'
when overload_until is not null then '过载暂停'
when temp_unschedulable_until is not null then '临时不可调度'
when status <> 'active' then '状态异常'
when schedulable = false then '不可调度'
when coalesce(error_message, '') <> '' then '错误'
else '正常'
end,
coalesce(extra->>'codex_5h_used_percent', '0') || '%',
coalesce(extra->>'codex_7d_used_percent', '0') || '%',
coalesce(extra->>'codex_7d_reset_at', extra->>'codex_5h_reset_at', to_char(rate_limit_reset_at, 'YYYY-MM-DD HH24:MI:SS'), ''),
coalesce(left(nullif(error_message, ''), 180), '')
from accounts
where platform='openai' and deleted_at is null
and (status <> 'active' or schedulable=false or rate_limited_at is not null or rate_limit_reset_at is not null or overload_until is not null or temp_unschedulable_until is not null or coalesce(error_message, '') <> '' or coalesce((extra->>'codex_5h_used_percent')::numeric,0) >= 100 or coalesce((extra->>'codex_7d_used_percent')::numeric,0) >= 100)
order by id
limit 80;
""")
top_rows = psql("""
select
a.id::text,
a.name,
a.priority::text,
a.status,
a.schedulable::text,
coalesce(x.req_count, 0)::text,
coalesce(x.token_count, 0)::text,
coalesce(to_char(a.last_used_at, 'YYYY-MM-DD HH24:MI:SS'), '')
from accounts a
left join (
select account_id, count(*) as req_count,
sum(coalesce(input_tokens,0)+coalesce(output_tokens,0)+coalesce(cache_creation_tokens,0)+coalesce(cache_read_tokens,0)+coalesce(image_output_tokens,0)) as token_count
from usage_logs
where created_at >= now() - interval '3 days'
group by account_id
) x on x.account_id = a.id
where a.platform='openai' and a.deleted_at is null
order by a.priority asc, coalesce(x.req_count,0) asc, coalesce(x.token_count,0) asc, a.last_used_at asc nulls first, a.id asc
limit 30;
""")
return {
"enabled": enabled,
"active": active,
"advanced_scheduler": advanced[0][0] if advanced else "missing",
"timer": timers,
"priority_rows": priority_rows,
"health": health_rows[0] if health_rows else ["0", "0", "0", "0", "0"],
"issue_rows": issue_rows,
"top_rows": top_rows,
}

CSS = ":root{font-family:Inter,Segoe UI,Arial,sans-serif;color:#1f2937;background:#f5f7fb}body{margin:0}.wrap{max-width:1180px;margin:0 auto;padding:28px}.bar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:22px}.title{font-size:24px;font-weight:700}.sub{color:#667085;margin-top:5px}.status{display:flex;gap:10px;align-items:center;font-weight:650}.dot{width:11px;height:11px;border-radius:50%;background:#d92d20}.dot.on{background:#039855}.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.cards{display:grid;grid-template-columns:repeat(5,1fr);gap:10px}.metric{background:#fff;border:1px solid #d9e1ec;border-radius:8px;padding:12px}.metric b{display:block;font-size:22px}.metric span{font-size:12px;color:#667085}.bad b{color:#b42318}.warn b{color:#b54708}.ok b{color:#027a48}.panel{background:white;border:1px solid #d9e1ec;border-radius:8px;padding:16px;box-shadow:0 1px 2px rgba(16,24,40,.05)}.actions{display:flex;flex-wrap:wrap;gap:10px}.btn{border:1px solid #b8c3d1;background:#fff;color:#1f2937;padding:9px 13px;border-radius:6px;cursor:pointer;font-weight:650}.btn.primary{background:#175cd3;color:white;border-color:#175cd3}.btn.danger{background:#b42318;color:white;border-color:#b42318}table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:8px;border-bottom:1px solid #edf1f7;vertical-align:top}th{color:#475467;background:#f8fafc}.mono{font-family:Consolas,ui-monospace,monospace;white-space:pre-wrap;font-size:12px;background:#0f172a;color:#e5e7eb;border-radius:6px;padding:12px;overflow:auto;max-height:220px}.note{font-size:13px;color:#667085;margin-top:10px}.empty{color:#027a48;font-weight:650;padding:8px 0}@media(max-width:900px){.grid,.cards{grid-template-columns:1fr}.bar{align-items:flex-start;flex-direction:column}}"

def rows_html(rows):
return "".join("<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in r) + "</tr>" for r in rows)

def page(message=""):
s = status()
state_text = "已开启" if s["enabled"] and s["active"] else "已关闭"
dot = "dot on" if s["enabled"] and s["active"] else "dot"
priority = "".join(f"<tr><td>{html.escape(r[0])}</td><td>{html.escape(r[1])}</td></tr>" for r in s["priority_rows"])
total, healthy, limited, paused, abnormal = s["health"]
issue_body = rows_html(s["issue_rows"]) if s["issue_rows"] else "<tr><td colspan='9'><div class='empty'>当前没有检测到限流、额度耗尽或异常账号</div></td></tr>"
top = rows_html(s["top_rows"])
msg = f"<div class='panel'>{html.escape(message)}</div>" if message else ""
return f"""<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>sub2api 轮询控制</title><style>{CSS}</style></head><body><div class='wrap'><div class='bar'><div><div class='title'>sub2api 账号轮询控制</div><div class='sub'>近 3 天低用量账号优先,每 10 分钟自动重算</div></div><div class='status'><span class='{dot}'></span>{state_text}</div></div>{msg}<div class='panel'><form class='actions' method='post'><button class='btn primary' name='action' value='enable'>开启轮询</button><button class='btn danger' name='action' value='disable'>关闭轮询</button><button class='btn' name='action' value='rebalance'>立即重算</button><button class='btn' name='action' value='refresh'>刷新</button></form><div class='note'>高级调度开关:{html.escape(s['advanced_scheduler'])}</div></div><div class='cards' style='margin-top:16px'><div class='metric'><b>{total}</b><span>OpenAI 总账号</span></div><div class='metric ok'><b>{healthy}</b><span>正常可调度</span></div><div class='metric warn'><b>{limited}</b><span>限流/疑似额度耗尽</span></div><div class='metric warn'><b>{paused}</b><span>暂停/临时不可调度</span></div><div class='metric bad'><b>{abnormal}</b><span>错误/不可用</span></div></div><div class='grid' style='margin-top:16px'><div class='panel'><h3>优先级分布</h3><table><thead><tr><th>priority</th><th>账号数</th></tr></thead><tbody>{priority}</tbody></table></div><div class='panel'><h3>Systemd Timer</h3><div class='mono'>{html.escape(s['timer'])}</div></div></div><div class='panel' style='margin-top:16px'><h3>额度/限流状态</h3><table><thead><tr><th>ID</th><th>账号</th><th>状态</th><th>可调度</th><th>判断</th><th>5h 使用率</th><th>7d 使用率</th><th>重置时间</th><th>错误信息</th></tr></thead><tbody>{issue_body}</tbody></table></div><div class='panel' style='margin-top:16px'><h3>当前优先使用的账号</h3><table><thead><tr><th>ID</th><th>账号</th><th>priority</th><th>状态</th><th>可调度</th><th>近3天请求</th><th>近3天Token</th><th>最后使用</th></tr></thead><tbody>{top}</tbody></table></div></div></body></html>"""

class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if urllib.parse.urlparse(self.path).path == "/api/status":
data = json.dumps(status(), ensure_ascii=False).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return
body = page().encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

def do_POST(self):
length = int(self.headers.get("Content-Length", "0") or 0)
raw = self.rfile.read(length).decode()
action = urllib.parse.parse_qs(raw).get("action", [""])[0]
message = "已刷新"
if action == "enable":
run(["systemctl", "enable", "--now", TIMER], check=True)
run(["docker", "exec", "sub2api-postgres", "psql", "-U", "sub2api", "-d", "sub2api", "-c", "insert into settings (key,value,updated_at) values ('openai_advanced_scheduler_enabled','true',now()) on conflict (key) do update set value='true', updated_at=now();"], check=True)
message = "轮询已开启"
elif action == "disable":
run(["systemctl", "disable", "--now", TIMER], check=False)
run(["docker", "exec", "sub2api-postgres", "psql", "-U", "sub2api", "-d", "sub2api", "-c", "insert into settings (key,value,updated_at) values ('openai_advanced_scheduler_enabled','false',now()) on conflict (key) do update set value='false', updated_at=now(); update accounts set priority=1, updated_at=now() where platform='openai' and deleted_at is null; update account_groups ag set priority=1 from accounts a where a.id=ag.account_id and a.platform='openai' and a.deleted_at is null;"], check=True)
message = "轮询已关闭,账号优先级已恢复为 1"
elif action == "rebalance":
run([REBALANCE, "3"], check=True)
message = "已立即重算近 3 天低用量优先级"
body = page(message).encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

def log_message(self, fmt, *args):
return

if __name__ == "__main__":
HTTPServer((HOST, PORT), Handler).serve_forever()
EOF

chmod +x /usr/local/bin/sub2api-polling-panel

创建 systemd service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cat > /etc/systemd/system/sub2api-polling-panel.service <<'EOF'
[Unit]
Description=sub2api polling control panel
After=network-online.target docker.service
Wants=network-online.target docker.service

[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/local/bin/sub2api-polling-panel
Restart=always
RestartSec=2

[Install]
WantedBy=multi-user.target
EOF

启动:

1
2
systemctl daemon-reload
systemctl enable --now sub2api-polling-panel.service

访问:

1
http://127.0.0.1:6781

6. 页面显示

image

image

4. 策略说明

轮询开关

由两个东西共同决定:

  • rebalance-sub2api-openai-accounts.timer 是否 active
  • settings.openai_advanced_scheduler_enabled 是否为 true

控制页的“开启轮询/关闭轮询”会同时处理这两项。

低用量优先

脚本每 10 分钟统计近 3 天:

  • 请求数
  • token 数
  • 最后使用时间

然后分成 10 桶:

  • priority=1 最优先
  • priority=10 较低
  • priority=99 额度用完或应避免使用

额度用完识别

sub2api 账号页的 5h/7d 进度来自:

1
2
3
4
accounts.extra.codex_5h_used_percent
accounts.extra.codex_7d_used_percent
accounts.extra.codex_5h_reset_at
accounts.extra.codex_7d_reset_at

当 5h 或 7d 使用率达到 100 时:

  • 控制页会显示该账号
  • 重平衡脚本会设置 priority=99
  • 写入 temp_unschedulable_reason
  • 到 reset 时间后,下次刷新数据和重平衡时会恢复

5. 注意事项

  • 本方案直接操作 sub2api PostgreSQL 数据库,升级 sub2api 后建议复查表结构。
  • 控制面板只监听 127.0.0.1:6781,默认不对外网开放。
  • 如果代理端口不是 7897,需要同步修改:
    • /usr/local/bin/update-sub2api-proxy
    • /etc/profile.d/wsl-proxy.sh
  • 如果 OpenAI 组 ID 不是 2,需要同步修改:
    • account_groups 插入语句
    • add_openai_account_to_openai_group() 触发器
    • rebalance-sub2api-openai-accounts 脚本中的 ag.group_id = 2

WSL中sub2api OpenAI 账号轮询与动态代理配置
http://example.com/post/sub2api-openai-account-polling-and-dynamic-proxy-configuration-in-wsl-5haly.html
作者
Dre4m
发布于
2026年5月9日
许可协议