codex-slot 0.1.22 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -5
- package/dist/app/service-lifecycle-service.js +3 -7
- package/dist/backend-proxy-service.js +241 -0
- package/dist/cli.js +1 -1
- package/dist/codex-config.js +55 -11
- package/dist/config.js +4 -49
- package/dist/server.js +38 -22
- package/dist/service-control.js +0 -3
- package/dist/upstream-client.js +68 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
- Manage multiple accounts or workspaces as separate slots
|
|
11
11
|
- Refresh and cache the latest usage from the official usage endpoint
|
|
12
12
|
- Expose a local provider endpoint for Codex
|
|
13
|
+
- Proxy ChatGPT backend plugin requests through the selected cslot account
|
|
13
14
|
- Apply local block rules for temporary, 5-hour, and weekly limits
|
|
14
15
|
- Automatically switch `~/.codex/config.toml` to the `cslot` provider while the local proxy is running (and restore it on stop)
|
|
15
16
|
|
|
@@ -71,8 +72,8 @@ codex-slot start --port 4399
|
|
|
71
72
|
```
|
|
72
73
|
|
|
73
74
|
`start` will automatically write the required provider config into `~/.codex/config.toml`.
|
|
74
|
-
It prefers port `4399` by default and will switch to the next available port automatically when `4399` is busy, then sync that actual port into config
|
|
75
|
-
|
|
75
|
+
It prefers port `4399` by default and will switch to the next available port automatically when `4399` is busy, then sync that actual port into config.
|
|
76
|
+
The local provider does not use a separate cslot API key; cslot authenticates upstream requests with the selected Codex ChatGPT account token internally.
|
|
76
77
|
|
|
77
78
|
```bash
|
|
78
79
|
codex-slot start
|
|
@@ -106,6 +107,7 @@ The project is intentionally split by responsibility:
|
|
|
106
107
|
- `src/service-control.ts`: background service lifecycle management
|
|
107
108
|
- `src/status-command.ts`: usage refresh output and interactive toggle UI
|
|
108
109
|
- `src/codex-config.ts`: managed `~/.codex/config.toml` apply/restore logic
|
|
110
|
+
- `src/backend-proxy-service.ts`: ChatGPT backend proxy for Codex plugin/runtime requests
|
|
109
111
|
- `src/account-store.ts`, `src/usage-sync.ts`, `src/scheduler.ts`, `src/status.ts`: core domain and runtime logic
|
|
110
112
|
- `src/text.ts`: shared bilingual text and locale-independent formatting helpers
|
|
111
113
|
|
|
@@ -131,17 +133,23 @@ Instead it:
|
|
|
131
133
|
name = "cslot"
|
|
132
134
|
base_url = "http://127.0.0.1:4399/v1"
|
|
133
135
|
wire_api = "responses"
|
|
134
|
-
|
|
136
|
+
requires_openai_auth = true
|
|
135
137
|
```
|
|
136
138
|
|
|
137
139
|
Behavior:
|
|
138
140
|
|
|
139
141
|
- A managed marker block is inserted for `model_provider = "cslot"` and `[model_providers.cslot]`
|
|
140
|
-
- On `cslot stop`, the original `model_provider` line and original `[model_providers.cslot]` block are restored from the saved snapshot
|
|
142
|
+
- On `cslot stop`, the original `model_provider` line and original `[model_providers.cslot]` block are restored from the saved snapshot; legacy local bearer-token fields in the cslot provider are removed
|
|
141
143
|
- Other providers and settings in `config.toml` are left untouched
|
|
142
144
|
- If you start with `--port`, the port is saved to `~/.cslot/config.yaml`
|
|
143
145
|
- If you start without `--port`, `4399` is preferred first and the next free port is chosen automatically on conflict, and the actual chosen port is written back to `~/.cslot/config.yaml` and the managed provider block
|
|
144
|
-
-
|
|
146
|
+
- `requires_openai_auth = true` keeps Codex App treating the local cslot provider as a ChatGPT-authenticated provider, so plugin navigation and trusted plugin runtimes are not disabled as API-key/custom-provider mode
|
|
147
|
+
- `/backend-api/*` requests are forwarded to ChatGPT backend with the current selected account's upstream token; client `Authorization` headers are not forwarded upstream
|
|
148
|
+
|
|
149
|
+
## Codex App Plugins
|
|
150
|
+
|
|
151
|
+
`codex-slot start` also switches the main `~/.codex` login state to the selected managed account while cslot is running.
|
|
152
|
+
This keeps Codex app plugins that depend on the main ChatGPT login state working without a separate manual setup step.
|
|
145
153
|
|
|
146
154
|
## Data Directory
|
|
147
155
|
|
|
@@ -253,17 +253,14 @@ async function startManagedService(portOverride) {
|
|
|
253
253
|
pid: runningPid,
|
|
254
254
|
port: config.server.port,
|
|
255
255
|
logPath: (0, config_1.getServiceLogPath)(),
|
|
256
|
-
autoSwitched: false
|
|
257
|
-
apiKeyRotated: false
|
|
256
|
+
autoSwitched: false
|
|
258
257
|
};
|
|
259
258
|
}
|
|
260
259
|
if (config.server.port !== port) {
|
|
261
260
|
config.server.port = port;
|
|
262
261
|
(0, config_1.saveConfig)(config);
|
|
263
262
|
}
|
|
264
|
-
|
|
265
|
-
const persistedConfig = (0, config_1.rotateServerApiKey)(config);
|
|
266
|
-
(0, codex_config_1.applyManagedCodexConfig)(undefined, { config: persistedConfig });
|
|
263
|
+
(0, codex_config_1.applyManagedCodexConfig)(undefined, { config });
|
|
267
264
|
applyManagedAuthIfPossible();
|
|
268
265
|
const logPath = (0, config_1.getServiceLogPath)();
|
|
269
266
|
const logFd = node_fs_1.default.openSync(logPath, "a");
|
|
@@ -293,8 +290,7 @@ async function startManagedService(portOverride) {
|
|
|
293
290
|
pid: childPid,
|
|
294
291
|
port,
|
|
295
292
|
logPath,
|
|
296
|
-
autoSwitched
|
|
297
|
-
apiKeyRotated: true
|
|
293
|
+
autoSwitched
|
|
298
294
|
};
|
|
299
295
|
}
|
|
300
296
|
/**
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.proxyChatGptBackendWithRetry = void 0;
|
|
4
|
+
exports.createBackendProxyService = createBackendProxyService;
|
|
5
|
+
const account_store_1 = require("./account-store");
|
|
6
|
+
const config_1 = require("./config");
|
|
7
|
+
const scheduler_1 = require("./scheduler");
|
|
8
|
+
const state_repository_1 = require("./state-repository");
|
|
9
|
+
const state_1 = require("./state");
|
|
10
|
+
const text_1 = require("./text");
|
|
11
|
+
const upstream_client_1 = require("./upstream-client");
|
|
12
|
+
const upstream_error_policy_1 = require("./upstream-error-policy");
|
|
13
|
+
const usage_sync_1 = require("./usage-sync");
|
|
14
|
+
/**
|
|
15
|
+
* 解析本地 ChatGPT backend 代理请求,并转换成上游 backend path。
|
|
16
|
+
*
|
|
17
|
+
* 业务含义:
|
|
18
|
+
* 1. 本地代理只承载 `/backend-api/*` 这一类 ChatGPT backend 请求。
|
|
19
|
+
* 2. backend path 与 query 保留原样透传给上游,具体上游鉴权由 cslot 内部接管。
|
|
20
|
+
*
|
|
21
|
+
* @param request 原始本地代理请求。
|
|
22
|
+
* @returns 可发往上游的 backend path;不属于 backend 代理范围时返回错误结果。
|
|
23
|
+
* @throws 当 URL 解析失败时返回错误结果,不向上游发请求。
|
|
24
|
+
*/
|
|
25
|
+
function resolveBackendPath(request) {
|
|
26
|
+
const parsedUrl = new URL(request.url, "http://127.0.0.1");
|
|
27
|
+
const backendPrefix = "/backend-api";
|
|
28
|
+
if (!parsedUrl.pathname.startsWith(`${backendPrefix}/`)) {
|
|
29
|
+
return {
|
|
30
|
+
error: buildSendResult(404, {
|
|
31
|
+
error: {
|
|
32
|
+
message: (0, text_1.bi)("不支持的 ChatGPT backend 代理路径", "Unsupported ChatGPT backend proxy path"),
|
|
33
|
+
type: "unsupported_backend_proxy_path"
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const backendPath = parsedUrl.pathname.slice(backendPrefix.length);
|
|
39
|
+
return {
|
|
40
|
+
pathWithQuery: `${backendPath}${parsedUrl.search}`
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 提取上游响应中允许透传给客户端的响应头。
|
|
45
|
+
*
|
|
46
|
+
* @param headers 上游响应头对象。
|
|
47
|
+
* @returns 可透传响应头。
|
|
48
|
+
* @throws 无显式抛出。
|
|
49
|
+
*/
|
|
50
|
+
function pickResponseHeaders(headers) {
|
|
51
|
+
const picked = {};
|
|
52
|
+
const contentType = headers["content-type"];
|
|
53
|
+
const cacheControl = headers["cache-control"];
|
|
54
|
+
if (typeof contentType === "string") {
|
|
55
|
+
picked["content-type"] = contentType;
|
|
56
|
+
}
|
|
57
|
+
if (typeof cacheControl === "string") {
|
|
58
|
+
picked["cache-control"] = cacheControl;
|
|
59
|
+
}
|
|
60
|
+
return picked;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 构造统一错误响应结果。
|
|
64
|
+
*
|
|
65
|
+
* @param statusCode HTTP 状态码。
|
|
66
|
+
* @param payload 响应体。
|
|
67
|
+
* @param headers 可选响应头。
|
|
68
|
+
* @returns 代理服务可直接写回的 send 结果。
|
|
69
|
+
* @throws 无显式抛出。
|
|
70
|
+
*/
|
|
71
|
+
function buildSendResult(statusCode, payload, headers) {
|
|
72
|
+
return {
|
|
73
|
+
type: "send",
|
|
74
|
+
statusCode,
|
|
75
|
+
payload,
|
|
76
|
+
headers
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 为当前请求失败的账号设置临时熔断状态,避免短时间内被重复选中。
|
|
81
|
+
*
|
|
82
|
+
* @param dependencies 代理服务依赖集合。
|
|
83
|
+
* @param accountId 账号标识。
|
|
84
|
+
* @param reason 本地状态中记录的失败原因。
|
|
85
|
+
* @param blockSeconds 熔断持续秒数。
|
|
86
|
+
* @returns 无返回值。
|
|
87
|
+
* @throws 当状态写入失败时透传底层异常。
|
|
88
|
+
*/
|
|
89
|
+
function markAccountFailure(dependencies, accountId, reason, blockSeconds) {
|
|
90
|
+
dependencies.setAccountBlock(accountId, Math.floor(Date.now() / 1000) + blockSeconds, reason);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 使用指定候选账号向 ChatGPT backend 发送请求。
|
|
94
|
+
*
|
|
95
|
+
* @param dependencies 代理服务依赖集合。
|
|
96
|
+
* @param picked 当前候选账号。
|
|
97
|
+
* @param accessToken 可用 access token。
|
|
98
|
+
* @param pathWithQuery 已解析的 backend path 与 query。
|
|
99
|
+
* @param request 原始本地代理请求。
|
|
100
|
+
* @returns 上游响应。
|
|
101
|
+
* @throws 当网络层或 undici 请求失败时透传底层异常。
|
|
102
|
+
*/
|
|
103
|
+
async function sendWithAccount(dependencies, picked, accessToken, pathWithQuery, request) {
|
|
104
|
+
const config = dependencies.loadConfig();
|
|
105
|
+
const auth = dependencies.readAuthFile(picked.account.codex_home);
|
|
106
|
+
return await dependencies.sendChatGptBackendRequest({
|
|
107
|
+
chatGptBaseUrl: config.upstream.chatgpt_base_url,
|
|
108
|
+
method: request.method.toUpperCase(),
|
|
109
|
+
pathWithQuery,
|
|
110
|
+
requestHeaders: request.headers,
|
|
111
|
+
accessToken,
|
|
112
|
+
accountIdHeader: auth?.tokens?.account_id,
|
|
113
|
+
body: request.body
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* 创建 ChatGPT backend 代理服务。
|
|
118
|
+
*
|
|
119
|
+
* 业务含义:
|
|
120
|
+
* 1. 该服务复用 cslot 的账号调度与 token 刷新能力。
|
|
121
|
+
* 2. `/backend-api/*` 请求全量透传给 ChatGPT backend,但官方 token 始终只在 cslot 内部使用。
|
|
122
|
+
* 3. 非模型 backend 请求不套用 responses 的额度熔断语义,只对缺凭据、网络异常与 5xx 做账号级兜底。
|
|
123
|
+
*
|
|
124
|
+
* @param overrides 可选依赖覆盖项。
|
|
125
|
+
* @returns ChatGPT backend 代理服务实例。
|
|
126
|
+
* @throws 无显式抛出。
|
|
127
|
+
*/
|
|
128
|
+
function createBackendProxyService(overrides) {
|
|
129
|
+
const dependencies = {
|
|
130
|
+
loadConfig: config_1.loadConfig,
|
|
131
|
+
listCandidateAccounts: scheduler_1.listCandidateAccounts,
|
|
132
|
+
readAuthFile: account_store_1.readAuthFile,
|
|
133
|
+
sendChatGptBackendRequest: upstream_client_1.sendChatGptBackendRequest,
|
|
134
|
+
refreshAccountTokens: usage_sync_1.refreshAccountTokens,
|
|
135
|
+
setAccountBlock: state_1.setAccountBlock,
|
|
136
|
+
recordAccountScheduleSuccess: state_repository_1.recordAccountScheduleSuccess,
|
|
137
|
+
...overrides
|
|
138
|
+
};
|
|
139
|
+
return {
|
|
140
|
+
async proxyChatGptBackendWithRetry(request) {
|
|
141
|
+
const route = resolveBackendPath(request);
|
|
142
|
+
if (route.error) {
|
|
143
|
+
return route.error;
|
|
144
|
+
}
|
|
145
|
+
const candidates = dependencies.listCandidateAccounts();
|
|
146
|
+
if (candidates.length === 0) {
|
|
147
|
+
return buildSendResult(503, {
|
|
148
|
+
error: {
|
|
149
|
+
message: (0, text_1.bi)("当前没有可用账号", "No available account"),
|
|
150
|
+
type: "no_available_account"
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
let lastErrorPayload = {
|
|
155
|
+
error: {
|
|
156
|
+
message: (0, text_1.bi)("所有账号都请求失败", "All accounts failed"),
|
|
157
|
+
type: "all_accounts_failed"
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
let lastStatusCode = 503;
|
|
161
|
+
for (const picked of candidates) {
|
|
162
|
+
const auth = dependencies.readAuthFile(picked.account.codex_home);
|
|
163
|
+
let accessToken = auth?.tokens?.access_token;
|
|
164
|
+
if (!accessToken) {
|
|
165
|
+
markAccountFailure(dependencies, picked.account.id, "invalid_account_auth", 10 * 60);
|
|
166
|
+
lastErrorPayload = {
|
|
167
|
+
error: {
|
|
168
|
+
message: (0, text_1.bi)(`账号 ${picked.account.id} 缺少 access_token`, `Account ${picked.account.id} is missing access_token`),
|
|
169
|
+
type: "invalid_account_auth"
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
let upstream;
|
|
175
|
+
try {
|
|
176
|
+
upstream = await sendWithAccount(dependencies, picked, accessToken, route.pathWithQuery, request);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
if ((0, upstream_error_policy_1.isNetworkUnavailableError)(error)) {
|
|
180
|
+
lastErrorPayload = (0, upstream_error_policy_1.buildNetworkUnavailablePayload)(picked.account.id, error);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
markAccountFailure(dependencies, picked.account.id, "request_failed", 60);
|
|
184
|
+
lastErrorPayload = {
|
|
185
|
+
error: {
|
|
186
|
+
message: `账号 ${picked.account.id} 请求 ChatGPT backend 失败: ${error instanceof Error ? error.message : String(error)}`,
|
|
187
|
+
type: "account_backend_request_failed"
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (upstream.statusCode === 401) {
|
|
193
|
+
try {
|
|
194
|
+
const refreshed = await dependencies.refreshAccountTokens(picked.account.id);
|
|
195
|
+
accessToken = refreshed.tokens?.access_token ?? accessToken;
|
|
196
|
+
upstream = await sendWithAccount(dependencies, picked, accessToken, route.pathWithQuery, request);
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
if ((0, upstream_error_policy_1.isNetworkUnavailableError)(error)) {
|
|
200
|
+
lastErrorPayload = (0, upstream_error_policy_1.buildNetworkUnavailablePayload)(picked.account.id, error);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
markAccountFailure(dependencies, picked.account.id, "token_refresh_failed", 10 * 60);
|
|
204
|
+
lastErrorPayload = {
|
|
205
|
+
error: {
|
|
206
|
+
message: `账号 ${picked.account.id} 刷新 token 失败: ${error instanceof Error ? error.message : String(error)}`,
|
|
207
|
+
type: "account_token_refresh_failed"
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const responseHeaders = pickResponseHeaders(upstream.headers);
|
|
214
|
+
if (upstream.statusCode >= 500) {
|
|
215
|
+
const errorText = await upstream.body.text();
|
|
216
|
+
markAccountFailure(dependencies, picked.account.id, "upstream_5xx", 60);
|
|
217
|
+
lastStatusCode = upstream.statusCode;
|
|
218
|
+
lastErrorPayload = {
|
|
219
|
+
error: {
|
|
220
|
+
message: `账号 ${picked.account.id} ChatGPT backend 异常: ${errorText}`,
|
|
221
|
+
type: "account_backend_upstream_failed"
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
dependencies.recordAccountScheduleSuccess(picked.account.id);
|
|
227
|
+
return {
|
|
228
|
+
type: "proxy",
|
|
229
|
+
statusCode: upstream.statusCode,
|
|
230
|
+
headers: {
|
|
231
|
+
...responseHeaders,
|
|
232
|
+
connection: "keep-alive"
|
|
233
|
+
},
|
|
234
|
+
body: upstream.body
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return buildSendResult(lastStatusCode, lastErrorPayload);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
exports.proxyChatGptBackendWithRetry = createBackendProxyService().proxyChatGptBackendWithRetry;
|
package/dist/cli.js
CHANGED
|
@@ -93,7 +93,7 @@ function registerRuntimeCommands(program) {
|
|
|
93
93
|
.addHelpText("after", [
|
|
94
94
|
"",
|
|
95
95
|
`${(0, text_1.bi)("说明", "Notes")}:`,
|
|
96
|
-
` ${(0, text_1.bi)("start 会自动接管 `~/.codex/config.toml`;默认优先使用 4399
|
|
96
|
+
` ${(0, text_1.bi)("start 会自动接管 `~/.codex/config.toml`;默认优先使用 4399,冲突时自动顺延;指定端口时会写入该端口;stop 会恢复接管前内容。", "`start` will manage `~/.codex/config.toml` automatically; it prefers 4399 by default, switches to the next free port on conflict, writes the specified port when provided, and `stop` restores the previous content.")}`,
|
|
97
97
|
].join("\n"))
|
|
98
98
|
.action(async (options) => {
|
|
99
99
|
await (0, service_control_1.handleStart)(options.port);
|
package/dist/codex-config.js
CHANGED
|
@@ -66,17 +66,16 @@ function buildManagedModelProviderBlock(eol) {
|
|
|
66
66
|
* @returns 带标记的 provider 配置块文本。
|
|
67
67
|
*/
|
|
68
68
|
function buildManagedProviderBlock(eol, config) {
|
|
69
|
-
|
|
69
|
+
const lines = [
|
|
70
70
|
PROVIDER_BLOCK_START_MARKER,
|
|
71
71
|
"[model_providers.cslot]",
|
|
72
72
|
'name = "cslot"',
|
|
73
73
|
`base_url = "http://${config.server.host}:${config.server.port}/v1"`,
|
|
74
74
|
'wire_api = "responses"',
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
].join(eol);
|
|
75
|
+
"requires_openai_auth = true"
|
|
76
|
+
];
|
|
77
|
+
lines.push(PROVIDER_BLOCK_END_MARKER);
|
|
78
|
+
return lines.join(eol);
|
|
80
79
|
}
|
|
81
80
|
/**
|
|
82
81
|
* 判断候选表头是否属于指定父表的子表。
|
|
@@ -262,6 +261,51 @@ function findTableSectionRange(content, header) {
|
|
|
262
261
|
function findProviderSectionRange(content) {
|
|
263
262
|
return findTableSectionRange(content, "[model_providers.cslot]");
|
|
264
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* 清理旧版本 cslot provider 中的本地鉴权配置。
|
|
266
|
+
*
|
|
267
|
+
* @param providerBlock 原始 `[model_providers.cslot]` 表块。
|
|
268
|
+
* @returns 移除 `experimental_bearer_token` 与 `http_headers.Authorization` 后的表块。
|
|
269
|
+
* @throws 无显式抛出。
|
|
270
|
+
*/
|
|
271
|
+
function sanitizeLegacyCslotProviderBlock(providerBlock) {
|
|
272
|
+
const eol = detectEol(providerBlock);
|
|
273
|
+
const lines = providerBlock.split(/\r?\n/);
|
|
274
|
+
const sanitized = [];
|
|
275
|
+
let skippingLegacyHttpHeaders = false;
|
|
276
|
+
for (const line of lines) {
|
|
277
|
+
const trimmed = line.trim();
|
|
278
|
+
if (trimmed === "[model_providers.cslot.http_headers]") {
|
|
279
|
+
skippingLegacyHttpHeaders = true;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (skippingLegacyHttpHeaders && trimmed.startsWith("[") && !isChildTableHeader("[model_providers.cslot.http_headers]", trimmed)) {
|
|
283
|
+
skippingLegacyHttpHeaders = false;
|
|
284
|
+
}
|
|
285
|
+
if (skippingLegacyHttpHeaders) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (/^experimental_bearer_token\s*=/.test(trimmed)) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
sanitized.push(line);
|
|
292
|
+
}
|
|
293
|
+
return sanitized.join(eol);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* 清理当前文本中已有 cslot provider 的旧本地鉴权配置。
|
|
297
|
+
*
|
|
298
|
+
* @param content 当前 `config.toml` 内容。
|
|
299
|
+
* @returns 已清理旧鉴权字段的文本内容。
|
|
300
|
+
* @throws 无显式抛出。
|
|
301
|
+
*/
|
|
302
|
+
function sanitizeExistingCslotProviderSection(content) {
|
|
303
|
+
const range = findProviderSectionRange(content);
|
|
304
|
+
if (!range) {
|
|
305
|
+
return content;
|
|
306
|
+
}
|
|
307
|
+
return `${content.slice(0, range.start)}${sanitizeLegacyCslotProviderBlock(range.value)}${content.slice(range.end)}`;
|
|
308
|
+
}
|
|
265
309
|
/**
|
|
266
310
|
* 查找指定表头所在的行起始偏移。
|
|
267
311
|
*
|
|
@@ -507,9 +551,10 @@ function buildManagedSnapshot(targetFile, strippedCurrent, previousManagedState)
|
|
|
507
551
|
: null) ??
|
|
508
552
|
previousManagedState?.original_model_provider_next_table_header ??
|
|
509
553
|
null,
|
|
510
|
-
original_cslot_provider_block: originalProviderSection
|
|
511
|
-
previousManagedState?.original_cslot_provider_block
|
|
512
|
-
|
|
554
|
+
original_cslot_provider_block: (originalProviderSection ? sanitizeLegacyCslotProviderBlock(originalProviderSection.value) : null) ??
|
|
555
|
+
(previousManagedState?.original_cslot_provider_block
|
|
556
|
+
? sanitizeLegacyCslotProviderBlock(previousManagedState.original_cslot_provider_block)
|
|
557
|
+
: null),
|
|
513
558
|
original_cslot_provider_previous_table_header: (originalProviderSection
|
|
514
559
|
? findPreviousTableHeaderBeforeOffset(strippedCurrent, originalProviderSection.start)
|
|
515
560
|
: null) ??
|
|
@@ -552,7 +597,6 @@ function applyManagedCodexConfig(targetPathOrDir, options) {
|
|
|
552
597
|
if (!options?.silent) {
|
|
553
598
|
console.log((0, text_1.bi)(`已写入: ${targetFile}`, `Written to: ${targetFile}`));
|
|
554
599
|
console.log(`base_url=http://${config.server.host}:${config.server.port}/v1`);
|
|
555
|
-
console.log(`api_key=${config.server.api_key}`);
|
|
556
600
|
console.log((0, text_1.bi)("提示: start 会自动接管 codex provider,stop 会精确恢复接管前内容。", "Note: start will manage the Codex provider automatically, and stop will restore the exact previous content."));
|
|
557
601
|
}
|
|
558
602
|
return targetFile;
|
|
@@ -575,7 +619,7 @@ function deactivateManagedCodexConfig() {
|
|
|
575
619
|
}
|
|
576
620
|
const current = node_fs_1.default.readFileSync(targetFile, "utf8");
|
|
577
621
|
const eol = detectEol(current);
|
|
578
|
-
let restored = stripAllManagedBlocks(current);
|
|
622
|
+
let restored = sanitizeExistingCslotProviderSection(stripAllManagedBlocks(current));
|
|
579
623
|
const existingModelProviderLine = findModelProviderLine(restored);
|
|
580
624
|
if (!existingModelProviderLine && managedState.original_model_provider_block) {
|
|
581
625
|
restored = insertRootBlock(restored, managedState.original_model_provider_block, eol, managedState.original_model_provider_next_table_header);
|
package/dist/config.js
CHANGED
|
@@ -4,7 +4,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.getUserHomeDir = getUserHomeDir;
|
|
7
|
-
exports.generateServerApiKey = generateServerApiKey;
|
|
8
7
|
exports.getCslotHome = getCslotHome;
|
|
9
8
|
exports.getConfigPath = getConfigPath;
|
|
10
9
|
exports.getPidPath = getPidPath;
|
|
@@ -12,10 +11,8 @@ exports.getServiceLogPath = getServiceLogPath;
|
|
|
12
11
|
exports.expandHome = expandHome;
|
|
13
12
|
exports.loadConfig = loadConfig;
|
|
14
13
|
exports.saveConfig = saveConfig;
|
|
15
|
-
exports.rotateServerApiKey = rotateServerApiKey;
|
|
16
14
|
exports.getManagedHome = getManagedHome;
|
|
17
15
|
exports.upsertAccount = upsertAccount;
|
|
18
|
-
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
19
16
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
20
17
|
const node_os_1 = __importDefault(require("node:os"));
|
|
21
18
|
const node_path_1 = __importDefault(require("node:path"));
|
|
@@ -35,23 +32,23 @@ const configSchema = zod_1.z.object({
|
|
|
35
32
|
.object({
|
|
36
33
|
host: zod_1.z.string().default("127.0.0.1"),
|
|
37
34
|
port: zod_1.z.number().int().default(4399),
|
|
38
|
-
api_key: zod_1.z.string().default("cslot-defaultkey"),
|
|
39
35
|
body_limit_mb: zod_1.z.number().positive().default(512)
|
|
40
36
|
})
|
|
41
37
|
.default({
|
|
42
38
|
host: "127.0.0.1",
|
|
43
39
|
port: 4399,
|
|
44
|
-
api_key: "cslot-defaultkey",
|
|
45
40
|
body_limit_mb: 512
|
|
46
41
|
}),
|
|
47
42
|
upstream: zod_1.z
|
|
48
43
|
.object({
|
|
49
44
|
codex_base_url: zod_1.z.string().default("https://chatgpt.com/backend-api/codex"),
|
|
45
|
+
chatgpt_base_url: zod_1.z.string().default("https://chatgpt.com/backend-api"),
|
|
50
46
|
auth_base_url: zod_1.z.string().default("https://auth.openai.com"),
|
|
51
47
|
oauth_client_id: zod_1.z.string().default("app_EMoamEEZ73f0CkXaXp7hrann")
|
|
52
48
|
})
|
|
53
49
|
.default({
|
|
54
50
|
codex_base_url: "https://chatgpt.com/backend-api/codex",
|
|
51
|
+
chatgpt_base_url: "https://chatgpt.com/backend-api",
|
|
55
52
|
auth_base_url: "https://auth.openai.com",
|
|
56
53
|
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
57
54
|
}),
|
|
@@ -65,17 +62,6 @@ const configSchema = zod_1.z.object({
|
|
|
65
62
|
function getUserHomeDir() {
|
|
66
63
|
return process.env.HOME || process.env.USERPROFILE || node_os_1.default.homedir();
|
|
67
64
|
}
|
|
68
|
-
/**
|
|
69
|
-
* 生成新的本地服务 API Key。
|
|
70
|
-
*
|
|
71
|
-
* 该 key 仅用于本地代理服务与受管 `~/.codex/config.toml` 之间的鉴权,
|
|
72
|
-
* 不会影响上游官方 access token。
|
|
73
|
-
*
|
|
74
|
-
* @returns 随机生成的本地 API Key。
|
|
75
|
-
*/
|
|
76
|
-
function generateServerApiKey() {
|
|
77
|
-
return `cslot-${node_crypto_1.default.randomBytes(18).toString("hex")}`;
|
|
78
|
-
}
|
|
79
65
|
/**
|
|
80
66
|
* 返回 cslot 的根目录,并确保基础目录结构存在。
|
|
81
67
|
*
|
|
@@ -139,17 +125,16 @@ function expandHome(input) {
|
|
|
139
125
|
function loadConfig() {
|
|
140
126
|
const configPath = getConfigPath();
|
|
141
127
|
if (!node_fs_1.default.existsSync(configPath)) {
|
|
142
|
-
const defaultApiKey = generateServerApiKey();
|
|
143
128
|
const defaultConfig = {
|
|
144
129
|
version: 1,
|
|
145
130
|
server: {
|
|
146
131
|
host: "127.0.0.1",
|
|
147
132
|
port: 4399,
|
|
148
|
-
api_key: defaultApiKey,
|
|
149
133
|
body_limit_mb: 512
|
|
150
134
|
},
|
|
151
135
|
upstream: {
|
|
152
136
|
codex_base_url: "https://chatgpt.com/backend-api/codex",
|
|
137
|
+
chatgpt_base_url: "https://chatgpt.com/backend-api",
|
|
153
138
|
auth_base_url: "https://auth.openai.com",
|
|
154
139
|
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
155
140
|
},
|
|
@@ -161,18 +146,7 @@ function loadConfig() {
|
|
|
161
146
|
const raw = node_fs_1.default.readFileSync(configPath, "utf8");
|
|
162
147
|
const parsed = raw.trim() ? yaml_1.default.parse(raw) : {};
|
|
163
148
|
const normalized = configSchema.parse(parsed);
|
|
164
|
-
|
|
165
|
-
if ((!parsed || typeof parsed !== "object" || !("server" in parsed)) ||
|
|
166
|
-
!(parsed.server && typeof parsed.server === "object" && "api_key" in parsed.server)) {
|
|
167
|
-
normalized.server.api_key = generateServerApiKey();
|
|
168
|
-
changed = true;
|
|
169
|
-
}
|
|
170
|
-
// 兼容历史默认值,统一迁移到新的随机本地 key。
|
|
171
|
-
if (normalized.server.api_key === "local-only-key" ||
|
|
172
|
-
normalized.server.api_key === "cslot-defaultkey") {
|
|
173
|
-
normalized.server.api_key = generateServerApiKey();
|
|
174
|
-
changed = true;
|
|
175
|
-
}
|
|
149
|
+
const changed = JSON.stringify(parsed) !== JSON.stringify(normalized);
|
|
176
150
|
// 当旧配置缺少新字段时,将补全后的配置回写,便于用户直接编辑查看。
|
|
177
151
|
if (changed) {
|
|
178
152
|
saveConfig(normalized);
|
|
@@ -191,25 +165,6 @@ function saveConfig(config) {
|
|
|
191
165
|
const text = yaml_1.default.stringify(config);
|
|
192
166
|
node_fs_1.default.writeFileSync(configPath, text, "utf8");
|
|
193
167
|
}
|
|
194
|
-
/**
|
|
195
|
-
* 刷新本地代理服务 API Key,并将结果写回配置文件。
|
|
196
|
-
*
|
|
197
|
-
* 业务语义:
|
|
198
|
-
* 1. 每次真正启动本地代理前都重新生成一个新的本地 key。
|
|
199
|
-
* 2. 该 key 会同时驱动本地服务鉴权与 `~/.codex/config.toml` 中的 provider 头。
|
|
200
|
-
* 3. 若调用方已经持有最新配置对象,可直接传入,避免重复读取磁盘。
|
|
201
|
-
*
|
|
202
|
-
* @param config 可选的当前配置对象;未传入时会自动从磁盘读取。
|
|
203
|
-
* @returns 已写回磁盘的最新配置对象,其中 `server.api_key` 一定是新值。
|
|
204
|
-
* @throws 当配置读写失败时抛出文件系统错误。
|
|
205
|
-
*/
|
|
206
|
-
function rotateServerApiKey(config) {
|
|
207
|
-
const nextConfig = config ?? loadConfig();
|
|
208
|
-
// 每次启动前轮换本地鉴权 key,避免长期复用同一个静态口令。
|
|
209
|
-
nextConfig.server.api_key = generateServerApiKey();
|
|
210
|
-
saveConfig(nextConfig);
|
|
211
|
-
return nextConfig;
|
|
212
|
-
}
|
|
213
168
|
/**
|
|
214
169
|
* 根据账号标识生成其独立的 HOME 目录。
|
|
215
170
|
*
|
package/dist/server.js
CHANGED
|
@@ -6,18 +6,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.startServer = startServer;
|
|
7
7
|
const fastify_1 = __importDefault(require("fastify"));
|
|
8
8
|
const config_1 = require("./config");
|
|
9
|
+
const backend_proxy_service_1 = require("./backend-proxy-service");
|
|
9
10
|
const proxy_retry_service_1 = require("./proxy-retry-service");
|
|
10
11
|
const status_1 = require("./status");
|
|
11
12
|
const scheduler_1 = require("./scheduler");
|
|
12
|
-
const text_1 = require("./text");
|
|
13
13
|
const usage_sync_1 = require("./usage-sync");
|
|
14
|
-
function getBearerToken(headerValue) {
|
|
15
|
-
if (!headerValue) {
|
|
16
|
-
return null;
|
|
17
|
-
}
|
|
18
|
-
const match = /^Bearer\s+(.+)$/i.exec(headerValue);
|
|
19
|
-
return match?.[1] ?? null;
|
|
20
|
-
}
|
|
21
14
|
/**
|
|
22
15
|
* 读取代理请求的原始 body 字节,供多账号重试时重复发送同一份载荷。
|
|
23
16
|
*
|
|
@@ -51,20 +44,6 @@ async function startServer(port) {
|
|
|
51
44
|
logger: false,
|
|
52
45
|
bodyLimit: Math.floor(config.server.body_limit_mb * 1024 * 1024)
|
|
53
46
|
});
|
|
54
|
-
app.addHook("onRequest", async (request, reply) => {
|
|
55
|
-
if (request.url === "/health") {
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
const bearer = getBearerToken(request.headers.authorization);
|
|
59
|
-
if (bearer !== config.server.api_key) {
|
|
60
|
-
return reply.code(401).send({
|
|
61
|
-
error: {
|
|
62
|
-
message: (0, text_1.bi)("本地 API Key 无效", "Invalid local API key"),
|
|
63
|
-
type: "invalid_local_api_key"
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
});
|
|
68
47
|
app.get("/health", async () => {
|
|
69
48
|
return { ok: true };
|
|
70
49
|
});
|
|
@@ -96,6 +75,29 @@ async function startServer(port) {
|
|
|
96
75
|
}
|
|
97
76
|
reply.raw.end();
|
|
98
77
|
};
|
|
78
|
+
const backendProxyHandler = async (request, reply) => {
|
|
79
|
+
const requestBody = request.body ? await readRawRequestBody(request.body) : undefined;
|
|
80
|
+
const result = await (0, backend_proxy_service_1.proxyChatGptBackendWithRetry)({
|
|
81
|
+
method: request.method,
|
|
82
|
+
url: request.url,
|
|
83
|
+
headers: request.headers,
|
|
84
|
+
body: requestBody
|
|
85
|
+
});
|
|
86
|
+
if (result.type === "send") {
|
|
87
|
+
reply.code(result.statusCode);
|
|
88
|
+
for (const [headerName, headerValue] of Object.entries(result.headers ?? {})) {
|
|
89
|
+
reply.header(headerName, headerValue);
|
|
90
|
+
}
|
|
91
|
+
reply.send(result.payload);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
reply.hijack();
|
|
95
|
+
reply.raw.writeHead(result.statusCode, result.headers);
|
|
96
|
+
for await (const chunk of result.body) {
|
|
97
|
+
reply.raw.write(chunk);
|
|
98
|
+
}
|
|
99
|
+
reply.raw.end();
|
|
100
|
+
};
|
|
99
101
|
await app.register(async (proxyApp) => {
|
|
100
102
|
// 代理路由需要原样透传 body,不能在本地先做 JSON 解析与大小限制拦截。
|
|
101
103
|
proxyApp.removeAllContentTypeParsers();
|
|
@@ -108,6 +110,20 @@ async function startServer(port) {
|
|
|
108
110
|
proxyApp.post("/backend-api/codex/responses", { bodyLimit: Number.MAX_SAFE_INTEGER }, async (request, reply) => {
|
|
109
111
|
await proxyHandler(request.body, request.headers, reply);
|
|
110
112
|
});
|
|
113
|
+
proxyApp.route({
|
|
114
|
+
method: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
|
|
115
|
+
url: "/backend-api/*",
|
|
116
|
+
bodyLimit: Number.MAX_SAFE_INTEGER,
|
|
117
|
+
handler: async (request, reply) => {
|
|
118
|
+
const body = request.body;
|
|
119
|
+
await backendProxyHandler({
|
|
120
|
+
method: request.method,
|
|
121
|
+
url: request.url,
|
|
122
|
+
headers: request.headers,
|
|
123
|
+
body
|
|
124
|
+
}, reply);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
111
127
|
});
|
|
112
128
|
await app.listen({
|
|
113
129
|
host: config.server.host,
|
package/dist/service-control.js
CHANGED
|
@@ -26,9 +26,6 @@ async function handleStart(portOverride) {
|
|
|
26
26
|
if (result.autoSwitched) {
|
|
27
27
|
console.log((0, text_1.bi)(`默认端口 4399 已被占用,已自动切换到 ${result.port}`, `Default port 4399 is busy. Automatically switched to ${result.port}`));
|
|
28
28
|
}
|
|
29
|
-
if (result.apiKeyRotated) {
|
|
30
|
-
console.log((0, text_1.bi)("本次启动已重新生成本地 api_key,并同步写入受管配置。", "A new local api_key was generated for this start and synced to the managed config."));
|
|
31
|
-
}
|
|
32
29
|
console.log((0, text_1.bi)(`服务已启动: http://${config.server.host}:${result.port}`, `Service started: http://${config.server.host}:${result.port}`));
|
|
33
30
|
console.log(`PID: ${result.pid}`);
|
|
34
31
|
console.log((0, text_1.bi)(`日志: ${result.logPath}`, `Log: ${result.logPath}`));
|
package/dist/upstream-client.js
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.buildUpstreamHeaders = buildUpstreamHeaders;
|
|
4
4
|
exports.sendCodexResponsesRequest = sendCodexResponsesRequest;
|
|
5
|
+
exports.buildChatGptBackendHeaders = buildChatGptBackendHeaders;
|
|
6
|
+
exports.sendChatGptBackendRequest = sendChatGptBackendRequest;
|
|
5
7
|
const undici_1 = require("undici");
|
|
6
8
|
/**
|
|
7
9
|
* 构造发往上游的请求头,并移除仅属于本地代理链路的头信息。
|
|
8
10
|
*
|
|
9
11
|
* 业务含义:
|
|
10
|
-
* 1.
|
|
12
|
+
* 1. 本地服务只监听 loopback 地址,转发时必须替换为真实上游 access token。
|
|
11
13
|
* 2. body 会在本地先读取成 Buffer 以支持失败后切换账号重试,因此这里重算 content-length。
|
|
12
14
|
*
|
|
13
15
|
* @param requestHeaders 客户端发到本地服务的原始请求头。
|
|
@@ -61,3 +63,68 @@ async function sendCodexResponsesRequest(options) {
|
|
|
61
63
|
body: options.body
|
|
62
64
|
});
|
|
63
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* 构造发往 ChatGPT backend API 的通用请求头。
|
|
68
|
+
*
|
|
69
|
+
* 业务含义:
|
|
70
|
+
* 1. 该方法服务于非模型兼容接口,例如 Browser Use 的安全检查接口。
|
|
71
|
+
* 2. 调用方残留的 Authorization 不能透传给上游,必须替换为当前调度账号的官方 access token。
|
|
72
|
+
*
|
|
73
|
+
* @param requestHeaders 客户端发到本地服务的原始请求头。
|
|
74
|
+
* @param accessToken 当前候选账号可用的上游访问令牌。
|
|
75
|
+
* @param accountIdHeader 可选的 ChatGPT 账号标识头。
|
|
76
|
+
* @param bodyLength 可选请求体长度;无请求体时不写 content-length。
|
|
77
|
+
* @returns 可直接传给 ChatGPT backend 的请求头对象。
|
|
78
|
+
* @throws 无显式抛出。
|
|
79
|
+
*/
|
|
80
|
+
function buildChatGptBackendHeaders(requestHeaders, accessToken, accountIdHeader, bodyLength) {
|
|
81
|
+
const headers = {};
|
|
82
|
+
for (const [headerName, headerValue] of Object.entries(requestHeaders)) {
|
|
83
|
+
const normalizedName = headerName.toLowerCase();
|
|
84
|
+
if (headerValue == null ||
|
|
85
|
+
normalizedName === "authorization" ||
|
|
86
|
+
normalizedName === "host" ||
|
|
87
|
+
normalizedName === "connection" ||
|
|
88
|
+
normalizedName === "content-length") {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
headers[normalizedName] = Array.isArray(headerValue)
|
|
92
|
+
? headerValue.join(", ")
|
|
93
|
+
: headerValue;
|
|
94
|
+
}
|
|
95
|
+
headers.authorization = `Bearer ${accessToken}`;
|
|
96
|
+
if (!headers.accept) {
|
|
97
|
+
headers.accept = "application/json";
|
|
98
|
+
}
|
|
99
|
+
headers["user-agent"] = "codex-slot/0.1.1";
|
|
100
|
+
if (typeof bodyLength === "number") {
|
|
101
|
+
headers["content-length"] = String(bodyLength);
|
|
102
|
+
}
|
|
103
|
+
if (accountIdHeader) {
|
|
104
|
+
headers["chatgpt-account-id"] = accountIdHeader;
|
|
105
|
+
}
|
|
106
|
+
return headers;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* 向 ChatGPT backend API 发送一次通用请求。
|
|
110
|
+
*
|
|
111
|
+
* 业务含义:
|
|
112
|
+
* 1. 该 Adapter 只负责安全地拼接 backend 基地址、路由和鉴权头。
|
|
113
|
+
* 2. 允许代理哪些 backend 路由由更上层策略控制,避免这里承担访问控制职责。
|
|
114
|
+
*
|
|
115
|
+
* @param options 上游请求参数。
|
|
116
|
+
* @returns undici 上游响应对象。
|
|
117
|
+
* @throws 当网络层或 undici 请求失败时透传底层异常。
|
|
118
|
+
*/
|
|
119
|
+
async function sendChatGptBackendRequest(options) {
|
|
120
|
+
const baseUrl = options.chatGptBaseUrl.replace(/\/+$/, "");
|
|
121
|
+
const pathWithQuery = options.pathWithQuery.startsWith("/")
|
|
122
|
+
? options.pathWithQuery
|
|
123
|
+
: `/${options.pathWithQuery}`;
|
|
124
|
+
const bodyLength = options.body && options.body.length > 0 ? options.body.length : undefined;
|
|
125
|
+
return await (0, undici_1.request)(`${baseUrl}${pathWithQuery}`, {
|
|
126
|
+
method: options.method,
|
|
127
|
+
headers: buildChatGptBackendHeaders(options.requestHeaders, options.accessToken, options.accountIdHeader, bodyLength),
|
|
128
|
+
body: options.body && options.body.length > 0 ? options.body : undefined
|
|
129
|
+
});
|
|
130
|
+
}
|