CPA反代使用

CPA反代使用

工具地址:https://github.com/router-for-me/CLIProxyAPI

这篇记录我在 Windows 下部署 CLIProxyAPI Plus(以下简称 CPA)的完整过程,以及后续如何用 cpa-warden 自动清理异常账号。

CPA反代使用1.png

1. 下载与启动说明

下载压缩包后,目录里通常会有这些核心文件:

  • cli-proxy-api-plus.exe:主程序
  • config.example.yaml:配置模板

CPA反代使用2.png

很多人第一反应是双击 exe,然后看到窗口一闪而过。
这不是程序坏了,而是它是命令行程序,且默认会读取 config.yaml。如果配置文件不存在,就会直接退出。

先做配置:

  1. 复制 config.example.yamlconfig.yaml
  2. 编辑 config.yaml,至少先设置管理密钥。
1
2
3
4
remote-management:
allow-remote: false
secret-key: '你自己设一个强密码'
disable-control-panel: true

2. 做一个可双击启动脚本

我这里用了两个 bat 文件,方便双击启动且保留窗口日志。

start-cli-proxy-api-plus.bat

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
@echo off
setlocal
chcp 65001 >nul
cd /d "%~dp0"

set "EXE=cli-proxy-api-plus.exe"
set "CONFIG=config.yaml"
set "EXAMPLE=config.example.yaml"

if not exist "%EXE%" (
echo [ERROR] %EXE% not found in current directory.
goto :END
)

if not exist "%CONFIG%" (
if exist "%EXAMPLE%" (
echo [INFO] %CONFIG% not found. Copying from %EXAMPLE% ...
copy /y "%EXAMPLE%" "%CONFIG%" >nul
if errorlevel 1 (
echo [ERROR] Failed to create %CONFIG%.
goto :END
)
echo [INFO] First run setup complete. Please edit %CONFIG% with your API keys.
echo [INFO] Opening %CONFIG% now ...
start "" notepad "%CONFIG%"
echo [INFO] After saving the config, run this script again.
goto :END
) else (
echo [ERROR] Neither %CONFIG% nor %EXAMPLE% exists.
goto :END
)
)

echo [INFO] Starting %EXE% with %CONFIG% ...
echo [INFO] Press Ctrl+C to stop the server.
echo.
"%EXE%" -config ".\%CONFIG%"
set "EXIT_CODE=%ERRORLEVEL%"
echo.
if "%EXIT_CODE%"=="0" (
echo [INFO] Process exited normally.
) else (
echo [ERROR] Process exited with code %EXIT_CODE%.
echo [TIP] Common causes:
echo [TIP] 1. Port 8317 is in use.
echo [TIP] 2. Invalid config.yaml.
)

:END
echo.
pause
endlocal

start.bat

1
2
3
@echo off
cd /d "%~dp0"
call ".\start-cli-proxy-api-plus.bat"

双击 start.bat 启动后,访问:

http://localhost:8317/management.html#/login

CPA使用反代3.png

