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 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
- Each start also generates a fresh local `api_key` and syncs it into the managed provider config.
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
- experimental_bearer_token = "<your-local-api-key>"
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
- - Every `start` rotates the local `api_key`, and the new value is written to both `~/.cslot/config.yaml` and the managed provider block
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
- // 每次真正启动服务前都轮换一次本地 api_key,并让受管 config.toml 使用同一新值。
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,冲突时自动顺延;每次启动都会重新生成本地 api_key;指定端口时会写入该端口;stop 会恢复接管前内容。", "`start` will manage `~/.codex/config.toml` automatically; it prefers 4399 by default, switches to the next free port on conflict, generates a fresh local api_key on every start, writes the specified port when provided, and `stop` restores the previous content.")}`,
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);
@@ -66,17 +66,16 @@ function buildManagedModelProviderBlock(eol) {
66
66
  * @returns 带标记的 provider 配置块文本。
67
67
  */
68
68
  function buildManagedProviderBlock(eol, config) {
69
- return [
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
- `experimental_bearer_token = "${config.server.api_key}"`,
76
- "[model_providers.cslot.http_headers]",
77
- `Authorization = "Bearer ${config.server.api_key}"`,
78
- PROVIDER_BLOCK_END_MARKER
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?.value ??
511
- previousManagedState?.original_cslot_provider_block ??
512
- null,
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
- let changed = JSON.stringify(parsed) !== JSON.stringify(normalized);
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,
@@ -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}`));
@@ -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. 本地服务使用独立 api_key 鉴权,转发时必须替换为真实上游 access token。
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-slot",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "本地 Codex 多账号切换与状态管理工具",
5
5
  "type": "commonjs",
6
6
  "main": "dist/cli.js",