codex-slot 0.1.28 → 0.1.29

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,6 +5,7 @@ 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");
@@ -71,6 +72,66 @@ function registerAccountCommands(program) {
71
72
  .argument("<newName>", (0, text_1.bi)("新账号或工作空间标识", "New managed slot name"))
72
73
  .action(account_commands_1.handleAccountRename);
73
74
  }
75
+ /**
76
+ * 注册 relay 与模型出口选择相关子命令。
77
+ *
78
+ * @param program Commander 程序实例。
79
+ * @returns 无返回值。
80
+ * @throws 无显式抛出。
81
+ */
82
+ function registerRelayCommands(program) {
83
+ const relay = program
84
+ .command("relay")
85
+ .description((0, text_1.bi)("管理 OpenAI-compatible 中转槽位", "Manage OpenAI-compatible relay slots"));
86
+ relay
87
+ .command("add")
88
+ .description((0, text_1.bi)("新增一个中转槽位", "Add a relay slot"))
89
+ .argument("<name>", (0, text_1.bi)("中转槽位名", "Relay slot name"))
90
+ .requiredOption("--base-url <url>", (0, text_1.bi)("OpenAI-compatible base_url,通常以 /v1 结尾", "OpenAI-compatible base_url, usually ending with /v1"))
91
+ .requiredOption("--api-key <key>", (0, text_1.bi)("中转 API key", "Relay API key"))
92
+ .action(relay_commands_1.handleRelayAdd);
93
+ relay
94
+ .command("list")
95
+ .description((0, text_1.bi)("列出中转槽位", "List relay slots"))
96
+ .action(relay_commands_1.handleRelayList);
97
+ relay
98
+ .command("del")
99
+ .description((0, text_1.bi)("删除一个中转槽位", "Remove a relay slot"))
100
+ .argument("<name>", (0, text_1.bi)("中转槽位名", "Relay slot name"))
101
+ .action(relay_commands_1.handleRelayRemove);
102
+ relay
103
+ .command("rename")
104
+ .description((0, text_1.bi)("重命名一个中转槽位", "Rename a relay slot"))
105
+ .argument("<oldName>", (0, text_1.bi)("原中转槽位名", "Old relay slot name"))
106
+ .argument("<newName>", (0, text_1.bi)("新中转槽位名", "New relay slot name"))
107
+ .action(relay_commands_1.handleRelayRename);
108
+ relay
109
+ .command("enable")
110
+ .description((0, text_1.bi)("启用一个中转槽位", "Enable a relay slot"))
111
+ .argument("<name>", (0, text_1.bi)("中转槽位名", "Relay slot name"))
112
+ .action(relay_commands_1.handleRelayEnable);
113
+ relay
114
+ .command("disable")
115
+ .description((0, text_1.bi)("禁用一个中转槽位", "Disable a relay slot"))
116
+ .argument("<name>", (0, text_1.bi)("中转槽位名", "Relay slot name"))
117
+ .action(relay_commands_1.handleRelayDisable);
118
+ const use = program
119
+ .command("use")
120
+ .description((0, text_1.bi)("切换模型请求出口", "Switch model route"));
121
+ use
122
+ .command("relay")
123
+ .description((0, text_1.bi)("固定模型请求到指定中转槽位", "Fix model route to a relay slot"))
124
+ .argument("<name>", (0, text_1.bi)("中转槽位名", "Relay slot name"))
125
+ .action(relay_commands_1.handleUseRelay);
126
+ use
127
+ .command("auth")
128
+ .description((0, text_1.bi)("恢复模型请求到官方账号池", "Restore model route to the official auth pool"))
129
+ .action(relay_commands_1.handleUseAuthPool);
130
+ program
131
+ .command("current")
132
+ .description((0, text_1.bi)("查看当前模型出口与 Codex App 登录态选择", "Show current model route and Codex App auth selection"))
133
+ .action(relay_commands_1.handleCurrent);
134
+ }
74
135
  /**
75
136
  * 注册配置与状态相关子命令。
76
137
  *
@@ -117,6 +178,7 @@ async function main() {
117
178
  (0, config_1.loadConfig)();
118
179
  configureRootProgram(program);
119
180
  registerAccountCommands(program);
181
+ registerRelayCommands(program);
120
182
  registerRuntimeCommands(program);
121
183
  await program.parseAsync(process.argv);
122
184
  }
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
  *
@@ -245,6 +245,12 @@ function resolveInitialCursorIndex(accounts, statuses, selectedAuthAccountId) {
245
245
  }
246
246
  return 0;
247
247
  }
248
+ function buildInteractiveItems(accounts, relaySlots) {
249
+ return [
250
+ ...accounts.map((account) => ({ type: "account", id: account.id })),
251
+ ...relaySlots.map((slot) => ({ type: "relay", id: slot.id }))
252
+ ];
253
+ }
248
254
  /**
249
255
  * 将状态面板中选中的账号立即应用为 Codex App 主登录态。
250
256
  *
@@ -286,15 +292,27 @@ async function handleInteractiveToggle(initialStatuses) {
286
292
  node_readline_1.default.emitKeypressEvents(stdin);
287
293
  stdin.setRawMode?.(true);
288
294
  const accountsFromConfig = (0, account_service_1.listAccounts)();
289
- if (accountsFromConfig.length === 0) {
290
- console.log((0, text_1.bi)("当前没有已录入账号。", "No managed accounts found."));
295
+ const latestSnapshotForRelays = (0, status_service_1.getStatusSnapshot)();
296
+ const relaySlotsFromConfig = latestSnapshotForRelays.relaySlots;
297
+ if (accountsFromConfig.length === 0 && relaySlotsFromConfig.length === 0) {
298
+ console.log((0, text_1.bi)("当前没有已录入账号或中转槽位。", "No managed accounts or relay slots found."));
291
299
  stdin.setRawMode?.(false);
292
300
  return;
293
301
  }
294
302
  const accounts = [...accountsFromConfig].sort((left, right) => left.name.localeCompare(right.name));
303
+ const relaySlots = [...relaySlotsFromConfig].sort((left, right) => left.name.localeCompare(right.name));
295
304
  let selectedAuthAccountId = (0, state_1.getSelectedCodexAuthAccountId)();
296
- let cursor = resolveInitialCursorIndex(accounts, initialStatuses ?? (0, status_1.collectAccountStatuses)(), selectedAuthAccountId);
297
- let changed = false;
305
+ const initialItems = buildInteractiveItems(accounts, relaySlots);
306
+ const selectedModelRoute = (0, state_1.getSelectedModelRoute)();
307
+ const initialAccountCursor = resolveInitialCursorIndex(accounts, initialStatuses ?? (0, status_1.collectAccountStatuses)(), selectedAuthAccountId);
308
+ const selectedRelayIndex = selectedModelRoute.mode === "relay_slot"
309
+ ? relaySlots.findIndex((slot) => slot.id === selectedModelRoute.relay_slot_id)
310
+ : -1;
311
+ let cursor = selectedRelayIndex >= 0
312
+ ? accounts.length + selectedRelayIndex
313
+ : Math.min(initialAccountCursor, Math.max(0, initialItems.length - 1));
314
+ let accountChanged = false;
315
+ let relayChanged = false;
298
316
  enterInteractiveScreen();
299
317
  return await new Promise((resolve) => {
300
318
  let closed = false;
@@ -304,7 +322,9 @@ async function handleInteractiveToggle(initialStatuses) {
304
322
  const screenWidth = process.stdout.columns ?? 80;
305
323
  const styled = shouldUseAnsiStyle();
306
324
  const latestSnapshot = (0, status_service_1.getStatusSnapshot)();
307
- const statusSource = changed ? latestSnapshot.statuses : (initialStatuses ?? latestSnapshot.statuses);
325
+ const items = buildInteractiveItems(accounts, relaySlots);
326
+ const currentSelection = items[cursor] ?? null;
327
+ const statusSource = accountChanged ? latestSnapshot.statuses : (initialStatuses ?? latestSnapshot.statuses);
308
328
  const statusById = new Map(statusSource.map((item) => [item.id, item]));
309
329
  const autoSelectedId = (0, scheduler_1.pickBestAccount)()?.account.id ?? null;
310
330
  const summary = (0, status_1.summarizeAccountStatuses)(statusSource);
@@ -321,7 +341,20 @@ async function handleInteractiveToggle(initialStatuses) {
321
341
  };
322
342
  })
323
343
  .filter((item) => item !== null);
324
- const currentItem = displayStatuses.find((item) => item.id === accounts[cursor]?.id) ?? null;
344
+ const displayRelays = relaySlots.map((slot) => {
345
+ const selected = latestSnapshot.modelRoute.mode === "relay_slot" &&
346
+ latestSnapshot.modelRoute.relay_slot_id === slot.id;
347
+ return {
348
+ ...slot,
349
+ name: selected ? `${slot.name}*` : slot.name
350
+ };
351
+ });
352
+ const currentAccount = currentSelection?.type === "account"
353
+ ? displayStatuses.find((item) => item.id === currentSelection.id) ?? null
354
+ : null;
355
+ const currentRelay = currentSelection?.type === "relay"
356
+ ? displayRelays.find((item) => item.id === currentSelection.id) ?? null
357
+ : null;
325
358
  const wideLayout = screenWidth >= 104;
326
359
  const leftWidth = wideLayout ? Math.max(68, Math.floor(screenWidth * 0.64)) : screenWidth;
327
360
  const rightWidth = wideLayout ? Math.max(28, screenWidth - leftWidth - 3) : screenWidth;
@@ -333,41 +366,71 @@ async function handleInteractiveToggle(initialStatuses) {
333
366
  styled,
334
367
  selectorColumn: {
335
368
  enabledById: Object.fromEntries(accounts.map((account) => [account.id, account.enabled])),
336
- cursorAccountId: accounts[cursor]?.id ?? null
369
+ cursorAccountId: currentSelection?.type === "account" ? currentSelection.id : null
337
370
  }
338
371
  }).split("\n")
339
372
  ];
373
+ const relayLines = [
374
+ renderSectionHeader("relays", leftWidth, styled),
375
+ ...(displayRelays.length > 0
376
+ ? (0, status_1.renderRelayStatusTable)(displayRelays, {
377
+ compact: true,
378
+ maxWidth: leftWidth,
379
+ styled,
380
+ selectorColumn: {
381
+ enabledById: Object.fromEntries(relaySlots.map((slot) => [slot.id, slot.enabled])),
382
+ cursorRelayId: currentSelection?.type === "relay" ? currentSelection.id : null
383
+ }
384
+ }).split("\n")
385
+ : ["-"])
386
+ ];
387
+ const currentDetails = currentSelection?.type === "relay"
388
+ ? (0, status_1.renderRelayStatusDetails)(currentRelay, { maxWidth: rightWidth, header: false }).split("\n")
389
+ : (0, status_1.renderStatusDetails)(currentAccount, { maxWidth: rightWidth, header: false }).split("\n");
340
390
  const sideLines = [
341
391
  renderSectionHeader("current", rightWidth, styled),
342
- ...(0, status_1.renderStatusDetails)(currentItem, { maxWidth: rightWidth, header: false }).split("\n"),
392
+ ...currentDetails,
343
393
  "",
344
394
  renderSectionHeader("summary", rightWidth, styled),
345
395
  renderSummaryLine(summary, rightWidth < 42, styled),
396
+ `model_route=${latestSnapshot.modelRouteLabel}`,
346
397
  `scheduler=${latestSnapshot.selectedName ?? "none"}`,
347
398
  `codex_auth=${selectedAuthAccountId ?? "none"}`,
399
+ `relay_slots=${latestSnapshot.relaySlots.length}`,
348
400
  ...(refreshStatusText ? [`refresh=${refreshStatusText}`] : []),
349
401
  "",
350
402
  renderSectionHeader("help", rightWidth, styled),
351
- "↑/↓ move Space toggle a app-auth c clear r refresh Enter/q exit"
403
+ "↑/↓ move Space toggle a app-auth m model-route c clear r refresh Enter/q exit"
404
+ ];
405
+ const leftLines = [
406
+ ...accountLines,
407
+ "",
408
+ ...relayLines
352
409
  ];
353
410
  if (wideLayout) {
354
- renderInteractiveScreen(renderColumns(accountLines, sideLines, 3));
411
+ renderInteractiveScreen(renderColumns(leftLines, sideLines, 3));
355
412
  return;
356
413
  }
357
414
  renderInteractiveScreen([
358
- ...accountLines,
415
+ ...leftLines,
359
416
  "",
360
417
  renderDivider(screenWidth, styled),
361
418
  ...sideLines
362
419
  ]);
363
420
  };
364
421
  const applyChanges = () => {
365
- if (!changed) {
422
+ if (!accountChanged && !relayChanged) {
366
423
  return;
367
424
  }
368
- (0, status_service_1.persistAccountEnabledState)(accounts);
369
- changed = false;
370
- initialStatuses = (0, status_1.collectAccountStatuses)();
425
+ if (accountChanged) {
426
+ (0, status_service_1.persistAccountEnabledState)(accounts);
427
+ accountChanged = false;
428
+ initialStatuses = (0, status_1.collectAccountStatuses)();
429
+ }
430
+ if (relayChanged) {
431
+ (0, status_service_1.persistRelayEnabledState)(relaySlots);
432
+ relayChanged = false;
433
+ }
371
434
  };
372
435
  const exitInteractive = () => {
373
436
  if (closed) {
@@ -392,7 +455,7 @@ async function handleInteractiveToggle(initialStatuses) {
392
455
  return;
393
456
  }
394
457
  if (key.name === "down") {
395
- const nextCursor = Math.min(accounts.length - 1, cursor + 1);
458
+ const nextCursor = Math.min(buildInteractiveItems(accounts, relaySlots).length - 1, cursor + 1);
396
459
  if (nextCursor !== cursor) {
397
460
  cursor = nextCursor;
398
461
  render();
@@ -400,14 +463,33 @@ async function handleInteractiveToggle(initialStatuses) {
400
463
  return;
401
464
  }
402
465
  if (key.name === "space") {
403
- accounts[cursor].enabled = !accounts[cursor].enabled;
404
- changed = true;
466
+ const item = buildInteractiveItems(accounts, relaySlots)[cursor];
467
+ if (item?.type === "account") {
468
+ const account = accounts.find((candidate) => candidate.id === item.id);
469
+ if (account) {
470
+ account.enabled = !account.enabled;
471
+ accountChanged = true;
472
+ }
473
+ }
474
+ else if (item?.type === "relay") {
475
+ const slot = relaySlots.find((candidate) => candidate.id === item.id);
476
+ if (slot) {
477
+ slot.enabled = !slot.enabled;
478
+ relayChanged = true;
479
+ }
480
+ }
405
481
  applyChanges();
406
482
  render();
407
483
  return;
408
484
  }
409
485
  if (key.name === "a") {
410
- const account = accounts[cursor];
486
+ const item = buildInteractiveItems(accounts, relaySlots)[cursor];
487
+ if (item?.type !== "account") {
488
+ refreshStatusText = "app-auth requires account";
489
+ render();
490
+ return;
491
+ }
492
+ const account = accounts.find((candidate) => candidate.id === item.id);
411
493
  if (!account) {
412
494
  return;
413
495
  }
@@ -422,6 +504,33 @@ async function handleInteractiveToggle(initialStatuses) {
422
504
  render();
423
505
  return;
424
506
  }
507
+ if (key.name === "m") {
508
+ const item = buildInteractiveItems(accounts, relaySlots)[cursor];
509
+ if (item?.type === "relay") {
510
+ const slot = relaySlots.find((candidate) => candidate.id === item.id);
511
+ if (!slot) {
512
+ return;
513
+ }
514
+ if (!slot.enabled) {
515
+ refreshStatusText = `relay_disabled=${slot.id}`;
516
+ render();
517
+ return;
518
+ }
519
+ (0, state_1.setSelectedModelRoute)({
520
+ mode: "relay_slot",
521
+ relay_slot_id: slot.id
522
+ });
523
+ refreshStatusText = `model_route=relay:${slot.id}`;
524
+ render();
525
+ return;
526
+ }
527
+ (0, state_1.setSelectedModelRoute)({
528
+ mode: "auth_pool"
529
+ });
530
+ refreshStatusText = "model_route=auth_pool";
531
+ render();
532
+ return;
533
+ }
425
534
  if (key.name === "c") {
426
535
  selectedAuthAccountId = null;
427
536
  (0, state_1.setSelectedCodexAuthAccountId)(null);
@@ -485,8 +594,21 @@ async function handleStatus(options) {
485
594
  name: `${item.name}${item.id === (0, scheduler_1.pickBestAccount)()?.account.id ? "*" : ""}${item.id === snapshot.codexAuthAccountId ? "@" : ""}`
486
595
  }));
487
596
  console.log((0, status_1.renderStatusTable)(displayStatuses));
597
+ if (snapshot.relaySlots.length > 0) {
598
+ const displayRelays = snapshot.relaySlots.map((slot) => ({
599
+ ...slot,
600
+ name: snapshot.modelRoute.mode === "relay_slot" &&
601
+ snapshot.modelRoute.relay_slot_id === slot.id
602
+ ? `${slot.name}*`
603
+ : slot.name
604
+ }));
605
+ console.log("");
606
+ console.log((0, status_1.renderRelayStatusTable)(displayRelays));
607
+ }
488
608
  console.log("");
489
609
  console.log(`available=${snapshot.summary.available} 5h_limited=${snapshot.summary.fiveHourLimited} weekly_limited=${snapshot.summary.weeklyLimited}`);
610
+ console.log(`model_route=${snapshot.modelRouteLabel}`);
490
611
  console.log(`scheduler=${snapshot.selectedName ?? "none"}`);
491
612
  console.log(`codex_auth=${snapshot.codexAuthAccountId ?? "none"}`);
613
+ console.log(`relay_slots=${snapshot.relaySlots.length}`);
492
614
  }
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.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.29",
4
4
  "description": "本地 Codex 多账号切换与状态管理工具",
5
5
  "type": "commonjs",
6
6
  "main": "dist/cli.js",