codex-slot 0.1.28 → 0.1.30

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
+ - Optionally pin model requests to an OpenAI-compatible relay slot
13
14
  - Proxy ChatGPT backend plugin requests through the selected cslot account
14
15
  - Apply local block rules for temporary, 5-hour, and weekly limits
15
16
  - Automatically switch `~/.codex/config.toml` to the `cslot` provider while the local proxy is running (and restore it on stop)
@@ -89,12 +90,21 @@ codex-slot import <name> [HOME]
89
90
  codex-slot status
90
91
  codex-slot start [--port <port>]
91
92
  codex-slot stop
93
+ codex-slot relay add <name> --base-url <url> --api-key <key>
94
+ codex-slot relay list
95
+ codex-slot use relay <name>
96
+ codex-slot use auth
97
+ codex-slot current
92
98
  ```
93
99
 
94
100
  Common patterns:
95
101
 
96
102
  - `cslot import work ~/workspace-home`
97
103
  - `cslot rename work work-main`
104
+ - `cslot relay add third --base-url https://relay.example.com/v1 --api-key sk-...`
105
+ - `cslot status`
106
+ - `cslot use relay third`
107
+ - `cslot use auth`
98
108
  - `cslot start`
99
109
 
100
110
  ## Architecture
@@ -108,6 +118,7 @@ The project is intentionally split by responsibility:
108
118
  - `src/status-command.ts`: usage refresh output and interactive toggle UI
109
119
  - `src/codex-config.ts`: managed `~/.codex/config.toml` apply/restore logic
110
120
  - `src/backend-proxy-service.ts`: ChatGPT backend proxy for Codex plugin/runtime requests
121
+ - `src/relay-proxy-service.ts`, `src/model-proxy-dispatcher.ts`, `src/relay-store.ts`: optional OpenAI-compatible relay slots and `/v1/*` model route dispatch
111
122
  - `src/account-store.ts`, `src/usage-sync.ts`, `src/scheduler.ts`, `src/status.ts`: core domain and runtime logic
112
123
  - `src/text.ts`: shared bilingual text and locale-independent formatting helpers
113
124
 
@@ -146,6 +157,27 @@ Behavior:
146
157
  - `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
158
  - `/backend-api/*` requests are forwarded to ChatGPT backend with the current selected account's upstream token; client `Authorization` headers are not forwarded upstream
148
159
 
160
+ ## OpenAI-compatible Relay Slots
161
+
162
+ Relay slots are optional model-only exits. They do not replace the official Codex / ChatGPT login state.
163
+ Use `codex-slot relay add` to register a relay slot, then use `codex-slot status` for daily enable/disable and model-route selection.
164
+
165
+ ```bash
166
+ codex-slot relay add third --base-url https://relay.example.com/v1 --api-key sk-...
167
+ codex-slot use relay third
168
+ codex-slot current
169
+ ```
170
+
171
+ Behavior:
172
+
173
+ - `/v1/*` model requests are fixed to the selected relay slot
174
+ - Relay requests use the relay slot API key; client `Authorization` headers are not forwarded upstream
175
+ - Relay failures are returned directly and do not fall back to official cslot accounts
176
+ - `/backend-api/*` plugin/runtime requests still use the selected official Codex / ChatGPT login state
177
+ - Usage refresh only applies to official accounts, not relay slots
178
+ - In interactive `status`, press `Space` to enable/disable the selected account or relay slot, and press `m` to select the model route
179
+ - Run `codex-slot use auth` to restore `/v1/*` model requests to the official account scheduler
180
+
149
181
  ## Codex App Plugins
150
182
 
151
183
  `codex-slot start` also switches the main `~/.codex` login state to the selected managed account while cslot is running.
@@ -3,11 +3,20 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.refreshStatusSnapshot = refreshStatusSnapshot;
4
4
  exports.getStatusSnapshot = getStatusSnapshot;
5
5
  exports.persistAccountEnabledState = persistAccountEnabledState;
6
+ exports.persistRelayEnabledState = persistRelayEnabledState;
6
7
  const config_1 = require("../config");
7
8
  const scheduler_1 = require("../scheduler");
8
9
  const status_1 = require("../status");
9
10
  const state_1 = require("../state");
11
+ const relay_store_1 = require("../relay-store");
12
+ const state_2 = require("../state");
10
13
  const usage_sync_1 = require("../usage-sync");
14
+ function formatModelRouteLabel(route) {
15
+ if (route.mode === "relay_slot") {
16
+ return `relay:${route.relay_slot_id}`;
17
+ }
18
+ return "auth_pool";
19
+ }
11
20
  /**
12
21
  * 刷新全部账号额度并返回最新状态快照。
13
22
  *
@@ -27,8 +36,12 @@ async function refreshStatusSnapshot() {
27
36
  function getStatusSnapshot() {
28
37
  const statuses = (0, status_1.collectAccountStatuses)();
29
38
  const selected = (0, scheduler_1.pickBestAccount)();
39
+ const modelRoute = (0, state_2.getSelectedModelRoute)();
30
40
  return {
31
41
  statuses,
42
+ relaySlots: (0, relay_store_1.listRelaySlots)(),
43
+ modelRoute,
44
+ modelRouteLabel: formatModelRouteLabel(modelRoute),
32
45
  selectedName: selected?.account.name ?? null,
33
46
  codexAuthAccountId: (0, state_1.getSelectedCodexAuthAccountId)(),
34
47
  summary: (0, status_1.summarizeAccountStatuses)(statuses)
@@ -51,3 +64,20 @@ function persistAccountEnabledState(accounts) {
51
64
  }
52
65
  (0, config_1.saveConfig)(latest);
53
66
  }
67
+ /**
68
+ * 将交互式界面中的 relay 启用状态修改写回配置文件。
69
+ *
70
+ * @param slots 用户在交互界面中调整后的 relay slot 数组。
71
+ * @returns 无返回值。
72
+ * @throws 当配置写入失败时抛出异常。
73
+ */
74
+ function persistRelayEnabledState(slots) {
75
+ const latest = (0, config_1.loadConfig)();
76
+ for (const slot of slots) {
77
+ const index = latest.relay_slots.findIndex((item) => item.id === slot.id);
78
+ if (index >= 0) {
79
+ latest.relay_slots[index].enabled = slot.enabled;
80
+ }
81
+ }
82
+ (0, config_1.saveConfig)(latest);
83
+ }
package/dist/cli.js CHANGED
@@ -5,9 +5,44 @@ const commander_1 = require("commander");
5
5
  const account_commands_1 = require("./account-commands");
6
6
  const cli_helpers_1 = require("./cli-helpers");
7
7
  const config_1 = require("./config");
8
+ const relay_commands_1 = require("./relay-commands");
8
9
  const service_control_1 = require("./service-control");
9
10
  const status_command_1 = require("./status-command");
10
11
  const text_1 = require("./text");