如果你是直接打开下载下来的 management.html 本地文件(file://...),记得勾选“自定义连接地址”,并手动填 http://127.0.0.1:8317,否则会出现 http://file: 导致无法登录。

将刚刚设置的 secret-key 填入登录页即可。
注意:程序启动后配置里的 secret-key 可能会被转成密文,但你登录时仍填“原始明文密码”。

3. 忘记 secret-key 怎么办

secret-key 忘了只能重置,不能反解。步骤如下:

  1. 停掉服务(启动窗口里按 Ctrl+C)。
  2. 打开 config.yaml
  3. remote-management.secret-key 改成新的明文值:
1
2
3
4
remote-management:
allow-remote: false
secret-key: '你的新密钥'
disable-control-panel: true
  1. 保存后重新双击 start.bat
  2. 管理页面使用新密钥登录。

补充:如果现在配置里已经是哈希值(例如 $2a$...),直接覆盖成新的明文即可,程序会自动处理。

4. 导入账号与添加 API Key

登录后进入“认证文件”页面,上传 Codex 账号的 .json 认证文件。

CPA反代使用4.png

然后进入“配置管理”,在 API 密钥列表里添加你自己的 API Key(给客户端调用 CPA 用)。

CPA反代使用5.png

5. 以 Cherry Studio 为例接入

打开 Cherry Studio设置 -> 添加

  • 提供商名称:任意
  • 提供商类型:OpenAI
  • API 地址:http://localhost:8317
  • API 密钥:你在 CPA 里设置的 API Key

CPA反代使用6.png

CPA反代使用7.png

测试连接通过后,添加需要的模型即可使用。

CPA反代使用8.png

至此,本地 CPA 代理就可以正常使用了。

6. 用 cpa-warden 自动处理 401/限额账号

项目地址:https://github.com/fantasticjoe/cpa-warden

如果你遇到账号失效(401)或限额账号,cpa-warden 可以自动扫描并维护。

CPA反代使用9.png

6.1 初始化

1
2
3
cd g:\CLIProxyAPIPlus_6.8.35-0_windows_amd64\cpa-warden
Copy-Item .\config.example.json .\config.json
notepad .\config.json

编辑 config.json,至少配置:

  1. base_url:你的 CPA 地址,例如 http://127.0.0.1:8317
  2. token:管理密钥(即 config.yaml 里的 remote-management.secret-key 明文)

默认策略就是你要的效果:

  • 自动删除 401(delete_401: true
  • 自动禁用限额账号(quota_action: "disable"

6.2 执行扫描与维护

1
2
3
uv sync
uv run python .\cpa_warden.py --mode scan
uv run python .\cpa_warden.py --mode maintain

CPA反代使用10.png

如果要完全无人值守(跳过删除确认):

1
uv run python .\cpa_warden.py --mode maintain --delete-401 --quota-action disable --yes

如果你是第一次接触 CPA,建议先按本文流程跑通“本地启动 + 管理面板登录 + 客户端连接”三步,再接入 cpa-warden 做自动运维,会更稳。

7. CPA 网页版部署

因为自己还没有去买服务器,所以想到了可以用下面这套组合:

  • Hugging Face Space:跑 CPA 后端
  • Cloudflare Worker:做入口反代 + 保活 + 401 自动清理

这样就能用网页地址访问:https://你的域名/management.html#/login

7.1 部署 Hugging Face

去 Hugging Face 新建一个 Docker 类型 的 Space。使用的文件如下:

config.hf.yaml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
host: "0.0.0.0"
port: 7860
auth-dir: "/app/auths"

# 必填:管理接口配置(当前版本使用 remote-management)
remote-management:
allow-remote: true
secret-key: "XXXXXXXXXXXXXX"(这里随便填,就是登入密码)
disable-control-panel: false

# 关键:控制面板必须启用,才能访问 /management.html
# 如果你配置里有 disable-control-panel,请确保是 false

# 按需配置你使用的上游(示例)
# openai-compatibility:
# providers:
# - name: "example-provider"
# base-url: "https://openrouter.ai/api/v1"
# api-key: "sk-xxx"
# models:
# - "openai/gpt-4.1-mini"

Dockerfile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
FROM golang:1.26-alpine AS builder
RUN apk add --no-cache git ca-certificates
WORKDIR /src
RUN git clone --depth=1 https://github.com/router-for-me/CLIProxyAPI.git .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/cliproxyapi ./cmd/server

FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
RUN mkdir -p /app/auths
COPY --from=builder /out/cliproxyapi /app/cliproxyapi
COPY config.hf.yaml /app/config.yaml
EXPOSE 7860
CMD ["/app/cliproxyapi", "-config", "/app/config.yaml"]

等构建完成,拿到 Space 地址,例如:https://用户名-创建的空间名.hf.space

7.2 部署 Cloudflare

文件有:

index.js:

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
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
const WHAM_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";

function text(msg, status = 400) {
return new Response(msg, { status });
}

function json(data, status = 200) {
return new Response(JSON.stringify(data, null, 2), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}

function parseIntSafe(value, fallback) {
const n = Number.parseInt(String(value ?? ""), 10);
return Number.isFinite(n) && n > 0 ? n : fallback;
}

function getAccountId(item) {
for (const key of ["chatgpt_account_id", "chatgptAccountId", "account_id", "accountId"]) {
const value = item?.[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
let token = item?.id_token;
if (typeof token === "string") {
try {
token = JSON.parse(token);
} catch {
token = null;
}
}
if (token && typeof token === "object") {
for (const key of ["chatgpt_account_id", "chatgptAccountId", "account_id", "accountId"]) {
const value = token?.[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
}
return "";
}

function mgmtHeaders(env, includeJson = false) {
const headers = {
Authorization: `Bearer ${env.MANAGEMENT_KEY}`,
Accept: "application/json, text/plain, */*",
};
if (includeJson) headers["Content-Type"] = "application/json";
return headers;
}

async function fetchJSON(url, init) {
const resp = await fetch(url, init);
const bodyText = await resp.text();
let body = null;
if (bodyText) {
try {
body = JSON.parse(bodyText);
} catch {
body = bodyText;
}
}
return { ok: resp.ok, status: resp.status, body };
}

function normalizeAuthFiles(body) {
if (Array.isArray(body)) return body;
if (body && typeof body === "object" && Array.isArray(body.files)) return body.files;
return [];
}

async function scanAndDelete401(env) {
if (!env.MANAGEMENT_KEY || !env.MANAGEMENT_KEY.trim()) {
return { ok: false, error: "MANAGEMENT_KEY is empty" };
}

const upstream = new URL(env.UPSTREAM_BASE_URL);
const typeFilter = (env.WARDEN_TARGET_TYPE || "codex").trim().toLowerCase();
const providerFilter = (env.WARDEN_PROVIDER || "").trim().toLowerCase();
const maxScan = parseIntSafe(env.WARDEN_MAX_SCAN, 500);

const listRes = await fetchJSON(`${upstream.origin}/v0/management/auth-files`, {
method: "GET",
headers: mgmtHeaders(env, false),
});
if (!listRes.ok) {
return {
ok: false,
error: "failed to fetch auth-files",
status: listRes.status,
details: listRes.body,
};
}

const files = normalizeAuthFiles(listRes.body);
const candidates = files
.filter((item) => {
const typ = String(item?.type || "").trim().toLowerCase();
const provider = String(item?.provider || "").trim().toLowerCase();
if (typeFilter && typ !== typeFilter) return false;
if (providerFilter && provider !== providerFilter) return false;
return true;
})
.slice(0, maxScan);

const invalid401 = [];
for (const item of candidates) {
const name = String(item?.name || "").trim();
const authIndex = String(item?.auth_index || "").trim();
if (!name) continue;

if (item?.unavailable === true) {
invalid401.push({ name, reason: "unavailable=true" });
continue;
}

const accountId = getAccountId(item);
if (!authIndex || !accountId) continue;

const payload = {
auth_index: authIndex,
request: {
method: "GET",
url: WHAM_USAGE_URL,
headers: {
Authorization: "Bearer $TOKEN$",
"Content-Type": "application/json",
"User-Agent": "codex_cli_rs/0.76.0 (Cloudflare Worker)",
"Chatgpt-Account-Id": accountId,
},
},
};

const probe = await fetchJSON(`${upstream.origin}/v0/management/api-call`, {
method: "POST",
headers: mgmtHeaders(env, true),
body: JSON.stringify(payload),
});

if (!probe.ok || typeof probe.body !== "object" || probe.body === null) continue;
if (probe.body.status_code === 401) {
invalid401.push({ name, reason: "status_code=401" });
}
}

const deleted = [];
const failed = [];
for (const item of invalid401) {
const del = await fetchJSON(
`${upstream.origin}/v0/management/auth-files?name=${encodeURIComponent(item.name)}`,
{ method: "DELETE", headers: mgmtHeaders(env, false) }
);
const success = del.ok && del.body && typeof del.body === "object" && del.body.status === "ok";
if (success) {
deleted.push(item.name);
} else {
failed.push({
name: item.name,
status: del.status,
details: del.body,
});
}
}

return {
ok: true,
scanned: candidates.length,
detected_401: invalid401.length,
deleted_401: deleted.length,
deleted_names: deleted,
delete_failed: failed,
filters: {
type: typeFilter || null,
provider: providerFilter || null,
max_scan: maxScan,
},
};
}

function isWardenAuthorized(request, env) {
const required = (env.WARDEN_TOKEN || "").trim();
if (!required) return true;

const fromHeader = request.headers.get("x-warden-token") || "";
if (fromHeader === required) return true;

const auth = request.headers.get("authorization") || "";
return auth === `Bearer ${required}`;
}

export default {
async fetch(request, env) {
const base = (env.UPSTREAM_BASE_URL || "").trim();
if (!base) return text("UPSTREAM_BASE_URL not configured", 500);

let upstream;
try {
upstream = new URL(base);
} catch {
return text("Invalid UPSTREAM_BASE_URL", 500);
}

const incoming = new URL(request.url);

if (incoming.pathname === "/warden/maintain-401") {
if (!isWardenAuthorized(request, env)) return text("Unauthorized", 401);
if (request.method !== "POST" && request.method !== "GET") {
return text("Method Not Allowed", 405);
}
const result = await scanAndDelete401(env);
return json(result, result.ok ? 200 : 500);
}

const edgeToken = (env.EDGE_BEARER_TOKEN || "").trim();
if (edgeToken) {
const auth = request.headers.get("authorization") || "";
if (auth !== `Bearer ${edgeToken}`) return text("Unauthorized", 401);
}

if (incoming.pathname === "/") {
return Response.redirect(new URL("/management.html#/login", incoming.origin).toString(), 302);
}

const target = new URL(incoming.pathname + incoming.search, upstream.origin);
const headers = new Headers(request.headers);

headers.set("x-forwarded-host", incoming.host);
headers.set("x-forwarded-proto", incoming.protocol.replace(":", ""));
headers.set("x-forwarded-for", request.headers.get("cf-connecting-ip") || "");

headers.delete("cf-connecting-ip");
headers.delete("cf-ipcountry");
headers.delete("cf-ray");
headers.delete("cf-visitor");

const proxied = new Request(target.toString(), {
method: request.method,
headers,
body: request.body,
redirect: "manual",
});

return fetch(proxied);
},

async scheduled(_event, env, ctx) {
const base = (env.UPSTREAM_BASE_URL || "").trim();
if (!base) return;

let upstream;
try {
upstream = new URL(base);
} catch {
return;
}

const enableKeepalive = String(env.ENABLE_KEEPALIVE || "true").toLowerCase() === "true";
const keepalivePath = String(env.KEEPALIVE_PATH || "/").trim() || "/";
const enableCronWarden = String(env.ENABLE_CRON_WARDEN || "false").toLowerCase() === "true";

if (enableKeepalive) {
const keepaliveURL = new URL(keepalivePath, upstream.origin).toString();
ctx.waitUntil(
fetch(keepaliveURL, { method: "GET" }).catch(() => null)
);
}

if (enableCronWarden) {
ctx.waitUntil(scanAndDelete401(env).catch(() => null));
}
},
};

wrangler.toml:

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
name = "cpa-web-gateway"
main = "src/index.js"
compatibility_date = "2026-03-04"
workers_dev = true

[triggers]
# 每 6 小时执行一次:保活 HF,且可选触发 401 清理
crons = ["0 */6 * * *"]

[vars]
# 你的 Hugging Face Space 地址,例如:https://your-space.hf.space
UPSTREAM_BASE_URL = "https://drift1ng-cpa-backend1.hf.space"

# 可选:给 Cloudflare 入口增加一层 Bearer 校验
EDGE_BEARER_TOKEN = ""

# --- Warden (401 自动处理) ---
# CPA 管理密钥(即 remote-management.secret-key 明文)
MANAGEMENT_KEY = "XXXXXXX"
# 调用 /warden/maintain-401 时的保护令牌(建议设置)
WARDEN_TOKEN = "XXXXXX" #(这个随便输)
# 仅处理指定类型(默认 codex)
WARDEN_TARGET_TYPE = "codex"
# 可选:仅处理指定 provider(留空表示不过滤)
WARDEN_PROVIDER = ""
# 一次最多扫描多少个账号
WARDEN_MAX_SCAN = "500"

# --- 定时任务开关 ---
# 是否启用定时保活
ENABLE_KEEPALIVE = "true"
# 保活请求路径
KEEPALIVE_PATH = "/"
# 是否在 cron 中顺带执行 401 维护
ENABLE_CRON_WARDEN = "ture"

编辑 wrangler.toml,重点是:

  • UPSTREAM_BASE_URL:填你的 HF Space 地址
  • MANAGEMENT_KEY:填上面 remote-management.secret-key 的明文,也就是填写登入密码
  • WARDEN_TOKEN:你自己新建一个随机字符串(保护 401 清理接口)

然后部署:

1
2
cd g:\cloudflare
wrangler deploy

部署后会给你一个 *.workers.dev 地址,直接访问:

  • https://你的worker地址/
  • https://你的worker地址/management.html#/login

7.3 登录管理页

登录密码就是你在 HF 配置文件里写的:

  • remote-management.secret-key

如果你看到“服务器地址无效或管理接口未启用”,通常是:

  1. remote-management 配置写错(比如误写成 management.key
  2. HF 还没用最新配置重启
  3. 浏览器缓存了旧地址(建议无痕窗口重试)

7.4 自动保活

cpa-web-cloudflare 已经内置定时任务:

  • 每 6 小时访问一次 HF(crons = ["0 */6 * * *"]

只要 Worker 正常运行,通常就能避免 HF 免费空间因 48 小时无访问而休眠。

7.5 网页版 401 自动清理

Worker 里加了一个接口:

  • POST /warden/maintain-401

调用示例:

1
2
curl -X POST "https://你的worker地址/warden/maintain-401" `
-H "x-warden-token: 你设置的WARDEN_TOKEN"

它会做三件事:

  1. 读取 CPA 的 auth-files
  2. 探测账号是否 401
  3. 自动删除 401 账号

返回结果里会有:

  • scanned:扫描数量
  • detected_401:识别到的 401 数量
  • deleted_401:成功删除数量

7.6 建议

  1. 不要把 secret-keyMANAGEMENT_KEYWARDEN_TOKEN 发到群里或截图里。
  2. WARDEN_TOKEN 建议用高强度随机字符串。
  3. 如果要更稳,定期看 Cloudflare 的 Worker 日志和 HF Space 日志,确