12
+ /**
13
+ * 生成根 help 中的 relay 与模型出口命令说明。
14
+ *
15
+ * 业务含义:
16
+ * 1. Commander 默认命令列表会按终端宽度折行,中英混排时可读性较差。
17
+ * 2. 最近新增的 relay/use/current 命令需要用固定双语格式补充业务含义和使用方式。
18
+ *
19
+ * @returns 可追加到根 help 的多行说明。
20
+ * @throws 无显式抛出。
21
+ */
22
+ function renderRelayCommandHelp() {
23
+ return [
24
+ "",
25
+ "中转命令 / Relay commands:",
26
+ " cslot relay add <name> --base-url <url> --api-key <key>",
27
+ " 中文: 新增 OpenAI-compatible 中转槽位。",
28
+ " English: Add an OpenAI-compatible relay slot.",
29
+ " cslot relay list",
30
+ " 中文: 查看全部中转槽位,API key 会脱敏。",
31
+ " English: List relay slots with API keys masked.",
32
+ " cslot relay enable <name> / cslot relay disable <name>",
33
+ " 中文: 控制某个中转槽位是否参与模型出口选择。",
34
+ " English: Enable or disable a relay slot for model routing.",
35
+ " cslot use relay <name>",
36
+ " 中文: 固定模型请求走指定中转槽位。",
37
+ " English: Route model requests through the selected relay slot.",
38
+ " cslot use auth",
39
+ " 中文: 恢复使用官方 Codex 账号池。",
40
+ " English: Restore routing through the official Codex auth pool.",
41
+ " cslot current",
42
+ " 中文: 查看当前模型出口和 Codex App 登录态选择。",
43
+ " English: Show the active model route and Codex App auth selection."
44
+ ];
45
+ }
11
46
  /**
12
47
  * 为 CLI 程序注册根级帮助信息与统一示例。
13
48
  *
@@ -28,6 +63,7 @@ function configureRootProgram(program) {
28
63
  " cslot rename work work-main",
29
64
  " cslot start --port 4399",
30
65
  " cslot status --no-interactive",
66
+ ...renderRelayCommandHelp(),
31
67
  "",
32
68
  `${(0, text_1.bi)("说明", "Notes")}:`,
33
69
  ` ${(0, text_1.bi)("`import current ~` 里的 current 只是示例槽位名,不是内置账号或工作空间。", "`current` in `import current ~` is only an example slot name, not a built-in account or workspace.")}`
@@ -71,6 +107,66 @@ function registerAccountCommands(program) {
71
107
  .argument("<newName>", (0, text_1.bi)("新账号或工作空间标识", "New managed slot name"))
72
108
  .action(account_commands_1.handleAccountRename);
73
109
  }
110
+ /**
111
+ * 注册 relay 与模型出口选择相关子命令。
112
+ *
113
+ * @param program Commander 程序实例。
114
+ * @returns 无返回值。
115
+ * @throws 无显式抛出。
116
+ */
117
+ function registerRelayCommands(program) {
118
+ const relay = program
119
+ .command("relay")
120
+ .description((0, text_1.bi)("管理中转槽位", "Manage relay slots"));
121
+ relay
122
+ .command("add")
123
+ .description((0, text_1.bi)("新增中转槽位", "Add relay slot"))
124
+ .argument("<name>", (0, text_1.bi)("中转槽位名", "Relay slot name"))
125
+ .requiredOption("--base-url <url>", (0, text_1.bi)("OpenAI-compatible base_url,通常以 /v1 结尾", "OpenAI-compatible base_url, usually ending with /v1"))
126
+ .requiredOption("--api-key <key>", (0, text_1.bi)("中转 API key", "Relay API key"))
127
+ .action(relay_commands_1.handleRelayAdd);
128
+ relay
129
+ .command("list")
130
+ .description((0, text_1.bi)("列出中转槽位", "List relay slots"))
131
+ .action(relay_commands_1.handleRelayList);
132
+ relay
133
+ .command("del")
134
+ .description((0, text_1.bi)("删除一个中转槽位", "Remove a relay slot"))
135
+ .argument("<name>", (0, text_1.bi)("中转槽位名", "Relay slot name"))
136
+ .action(relay_commands_1.handleRelayRemove);
137
+ relay
138
+ .command("rename")
139
+ .description((0, text_1.bi)("重命名一个中转槽位", "Rename a relay slot"))
140
+ .argument("<oldName>", (0, text_1.bi)("原中转槽位名", "Old relay slot name"))
141
+ .argument("<newName>", (0, text_1.bi)("新中转槽位名", "New relay slot name"))
142
+ .action(relay_commands_1.handleRelayRename);
143
+ relay
144
+ .command("enable")
145
+ .description((0, text_1.bi)("启用中转槽位", "Enable relay slot"))
146
+ .argument("<name>", (0, text_1.bi)("中转槽位名", "Relay slot name"))
147
+ .action(relay_commands_1.handleRelayEnable);
148
+ relay
149
+ .command("disable")
150
+ .description((0, text_1.bi)("禁用中转槽位", "Disable relay slot"))
151
+ .argument("<name>", (0, text_1.bi)("中转槽位名", "Relay slot name"))
152
+ .action(relay_commands_1.handleRelayDisable);
153
+ const use = program
154
+ .command("use")
155
+ .description((0, text_1.bi)("切换模型出口", "Switch model route"));
156
+ use
157
+ .command("relay")
158
+ .description((0, text_1.bi)("使用指定中转槽位", "Use relay slot"))
159
+ .argument("<name>", (0, text_1.bi)("中转槽位名", "Relay slot name"))
160
+ .action(relay_commands_1.handleUseRelay);
161
+ use
162
+ .command("auth")
163
+ .description((0, text_1.bi)("使用官方账号池", "Use official auth pool"))
164
+ .action(relay_commands_1.handleUseAuthPool);
165
+ program
166
+ .command("current")
167
+ .description((0, text_1.bi)("查看当前出口", "Show current route"))
168
+ .action(relay_commands_1.handleCurrent);
169
+ }
74
170
  /**
75
171
  * 注册配置与状态相关子命令。
76
172
  *
@@ -117,6 +213,7 @@ async function main() {
117
213
  (0, config_1.loadConfig)();
118
214
  configureRootProgram(program);
119
215
  registerAccountCommands(program);
216
+ registerRelayCommands(program);
120
217
  registerRuntimeCommands(program);
121
218
  await program.parseAsync(process.argv);
122
219
  }
package/dist/config.js CHANGED
@@ -26,6 +26,14 @@ const managedAccountSchema = zod_1.z.object({
26
26
  enabled: zod_1.z.boolean().default(true),
27
27
  imported_at: zod_1.z.string().optional()
28
28
  });
29
+ const relaySlotSchema = zod_1.z.object({
30
+ id: zod_1.z.string().min(1),
31
+ name: zod_1.z.string().min(1),
32
+ base_url: zod_1.z.string().url(),
33
+ api_key: zod_1.z.string().min(1),
34
+ enabled: zod_1.z.boolean().default(true),
35
+ imported_at: zod_1.z.string().optional()
36
+ });
29
37
  const configSchema = zod_1.z.object({
30
38
  version: zod_1.z.number().int().default(1),
31
39
  server: zod_1.z
@@ -52,7 +60,8 @@ const configSchema = zod_1.z.object({
52
60
  auth_base_url: "https://auth.openai.com",
53
61
  oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann"
54
62
  }),
55
- accounts: zod_1.z.array(managedAccountSchema).default([])
63
+ accounts: zod_1.z.array(managedAccountSchema).default([]),
64
+ relay_slots: zod_1.z.array(relaySlotSchema).default([])
56
65
  });
57
66
  /**
58
67
  * 解析当前进程应使用的用户 HOME 目录,兼容 Windows 缺少 `HOME` 的场景。
@@ -138,7 +147,8 @@ function loadConfig() {
138
147
  auth_base_url: "https://auth.openai.com",
139
148
  oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann"
140
149
  },
141
- accounts: []
150
+ accounts: [],
151
+ relay_slots: []
142
152
  };
143
153
  saveConfig(defaultConfig);
144
154
  return defaultConfig;
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.proxyModelWithRoute = void 0;
4
+ exports.createModelProxyDispatcher = createModelProxyDispatcher;
5
+ const proxy_retry_service_1 = require("./proxy-retry-service");
6
+ const relay_proxy_service_1 = require("./relay-proxy-service");
7
+ const relay_store_1 = require("./relay-store");
8
+ const state_1 = require("./state");
9
+ function buildSendResult(statusCode, payload, headers) {
10
+ return {
11
+ type: "send",
12
+ statusCode,
13
+ payload,
14
+ headers
15
+ };
16
+ }
17
+ /**
18
+ * 创建 `/v1/*` 模型请求分发器。
19
+ *
20
+ * 业务含义:
21
+ * 1. 默认 `auth_pool` 模式保持原有官方账号自动调度行为。
22
+ * 2. 手动选择 `relay_slot` 后,模型请求固定走该 relay slot。
23
+ * 3. relay 模式不影响 `/backend-api/*` 插件链路,也不会失败回退官方账号。
24
+ *
25
+ * @param overrides 可选依赖覆盖项。
26
+ * @returns 模型请求分发器实例。
27
+ * @throws 无显式抛出。
28
+ */
29
+ function createModelProxyDispatcher(overrides) {
30
+ const dependencies = {
31
+ getSelectedModelRoute: state_1.getSelectedModelRoute,
32
+ findRelaySlot: relay_store_1.findRelaySlot,
33
+ proxyCodexWithRetry: proxy_retry_service_1.proxyCodexWithRetry,
34
+ proxyRelaySlot: relay_proxy_service_1.proxyRelaySlot,
35
+ ...overrides
36
+ };
37
+ return {
38
+ async proxyModelWithRoute(request) {
39
+ const route = dependencies.getSelectedModelRoute();
40
+ if (route.mode !== "relay_slot") {
41
+ return await dependencies.proxyCodexWithRetry(request);
42
+ }
43
+ const slot = dependencies.findRelaySlot(route.relay_slot_id);
44
+ if (!slot) {
45
+ return buildSendResult(503, {
46
+ error: {
47
+ message: `Relay slot not found: ${route.relay_slot_id}`,
48
+ type: "relay_slot_not_found"
49
+ }
50
+ });
51
+ }
52
+ if (!slot.enabled) {
53
+ return buildSendResult(503, {
54
+ error: {
55
+ message: `Relay slot is disabled: ${route.relay_slot_id}`,
56
+ type: "relay_slot_disabled"
57
+ }
58
+ });
59
+ }
60
+ return await dependencies.proxyRelaySlot({
61
+ slot,
62
+ request
63
+ });
64
+ }
65
+ };
66
+ }
67
+ exports.proxyModelWithRoute = createModelProxyDispatcher().proxyModelWithRoute;
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleRelayAdd = handleRelayAdd;
4
+ exports.handleRelayList = handleRelayList;
5
+ exports.handleRelayRemove = handleRelayRemove;
6
+ exports.handleRelayRename = handleRelayRename;
7
+ exports.handleRelayEnable = handleRelayEnable;
8
+ exports.handleRelayDisable = handleRelayDisable;
9
+ exports.handleUseRelay = handleUseRelay;
10
+ exports.handleUseAuthPool = handleUseAuthPool;
11
+ exports.handleCurrent = handleCurrent;
12
+ const relay_store_1 = require("./relay-store");
13
+ const state_1 = require("./state");
14
+ const text_1 = require("./text");
15
+ function maskApiKey(apiKey) {
16
+ if (apiKey.length <= 8) {
17
+ return "****";
18
+ }
19
+ return `${apiKey.slice(0, 4)}...${apiKey.slice(-4)}`;
20
+ }
21
+ function formatModelRoute() {
22
+ const route = (0, state_1.getSelectedModelRoute)();
23
+ if (route.mode === "relay_slot") {
24
+ return `relay:${route.relay_slot_id}`;
25
+ }
26
+ return "auth_pool";
27
+ }
28
+ /**
29
+ * 新增 OpenAI-compatible relay slot。
30
+ *
31
+ * @param name relay slot 名称。
32
+ * @param options relay 参数,必须包含 baseUrl 与 apiKey。
33
+ * @returns 无返回值。
34
+ * @throws 当 slot 已存在、URL 非法或配置写入失败时抛出异常。
35
+ */
36
+ function handleRelayAdd(name, options) {
37
+ const slot = (0, relay_store_1.addRelaySlot)({
38
+ name,
39
+ baseUrl: options.baseUrl,
40
+ apiKey: options.apiKey
41
+ });
42
+ console.log((0, text_1.bi)(`已添加中转槽位: ${slot.name}`, `Relay slot added: ${slot.name}`));
43
+ }
44
+ /**
45
+ * 列出 OpenAI-compatible relay slot。
46
+ *
47
+ * @returns 无返回值。
48
+ * @throws 当配置读取失败时抛出异常。
49
+ */
50
+ function handleRelayList() {
51
+ const slots = (0, relay_store_1.listRelaySlots)();
52
+ const route = (0, state_1.getSelectedModelRoute)();
53
+ if (slots.length === 0) {
54
+ console.log((0, text_1.bi)("当前没有中转槽位。", "No relay slots found."));
55
+ return;
56
+ }
57
+ for (const slot of slots) {
58
+ const selected = route.mode === "relay_slot" && route.relay_slot_id === slot.id ? "*" : " ";
59
+ const enabled = slot.enabled ? "enabled" : "disabled";
60
+ console.log(`${selected} ${slot.name} ${enabled} ${slot.base_url} key=${maskApiKey(slot.api_key)}`);
61
+ }
62
+ }
63
+ /**
64
+ * 删除 OpenAI-compatible relay slot。
65
+ *
66
+ * @param name relay slot 名称。
67
+ * @returns 无返回值。
68
+ * @throws 当配置写入失败时抛出异常。
69
+ */
70
+ function handleRelayRemove(name) {
71
+ const removed = (0, relay_store_1.removeRelaySlot)(name);
72
+ if (!removed) {
73
+ console.log((0, text_1.bi)(`未找到中转槽位: ${name}`, `Relay slot not found: ${name}`));
74
+ return;
75
+ }
76
+ console.log((0, text_1.bi)(`已删除中转槽位: ${name}`, `Relay slot removed: ${name}`));
77
+ }
78
+ /**
79
+ * 重命名 OpenAI-compatible relay slot。
80
+ *
81
+ * @param oldName 原 slot 名称。
82
+ * @param newName 新 slot 名称。
83
+ * @returns 无返回值。
84
+ * @throws 当旧 slot 不存在、新 slot 已存在或写入失败时抛出异常。
85
+ */
86
+ function handleRelayRename(oldName, newName) {
87
+ const slot = (0, relay_store_1.renameRelaySlot)(oldName, newName);
88
+ console.log((0, text_1.bi)(`已重命名中转槽位: ${oldName} -> ${slot.name}`, `Relay slot renamed: ${oldName} -> ${slot.name}`));
89
+ }
90
+ /**
91
+ * 启用或禁用 OpenAI-compatible relay slot。
92
+ *
93
+ * @param name relay slot 名称。
94
+ * @param enabled 是否启用。
95
+ * @returns 无返回值。
96
+ * @throws 当 slot 不存在或写入失败时抛出异常。
97
+ */
98
+ function handleRelayEnabled(name, enabled) {
99
+ const slot = (0, relay_store_1.setRelaySlotEnabled)(name, enabled);
100
+ console.log(enabled
101
+ ? (0, text_1.bi)(`已启用中转槽位: ${slot.name}`, `Relay slot enabled: ${slot.name}`)
102
+ : (0, text_1.bi)(`已禁用中转槽位: ${slot.name}`, `Relay slot disabled: ${slot.name}`));
103
+ }
104
+ /**
105
+ * 启用 OpenAI-compatible relay slot。
106
+ *
107
+ * @param name relay slot 名称。
108
+ * @returns 无返回值。
109
+ * @throws 当 slot 不存在或写入失败时抛出异常。
110
+ */
111
+ function handleRelayEnable(name) {
112
+ handleRelayEnabled(name, true);
113
+ }
114
+ /**
115
+ * 禁用 OpenAI-compatible relay slot。
116
+ *
117
+ * @param name relay slot 名称。
118
+ * @returns 无返回值。
119
+ * @throws 当 slot 不存在或写入失败时抛出异常。
120
+ */
121
+ function handleRelayDisable(name) {
122
+ handleRelayEnabled(name, false);
123
+ }
124
+ /**
125
+ * 将模型请求固定到指定 relay slot。
126
+ *
127
+ * @param name relay slot 名称。
128
+ * @returns 无返回值。
129
+ * @throws 当 relay slot 不存在或状态写入失败时抛出异常。
130
+ */
131
+ function handleUseRelay(name) {
132
+ const slot = (0, relay_store_1.findRelaySlot)(name);
133
+ if (!slot) {
134
+ throw new Error((0, text_1.bi)(`未找到中转槽位 ${name}`, `Relay slot not found: ${name}`));
135
+ }
136
+ if (!slot.enabled) {
137
+ throw new Error((0, text_1.bi)(`中转槽位 ${name} 已禁用`, `Relay slot is disabled: ${name}`));
138
+ }
139
+ (0, state_1.setSelectedModelRoute)({
140
+ mode: "relay_slot",
141
+ relay_slot_id: slot.id
142
+ });
143
+ console.log((0, text_1.bi)(`模型请求已固定到中转槽位: ${slot.name}`, `Model route fixed to relay slot: ${slot.name}`));
144
+ }
145
+ /**
146
+ * 恢复模型请求到官方账号自动调度池。
147
+ *
148
+ * @returns 无返回值。
149
+ * @throws 当状态写入失败时抛出异常。
150
+ */
151
+ function handleUseAuthPool() {
152
+ (0, state_1.setSelectedModelRoute)({
153
+ mode: "auth_pool"
154
+ });
155
+ console.log((0, text_1.bi)("模型请求已恢复官方账号池。", "Model route restored to auth pool."));
156
+ }
157
+ /**
158
+ * 输出当前模型出口与 Codex App 登录态选择。
159
+ *
160
+ * @returns 无返回值。
161
+ * @throws 当状态读取失败时抛出异常。
162
+ */
163
+ function handleCurrent() {
164
+ console.log(`model_route=${formatModelRoute()}`);
165
+ console.log(`codex_auth=${(0, state_1.getSelectedCodexAuthAccountId)() ?? "none"}`);
166
+ }
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.proxyRelaySlot = void 0;
4
+ exports.buildRelayHeaders = buildRelayHeaders;
5
+ exports.sendRelayRequest = sendRelayRequest;
6
+ exports.createRelayProxyService = createRelayProxyService;
7
+ const undici_1 = require("undici");
8
+ /**
9
+ * 构造 OpenAI-compatible relay 上游请求头。
10
+ *
11
+ * 业务含义:
12
+ * 1. 客户端发给本地 cslot 的 Authorization 不代表 relay 凭据,不能透传。
13
+ * 2. relay 模式只使用 relay slot 自己的 api_key 鉴权,并保留其余兼容请求头。
14
+ *
15
+ * @param requestHeaders 客户端发到本地服务的原始请求头。
16
+ * @param apiKey relay slot 的 API key。
17
+ * @param bodyLength 请求体字节长度;无 body 时不写 content-length。
18
+ * @returns 可发往 relay 上游的请求头。
19
+ * @throws 无显式抛出。
20
+ */
21
+ function buildRelayHeaders(requestHeaders, apiKey, bodyLength) {
22
+ const headers = {};
23
+ for (const [headerName, headerValue] of Object.entries(requestHeaders)) {
24
+ const normalizedName = headerName.toLowerCase();
25
+ if (headerValue == null ||
26
+ normalizedName === "authorization" ||
27
+ normalizedName === "host" ||
28
+ normalizedName === "connection" ||
29
+ normalizedName === "content-length") {
30
+ continue;
31
+ }
32
+ headers[normalizedName] = Array.isArray(headerValue)
33
+ ? headerValue.join(", ")
34
+ : headerValue;
35
+ }
36
+ headers.authorization = `Bearer ${apiKey}`;
37
+ if (typeof bodyLength === "number") {
38
+ headers["content-length"] = String(bodyLength);
39
+ }
40
+ headers["user-agent"] = "codex-slot/0.1.1";
41
+ return headers;
42
+ }
43
+ /**
44
+ * 将本地 `/v1/*` 请求解析成 relay base_url 后的相对路径。
45
+ *
46
+ * @param url 本地代理收到的 URL。
47
+ * @returns relay 上游相对 path 与 query;不属于 `/v1/*` 时返回 `null`。
48
+ * @throws URL 解析失败时透传异常。
49
+ */
50
+ function resolveRelayPath(url) {
51
+ const parsedUrl = new URL(url, "http://127.0.0.1");
52
+ const openAiPrefix = "/v1";
53
+ if (!parsedUrl.pathname.startsWith(`${openAiPrefix}/`)) {
54
+ return null;
55
+ }
56
+ return `${parsedUrl.pathname.slice(openAiPrefix.length)}${parsedUrl.search}`;
57
+ }
58
+ /**
59
+ * 提取上游响应中允许透传给客户端的响应头。
60
+ *
61
+ * @param headers 上游响应头对象。
62
+ * @returns 可透传响应头。
63
+ * @throws 无显式抛出。
64
+ */
65
+ function pickResponseHeaders(headers) {
66
+ const picked = {};
67
+ const contentType = headers["content-type"];
68
+ const cacheControl = headers["cache-control"];
69
+ if (typeof contentType === "string") {
70
+ picked["content-type"] = contentType;
71
+ }
72
+ if (typeof cacheControl === "string") {
73
+ picked["cache-control"] = cacheControl;
74
+ }
75
+ return picked;
76
+ }
77
+ function buildSendResult(statusCode, payload, headers) {
78
+ return {
79
+ type: "send",
80
+ statusCode,
81
+ payload,
82
+ headers
83
+ };
84
+ }
85
+ /**
86
+ * 向 OpenAI-compatible relay 上游发送一次请求。
87
+ *
88
+ * @param options relay 请求参数。
89
+ * @returns undici 上游响应对象。
90
+ * @throws 当网络层或 undici 请求失败时透传底层异常。
91
+ */
92
+ async function sendRelayRequest(options) {
93
+ const baseUrl = options.baseUrl.replace(/\/+$/, "");
94
+ const pathWithQuery = options.pathWithQuery.startsWith("/")
95
+ ? options.pathWithQuery
96
+ : `/${options.pathWithQuery}`;
97
+ return await (0, undici_1.request)(`${baseUrl}${pathWithQuery}`, {
98
+ method: options.method,
99
+ headers: options.headers,
100
+ body: options.body && options.body.length > 0 ? options.body : undefined
101
+ });
102
+ }
103
+ /**
104
+ * 创建 relay 模型代理服务。
105
+ *
106
+ * 业务含义:
107
+ * 1. relay slot 是手动固定的模型出口,不参与官方账号自动调度。
108
+ * 2. relay 上游返回 401/429/5xx 时原样返回给客户端,不回退到官方账号。
109
+ *
110
+ * @param overrides 可选依赖覆盖项。
111
+ * @returns relay 代理服务实例。
112
+ * @throws 无显式抛出。
113
+ */
114
+ function createRelayProxyService(overrides) {
115
+ const dependencies = {
116
+ sendRelayRequest,
117
+ ...overrides
118
+ };
119
+ return {
120
+ async proxyRelaySlot(options) {
121
+ const pathWithQuery = resolveRelayPath(options.request.url);
122
+ if (!pathWithQuery) {
123
+ return buildSendResult(404, {
124
+ error: {
125
+ message: "Unsupported relay proxy path",
126
+ type: "unsupported_relay_proxy_path"
127
+ }
128
+ });
129
+ }
130
+ const bodyLength = options.request.body && options.request.body.length > 0
131
+ ? options.request.body.length
132
+ : undefined;
133
+ let upstream;
134
+ try {
135
+ upstream = await dependencies.sendRelayRequest({
136
+ baseUrl: options.slot.base_url,
137
+ method: options.request.method.toUpperCase(),
138
+ pathWithQuery,
139
+ headers: buildRelayHeaders(options.request.headers, options.slot.api_key, bodyLength),
140
+ body: options.request.body
141
+ });
142
+ }
143
+ catch (error) {
144
+ return buildSendResult(502, {
145
+ error: {
146
+ message: error instanceof Error ? error.message : String(error),
147
+ type: "relay_request_failed"
148
+ }
149
+ });
150
+ }
151
+ return {
152
+ type: "proxy",
153
+ statusCode: upstream.statusCode,
154
+ headers: pickResponseHeaders(upstream.headers),
155
+ body: upstream.body
156
+ };
157
+ }
158
+ };
159
+ }
160
+ exports.proxyRelaySlot = createRelayProxyService().proxyRelaySlot;
@@ -0,0 +1,138 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.listRelaySlots = listRelaySlots;
4
+ exports.findRelaySlot = findRelaySlot;
5
+ exports.addRelaySlot = addRelaySlot;
6
+ exports.removeRelaySlot = removeRelaySlot;
7
+ exports.renameRelaySlot = renameRelaySlot;
8
+ exports.setRelaySlotEnabled = setRelaySlotEnabled;
9
+ const config_1 = require("./config");
10
+ const state_1 = require("./state");
11
+ const text_1 = require("./text");
12
+ /**
13
+ * 列出所有 OpenAI-compatible relay 槽位。
14
+ *
15
+ * @returns 当前配置中的 relay slot 列表。
16
+ * @throws 当配置读取失败时抛出异常。
17
+ */
18
+ function listRelaySlots() {
19
+ return (0, config_1.loadConfig)().relay_slots;
20
+ }
21
+ /**
22
+ * 查找指定 relay slot。
23
+ *
24
+ * @param slotId relay slot 标识。
25
+ * @returns 命中时返回 slot;不存在时返回 `null`。
26
+ * @throws 当配置读取失败时抛出异常。
27
+ */
28
+ function findRelaySlot(slotId) {
29
+ return listRelaySlots().find((item) => item.id === slotId) ?? null;
30
+ }
31
+ /**
32
+ * 新增一个 OpenAI-compatible relay slot。
33
+ *
34
+ * @param input relay slot 配置。
35
+ * @returns 新增后的 relay slot。
36
+ * @throws 当同名 slot 已存在或配置写入失败时抛出异常。
37
+ */
38
+ function addRelaySlot(input) {
39
+ const config = (0, config_1.loadConfig)();
40
+ if (config.relay_slots.some((item) => item.id === input.name)) {
41
+ throw new Error((0, text_1.bi)(`中转槽位 ${input.name} 已存在`, `Relay slot already exists: ${input.name}`));
42
+ }
43
+ try {
44
+ new URL(input.baseUrl);
45
+ }
46
+ catch {
47
+ throw new Error((0, text_1.bi)(`中转 base_url 非法: ${input.baseUrl}`, `Invalid relay base_url: ${input.baseUrl}`));
48
+ }
49
+ const slot = {
50
+ id: input.name,
51
+ name: input.name,
52
+ base_url: input.baseUrl,
53
+ api_key: input.apiKey,
54
+ enabled: true,
55
+ imported_at: new Date().toISOString()
56
+ };
57
+ config.relay_slots.push(slot);
58
+ (0, config_1.saveConfig)(config);
59
+ return slot;
60
+ }
61
+ /**
62
+ * 删除指定 relay slot,并在当前模型出口指向它时恢复官方账号池。
63
+ *
64
+ * @param slotId relay slot 标识。
65
+ * @returns 被删除的 slot;不存在时返回 `null`。
66
+ * @throws 当配置或状态写入失败时抛出异常。
67
+ */
68
+ function removeRelaySlot(slotId) {
69
+ const config = (0, config_1.loadConfig)();
70
+ const index = config.relay_slots.findIndex((item) => item.id === slotId);
71
+ if (index < 0) {
72
+ return null;
73
+ }
74
+ const [removed] = config.relay_slots.splice(index, 1);
75
+ (0, config_1.saveConfig)(config);
76
+ const route = (0, state_1.getSelectedModelRoute)();
77
+ if (route.mode === "relay_slot" && route.relay_slot_id === slotId) {
78
+ (0, state_1.setSelectedModelRoute)({ mode: "auth_pool" });
79
+ }
80
+ return removed;
81
+ }
82
+ /**
83
+ * 重命名 relay slot,并迁移当前模型出口选择。
84
+ *
85
+ * @param oldName 原 slot 标识。
86
+ * @param newName 新 slot 标识。
87
+ * @returns 重命名后的 relay slot。
88
+ * @throws 当旧 slot 不存在、新 slot 已存在或写入失败时抛出异常。
89
+ */
90
+ function renameRelaySlot(oldName, newName) {
91
+ const config = (0, config_1.loadConfig)();
92
+ const index = config.relay_slots.findIndex((item) => item.id === oldName);
93
+ if (index < 0) {
94
+ throw new Error((0, text_1.bi)(`未找到中转槽位 ${oldName}`, `Relay slot not found: ${oldName}`));
95
+ }
96
+ if (config.relay_slots.some((item) => item.id === newName)) {
97
+ throw new Error((0, text_1.bi)(`中转槽位 ${newName} 已存在`, `Relay slot already exists: ${newName}`));
98
+ }
99
+ const renamed = {
100
+ ...config.relay_slots[index],
101
+ id: newName,
102
+ name: newName
103
+ };
104
+ config.relay_slots[index] = renamed;
105
+ (0, config_1.saveConfig)(config);
106
+ (0, state_1.updateState)((state) => {
107
+ if (state.selected_model_route?.mode === "relay_slot" &&
108
+ state.selected_model_route.relay_slot_id === oldName) {
109
+ state.selected_model_route = {
110
+ mode: "relay_slot",
111
+ relay_slot_id: newName
112
+ };
113
+ }
114
+ });
115
+ return renamed;
116
+ }
117
+ /**
118
+ * 更新 relay slot 启用状态。
119
+ *
120
+ * @param slotId relay slot 标识。
121
+ * @param enabled 是否启用。
122
+ * @returns 更新后的 relay slot。
123
+ * @throws 当 slot 不存在或配置写入失败时抛出异常。
124
+ */
125
+ function setRelaySlotEnabled(slotId, enabled) {
126
+ const config = (0, config_1.loadConfig)();
127
+ const index = config.relay_slots.findIndex((item) => item.id === slotId);
128
+ if (index < 0) {
129
+ throw new Error((0, text_1.bi)(`未找到中转槽位 ${slotId}`, `Relay slot not found: ${slotId}`));
130
+ }
131
+ const updated = {
132
+ ...config.relay_slots[index],
133
+ enabled
134
+ };
135
+ config.relay_slots[index] = updated;
136
+ (0, config_1.saveConfig)(config);
137
+ return updated;
138
+ }
package/dist/server.js CHANGED
@@ -7,7 +7,7 @@ exports.startServer = startServer;
7
7
  const fastify_1 = __importDefault(require("fastify"));
8
8
  const config_1 = require("./config");
9
9
  const backend_proxy_service_1 = require("./backend-proxy-service");
10
- const proxy_retry_service_1 = require("./proxy-retry-service");
10
+ const model_proxy_dispatcher_1 = require("./model-proxy-dispatcher");
11
11
  const status_1 = require("./status");
12
12
  const scheduler_1 = require("./scheduler");
13
13
  const usage_sync_1 = require("./usage-sync");
@@ -91,7 +91,7 @@ async function startServer(port) {
91
91
  });
92
92
  const codexProxyHandler = async (request, reply) => {
93
93
  const requestBody = await readRawRequestBody(request.body);
94
- const result = await (0, proxy_retry_service_1.proxyCodexWithRetry)({
94
+ const result = await (0, model_proxy_dispatcher_1.proxyModelWithRoute)({
95
95
  method: request.method,
96
96
  url: request.url,
97
97
  headers: request.headers,
package/dist/state.js CHANGED
@@ -17,6 +17,8 @@ exports.clearUsageRefreshError = clearUsageRefreshError;
17
17
  exports.getUsageRefreshError = getUsageRefreshError;
18
18
  exports.getSelectedCodexAuthAccountId = getSelectedCodexAuthAccountId;
19
19
  exports.setSelectedCodexAuthAccountId = setSelectedCodexAuthAccountId;
20
+ exports.getSelectedModelRoute = getSelectedModelRoute;
21
+ exports.setSelectedModelRoute = setSelectedModelRoute;
20
22
  exports.getManagedCodexConfigState = getManagedCodexConfigState;
21
23
  exports.getManagedCodexAuthState = getManagedCodexAuthState;
22
24
  exports.setManagedCodexConfigState = setManagedCodexConfigState;
@@ -44,6 +46,9 @@ function createDefaultState() {
44
46
  return {
45
47
  state_version: STATE_SCHEMA_VERSION,
46
48
  selected_codex_auth_account_id: null,
49
+ selected_model_route: {
50
+ mode: "auth_pool"
51
+ },
47
52
  account_blocks: {},
48
53
  usage_cache: {},
49
54
  usage_refresh_errors: {},
@@ -64,6 +69,7 @@ function normalizeState(parsed) {
64
69
  return {
65
70
  state_version: STATE_SCHEMA_VERSION,
66
71
  selected_codex_auth_account_id: parsed?.selected_codex_auth_account_id ?? defaults.selected_codex_auth_account_id,
72
+ selected_model_route: normalizeModelRouteSelection(parsed?.selected_model_route ?? defaults.selected_model_route),
67
73
  account_blocks: parsed?.account_blocks ?? defaults.account_blocks,
68
74
  usage_cache: parsed?.usage_cache ?? defaults.usage_cache,
69
75
  usage_refresh_errors: parsed?.usage_refresh_errors ?? defaults.usage_refresh_errors,
@@ -72,6 +78,24 @@ function normalizeState(parsed) {
72
78
  managed_codex_config: parsed?.managed_codex_config ?? defaults.managed_codex_config
73
79
  };
74
80
  }
81
+ /**
82
+ * 归一化当前模型出口选择,避免历史 state 或手工编辑内容让代理入口进入未知模式。
83
+ *
84
+ * @param value state 文件中记录的模型出口选择。
85
+ * @returns 可安全使用的模型出口选择;非法内容回退到官方账号池。
86
+ * @throws 无显式抛出。
87
+ */
88
+ function normalizeModelRouteSelection(value) {
89
+ if (value?.mode === "relay_slot" && value.relay_slot_id) {
90
+ return {
91
+ mode: "relay_slot",
92
+ relay_slot_id: value.relay_slot_id
93
+ };
94
+ }
95
+ return {
96
+ mode: "auth_pool"
97
+ };
98
+ }
75
99
  /**
76
100
  * 读取 cslot 的本地运行状态;文件不存在时返回默认空状态。
77
101
  *
@@ -261,6 +285,32 @@ function setSelectedCodexAuthAccountId(accountId) {
261
285
  state.selected_codex_auth_account_id = accountId;
262
286
  });
263
287
  }
288
+ /**
289
+ * 读取当前模型请求出口选择。
290
+ *
291
+ * 业务含义:
292
+ * 1. `auth_pool` 表示 `/v1/*` 继续走官方账号自动调度。
293
+ * 2. `relay_slot` 表示 `/v1/*` 固定走指定 OpenAI-compatible 中转槽位。
294
+ * 3. 该选择不影响 Codex App 主登录态与 `/backend-api/*` 插件链路。
295
+ *
296
+ * @returns 当前模型出口选择。
297
+ * @throws 当 state 文件读取或解析失败时透传底层异常。
298
+ */
299
+ function getSelectedModelRoute() {
300
+ return normalizeModelRouteSelection(loadState().selected_model_route);
301
+ }
302
+ /**
303
+ * 保存当前模型请求出口选择。
304
+ *
305
+ * @param selection 模型出口选择;`null` 表示恢复官方账号池。
306
+ * @returns 无返回值。
307
+ * @throws 当 state 文件写入失败时透传底层异常。
308
+ */
309
+ function setSelectedModelRoute(selection) {
310
+ updateState((state) => {
311
+ state.selected_model_route = normalizeModelRouteSelection(selection);
312
+ });
313
+ }
264
314
  /**
265
315
  * 读取当前记录的 Codex `config.toml` 接管快照。
266
316
  *
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.renderInteractiveHelpLines = renderInteractiveHelpLines;
6
7
  exports.handleStatus = handleStatus;
7
8
  const node_readline_1 = __importDefault(require("node:readline"));
8
9
  const account_store_1 = require("./account-store");
@@ -94,6 +95,32 @@ function renderSummaryLine(summary, narrowScreen, styled) {
94
95
  }
95
96
  return `available=${available} 5h_limited=${fiveHourLimited} weekly_limited=${weeklyLimited}`;
96
97
  }
98
+ /**
99
+ * 渲染交互状态面板的快捷键说明,并确保每一行都能放进右侧详情栏。
100
+ *
101
+ * 业务含义:
102
+ * 1. 状态页在宽终端使用双栏布局,右栏宽度可能仍然较窄。
103
+ * 2. 快捷键说明必须逐行展示,避免单条长 help 在终端自动换行后打乱面板。
104
+ *
105
+ * @param maxWidth 右侧详情栏可用显示宽度;小于等于 0 时返回空列表。
106
+ * @returns 逐行渲染后的快捷键说明。
107
+ * @throws 无显式抛出。
108
+ */
109
+ function renderInteractiveHelpLines(maxWidth) {
110
+ if (maxWidth <= 0) {
111
+ return [];
112
+ }
113
+ const lines = [
114
+ "↑/↓ move/select",
115
+ "Space toggle enabled",
116
+ "a app auth",
117
+ "m model route",
118
+ "c clear app auth",
119
+ "r refresh usage",
120
+ "Enter/q exit"
121
+ ];
122
+ return lines.map((line) => truncateVisible(line, maxWidth));
123
+ }
97
124
  /**
98
125
  * 移除 ANSI 控制序列,避免布局计算把颜色码当成可见字符。
99
126
  *
@@ -150,6 +177,37 @@ function getDisplayWidth(value) {
150
177
  function padVisible(value, width) {
151
178
  return `${value}${" ".repeat(Math.max(0, width - getDisplayWidth(value)))}`;
152
179
  }
180
+ /**
181
+ * 按显示宽度截断文本,保留省略号提示内容被压缩。
182
+ *
183
+ * @param value 原始文本。
184
+ * @param maxWidth 最大显示列宽。
185
+ * @returns 截断后的文本。
186
+ * @throws 无显式抛出。
187
+ */
188
+ function truncateVisible(value, maxWidth) {
189
+ if (maxWidth <= 0) {
190
+ return "";
191
+ }
192
+ if (getDisplayWidth(value) <= maxWidth) {
193
+ return value;
194
+ }
195
+ if (maxWidth <= 2) {
196
+ return value.slice(0, maxWidth);
197
+ }
198
+ let output = "";
199
+ let width = 0;
200
+ const ellipsisWidth = getDisplayWidth("…");
201
+ for (const char of value) {
202
+ const charWidth = getDisplayWidth(char);
203
+ if (width + charWidth + ellipsisWidth > maxWidth) {
204
+ break;
205
+ }
206
+ output += char;
207
+ width += charWidth;
208
+ }
209
+ return `${output}…`;
210
+ }
153
211
  /**
154
212
  * 将左右两组文本行渲染为双栏布局。
155
213
  *
@@ -245,6 +303,12 @@ function resolveInitialCursorIndex(accounts, statuses, selectedAuthAccountId) {
245
303
  }
246
304
  return 0;
247
305
  }
306
+ function buildInteractiveItems(accounts, relaySlots) {
307
+ return [
308
+ ...accounts.map((account) => ({ type: "account", id: account.id })),
309
+ ...relaySlots.map((slot) => ({ type: "relay", id: slot.id }))
310
+ ];
311
+ }
248
312
  /**
249
313
  * 将状态面板中选中的账号立即应用为 Codex App 主登录态。
250
314
  *
@@ -286,15 +350,27 @@ async function handleInteractiveToggle(initialStatuses) {
286
350
  node_readline_1.default.emitKeypressEvents(stdin);
287
351
  stdin.setRawMode?.(true);
288
352
  const accountsFromConfig = (0, account_service_1.listAccounts)();
289
- if (accountsFromConfig.length === 0) {
290
- console.log((0, text_1.bi)("当前没有已录入账号。", "No managed accounts found."));
353
+ const latestSnapshotForRelays = (0, status_service_1.getStatusSnapshot)();
354
+ const relaySlotsFromConfig = latestSnapshotForRelays.relaySlots;
355
+ if (accountsFromConfig.length === 0 && relaySlotsFromConfig.length === 0) {
356
+ console.log((0, text_1.bi)("当前没有已录入账号或中转槽位。", "No managed accounts or relay slots found."));
291
357
  stdin.setRawMode?.(false);
292
358
  return;
293
359
  }
294
360
  const accounts = [...accountsFromConfig].sort((left, right) => left.name.localeCompare(right.name));
361
+ const relaySlots = [...relaySlotsFromConfig].sort((left, right) => left.name.localeCompare(right.name));
295
362
  let selectedAuthAccountId = (0, state_1.getSelectedCodexAuthAccountId)();
296
- let cursor = resolveInitialCursorIndex(accounts, initialStatuses ?? (0, status_1.collectAccountStatuses)(), selectedAuthAccountId);
297
- let changed = false;
363
+ const initialItems = buildInteractiveItems(accounts, relaySlots);
364
+ const selectedModelRoute = (0, state_1.getSelectedModelRoute)();
365
+ const initialAccountCursor = resolveInitialCursorIndex(accounts, initialStatuses ?? (0, status_1.collectAccountStatuses)(), selectedAuthAccountId);
366
+ const selectedRelayIndex = selectedModelRoute.mode === "relay_slot"
367
+ ? relaySlots.findIndex((slot) => slot.id === selectedModelRoute.relay_slot_id)
368
+ : -1;
369
+ let cursor = selectedRelayIndex >= 0
370
+ ? accounts.length + selectedRelayIndex
371
+ : Math.min(initialAccountCursor, Math.max(0, initialItems.length - 1));
372
+ let accountChanged = false;
373
+ let relayChanged = false;
298
374
  enterInteractiveScreen();
299
375
  return await new Promise((resolve) => {
300
376
  let closed = false;
@@ -304,7 +380,9 @@ async function handleInteractiveToggle(initialStatuses) {
304
380
  const screenWidth = process.stdout.columns ?? 80;
305
381
  const styled = shouldUseAnsiStyle();
306
382
  const latestSnapshot = (0, status_service_1.getStatusSnapshot)();
307
- const statusSource = changed ? latestSnapshot.statuses : (initialStatuses ?? latestSnapshot.statuses);
383
+ const items = buildInteractiveItems(accounts, relaySlots);
384
+ const currentSelection = items[cursor] ?? null;
385
+ const statusSource = accountChanged ? latestSnapshot.statuses : (initialStatuses ?? latestSnapshot.statuses);
308
386
  const statusById = new Map(statusSource.map((item) => [item.id, item]));
309
387
  const autoSelectedId = (0, scheduler_1.pickBestAccount)()?.account.id ?? null;
310
388
  const summary = (0, status_1.summarizeAccountStatuses)(statusSource);
@@ -321,7 +399,20 @@ async function handleInteractiveToggle(initialStatuses) {
321
399
  };
322
400
  })
323
401
  .filter((item) => item !== null);
324
- const currentItem = displayStatuses.find((item) => item.id === accounts[cursor]?.id) ?? null;
402
+ const displayRelays = relaySlots.map((slot) => {
403
+ const selected = latestSnapshot.modelRoute.mode === "relay_slot" &&
404
+ latestSnapshot.modelRoute.relay_slot_id === slot.id;
405
+ return {
406
+ ...slot,
407
+ name: selected ? `${slot.name}*` : slot.name
408
+ };
409
+ });
410
+ const currentAccount = currentSelection?.type === "account"
411
+ ? displayStatuses.find((item) => item.id === currentSelection.id) ?? null
412
+ : null;
413
+ const currentRelay = currentSelection?.type === "relay"
414
+ ? displayRelays.find((item) => item.id === currentSelection.id) ?? null
415
+ : null;
325
416
  const wideLayout = screenWidth >= 104;
326
417
  const leftWidth = wideLayout ? Math.max(68, Math.floor(screenWidth * 0.64)) : screenWidth;
327
418
  const rightWidth = wideLayout ? Math.max(28, screenWidth - leftWidth - 3) : screenWidth;
@@ -333,41 +424,71 @@ async function handleInteractiveToggle(initialStatuses) {
333
424
  styled,
334
425
  selectorColumn: {
335
426
  enabledById: Object.fromEntries(accounts.map((account) => [account.id, account.enabled])),
336
- cursorAccountId: accounts[cursor]?.id ?? null
427
+ cursorAccountId: currentSelection?.type === "account" ? currentSelection.id : null
337
428
  }
338
429
  }).split("\n")
339
430
  ];
431
+ const relayLines = [
432
+ renderSectionHeader("relays", leftWidth, styled),
433
+ ...(displayRelays.length > 0
434
+ ? (0, status_1.renderRelayStatusTable)(displayRelays, {
435
+ compact: true,
436
+ maxWidth: leftWidth,
437
+ styled,
438
+ selectorColumn: {
439
+ enabledById: Object.fromEntries(relaySlots.map((slot) => [slot.id, slot.enabled])),
440
+ cursorRelayId: currentSelection?.type === "relay" ? currentSelection.id : null
441
+ }
442
+ }).split("\n")
443
+ : ["-"])
444
+ ];
445
+ const currentDetails = currentSelection?.type === "relay"
446
+ ? (0, status_1.renderRelayStatusDetails)(currentRelay, { maxWidth: rightWidth, header: false }).split("\n")
447
+ : (0, status_1.renderStatusDetails)(currentAccount, { maxWidth: rightWidth, header: false }).split("\n");
340
448
  const sideLines = [
341
449
  renderSectionHeader("current", rightWidth, styled),
342
- ...(0, status_1.renderStatusDetails)(currentItem, { maxWidth: rightWidth, header: false }).split("\n"),
450
+ ...currentDetails,
343
451
  "",
344
452
  renderSectionHeader("summary", rightWidth, styled),
345
453
  renderSummaryLine(summary, rightWidth < 42, styled),
454
+ `model_route=${latestSnapshot.modelRouteLabel}`,
346
455
  `scheduler=${latestSnapshot.selectedName ?? "none"}`,
347
456
  `codex_auth=${selectedAuthAccountId ?? "none"}`,
457
+ `relay_slots=${latestSnapshot.relaySlots.length}`,
348
458
  ...(refreshStatusText ? [`refresh=${refreshStatusText}`] : []),
349
459
  "",
350
460
  renderSectionHeader("help", rightWidth, styled),
351
- "↑/↓ move Space toggle a app-auth c clear r refresh Enter/q exit"
461
+ ...renderInteractiveHelpLines(rightWidth)
462
+ ];
463
+ const leftLines = [
464
+ ...accountLines,
465
+ "",
466
+ ...relayLines
352
467
  ];
353
468
  if (wideLayout) {
354
- renderInteractiveScreen(renderColumns(accountLines, sideLines, 3));
469
+ renderInteractiveScreen(renderColumns(leftLines, sideLines, 3));
355
470
  return;
356
471
  }
357
472
  renderInteractiveScreen([
358
- ...accountLines,
473
+ ...leftLines,
359
474
  "",
360
475
  renderDivider(screenWidth, styled),
361
476
  ...sideLines
362
477
  ]);
363
478
  };
364
479
  const applyChanges = () => {
365
- if (!changed) {
480
+ if (!accountChanged && !relayChanged) {
366
481
  return;
367
482
  }
368
- (0, status_service_1.persistAccountEnabledState)(accounts);
369
- changed = false;
370
- initialStatuses = (0, status_1.collectAccountStatuses)();
483
+ if (accountChanged) {
484
+ (0, status_service_1.persistAccountEnabledState)(accounts);
485
+ accountChanged = false;
486
+ initialStatuses = (0, status_1.collectAccountStatuses)();
487
+ }
488
+ if (relayChanged) {
489
+ (0, status_service_1.persistRelayEnabledState)(relaySlots);
490
+ relayChanged = false;
491
+ }
371
492
  };
372
493
  const exitInteractive = () => {
373
494
  if (closed) {
@@ -392,7 +513,7 @@ async function handleInteractiveToggle(initialStatuses) {
392
513
  return;
393
514
  }
394
515
  if (key.name === "down") {
395
- const nextCursor = Math.min(accounts.length - 1, cursor + 1);
516
+ const nextCursor = Math.min(buildInteractiveItems(accounts, relaySlots).length - 1, cursor + 1);
396
517
  if (nextCursor !== cursor) {
397
518
  cursor = nextCursor;
398
519
  render();
@@ -400,14 +521,33 @@ async function handleInteractiveToggle(initialStatuses) {
400
521
  return;
401
522
  }
402
523
  if (key.name === "space") {
403
- accounts[cursor].enabled = !accounts[cursor].enabled;
404
- changed = true;
524
+ const item = buildInteractiveItems(accounts, relaySlots)[cursor];
525
+ if (item?.type === "account") {
526
+ const account = accounts.find((candidate) => candidate.id === item.id);
527
+ if (account) {
528
+ account.enabled = !account.enabled;
529
+ accountChanged = true;
530
+ }
531
+ }
532
+ else if (item?.type === "relay") {
533
+ const slot = relaySlots.find((candidate) => candidate.id === item.id);
534
+ if (slot) {
535
+ slot.enabled = !slot.enabled;
536
+ relayChanged = true;
537
+ }
538
+ }
405
539
  applyChanges();
406
540
  render();
407
541
  return;
408
542
  }
409
543
  if (key.name === "a") {
410
- const account = accounts[cursor];
544
+ const item = buildInteractiveItems(accounts, relaySlots)[cursor];
545
+ if (item?.type !== "account") {
546
+ refreshStatusText = "app-auth requires account";
547
+ render();
548
+ return;
549
+ }
550
+ const account = accounts.find((candidate) => candidate.id === item.id);
411
551
  if (!account) {
412
552
  return;
413
553
  }
@@ -422,6 +562,33 @@ async function handleInteractiveToggle(initialStatuses) {
422
562
  render();
423
563
  return;
424
564
  }
565
+ if (key.name === "m") {
566
+ const item = buildInteractiveItems(accounts, relaySlots)[cursor];
567
+ if (item?.type === "relay") {
568
+ const slot = relaySlots.find((candidate) => candidate.id === item.id);
569
+ if (!slot) {
570
+ return;
571
+ }
572
+ if (!slot.enabled) {
573
+ refreshStatusText = `relay_disabled=${slot.id}`;
574
+ render();
575
+ return;
576
+ }
577
+ (0, state_1.setSelectedModelRoute)({
578
+ mode: "relay_slot",
579
+ relay_slot_id: slot.id
580
+ });
581
+ refreshStatusText = `model_route=relay:${slot.id}`;
582
+ render();
583
+ return;
584
+ }
585
+ (0, state_1.setSelectedModelRoute)({
586
+ mode: "auth_pool"
587
+ });
588
+ refreshStatusText = "model_route=auth_pool";
589
+ render();
590
+ return;
591
+ }
425
592
  if (key.name === "c") {
426
593
  selectedAuthAccountId = null;
427
594
  (0, state_1.setSelectedCodexAuthAccountId)(null);
@@ -485,8 +652,21 @@ async function handleStatus(options) {
485
652
  name: `${item.name}${item.id === (0, scheduler_1.pickBestAccount)()?.account.id ? "*" : ""}${item.id === snapshot.codexAuthAccountId ? "@" : ""}`
486
653
  }));
487
654
  console.log((0, status_1.renderStatusTable)(displayStatuses));
655
+ if (snapshot.relaySlots.length > 0) {
656
+ const displayRelays = snapshot.relaySlots.map((slot) => ({
657
+ ...slot,
658
+ name: snapshot.modelRoute.mode === "relay_slot" &&
659
+ snapshot.modelRoute.relay_slot_id === slot.id
660
+ ? `${slot.name}*`
661
+ : slot.name
662
+ }));
663
+ console.log("");
664
+ console.log((0, status_1.renderRelayStatusTable)(displayRelays));
665
+ }
488
666
  console.log("");
489
667
  console.log(`available=${snapshot.summary.available} 5h_limited=${snapshot.summary.fiveHourLimited} weekly_limited=${snapshot.summary.weeklyLimited}`);
668
+ console.log(`model_route=${snapshot.modelRouteLabel}`);
490
669
  console.log(`scheduler=${snapshot.selectedName ?? "none"}`);
491
670
  console.log(`codex_auth=${snapshot.codexAuthAccountId ?? "none"}`);
671
+ console.log(`relay_slots=${snapshot.relaySlots.length}`);
492
672
  }
package/dist/status.js CHANGED
@@ -4,6 +4,8 @@ exports.collectAccountStatuses = collectAccountStatuses;
4
4
  exports.summarizeAccountStatuses = summarizeAccountStatuses;
5
5
  exports.renderStatusTable = renderStatusTable;
6
6
  exports.renderStatusDetails = renderStatusDetails;
7
+ exports.renderRelayStatusTable = renderRelayStatusTable;
8
+ exports.renderRelayStatusDetails = renderRelayStatusDetails;
7
9
  const config_1 = require("./config");
8
10
  const account_store_1 = require("./account-store");
9
11
  const state_1 = require("./state");
@@ -554,3 +556,73 @@ function renderStatusDetails(item, options) {
554
556
  }
555
557
  return lines.join("\n");
556
558
  }
559
+ /**
560
+ * 将 relay slot 状态渲染为适合终端输出的表格文本。
561
+ *
562
+ * @param slots 待展示的 relay slot 列表。
563
+ * @param options 渲染选项;交互模式下可传入选择列配置。
564
+ * @returns 可直接打印到终端的表格字符串。
565
+ * @throws 无显式抛出。
566
+ */
567
+ function renderRelayStatusTable(slots, options) {
568
+ const selectorColumn = options?.selectorColumn;
569
+ const compact = options?.compact ?? false;
570
+ const maxWidth = options?.maxWidth ?? Number.POSITIVE_INFINITY;
571
+ const compactHeader = maxWidth < 68;
572
+ const relayHeader = compactHeader ? "ID" : "RELAY";
573
+ const statusHeader = compactHeader ? "ST" : "STATUS";
574
+ const relayWidth = Math.max(getDisplayWidth(relayHeader), ...slots.map((item) => getDisplayWidth(item.name)));
575
+ const statusWidth = compactHeader ? 8 : 10;
576
+ const fixedWidth = (selectorColumn ? 4 + 2 : 0) + relayWidth + 2 + statusWidth + 2;
577
+ const baseUrlWidth = Number.isFinite(maxWidth)
578
+ ? Math.max(12, Math.min(72, Math.floor(maxWidth) - fixedWidth))
579
+ : Math.max(getDisplayWidth("BASE_URL"), ...slots.map((item) => getDisplayWidth(item.base_url)));
580
+ const rows = [
581
+ [
582
+ ...(selectorColumn ? [" "] : []),
583
+ relayHeader,
584
+ statusHeader,
585
+ "BASE_URL"
586
+ ]
587
+ ];
588
+ for (const slot of slots) {
589
+ const selectorCell = selectorColumn
590
+ ? `${selectorColumn.cursorRelayId === slot.id ? ">" : " "}[${selectorColumn.enabledById[slot.id] ? "x" : " "}]`
591
+ : null;
592
+ const status = slot.enabled ? "enabled" : "disabled";
593
+ rows.push([
594
+ ...(selectorCell ? [selectorCell] : []),
595
+ styleNameCell(truncateCell(slot.name, relayWidth), options?.styled ?? false),
596
+ truncateCell(status, statusWidth),
597
+ truncateCell(slot.base_url, baseUrlWidth)
598
+ ]);
599
+ }
600
+ const widths = rows[0].map((_, columnIndex) => Math.max(...rows.map((row) => getDisplayWidth(row[columnIndex]))));
601
+ return rows
602
+ .map((row) => row.map((cell, index) => padCell(cell, widths[index])).join(" "))
603
+ .join("\n");
604
+ }
605
+ /**
606
+ * 将当前选中 relay slot 渲染为紧凑详情区。
607
+ *
608
+ * @param slot 当前选中的 relay slot;为空时返回占位提示。
609
+ * @param options 详情区渲染选项。
610
+ * @returns 适合直接打印的详情区文本。
611
+ * @throws 无显式抛出。
612
+ */
613
+ function renderRelayStatusDetails(slot, options) {
614
+ const includeHeader = options?.header ?? true;
615
+ if (!slot) {
616
+ return [includeHeader ? "[ relay ]" : "slot -", includeHeader ? "slot -" : ""]
617
+ .filter((line) => line.length > 0)
618
+ .join("\n");
619
+ }
620
+ const maxWidth = options?.maxWidth ?? Number.POSITIVE_INFINITY;
621
+ return [
622
+ ...(includeHeader ? ["[ relay ]"] : []),
623
+ formatDetailLine("slot", slot.name, maxWidth),
624
+ formatDetailLine("status", slot.enabled ? "enabled" : "disabled", maxWidth),
625
+ formatDetailLine("base", slot.base_url, maxWidth),
626
+ formatDetailLine("key", "********", maxWidth)
627
+ ].join("\n");
628
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-slot",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "本地 Codex 多账号切换与状态管理工具",
5
5
  "type": "commonjs",
6
6
  "main": "dist/cli.js",