codex-slot 0.1.0

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/dist/config.js ADDED
@@ -0,0 +1,205 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getCodexSwHome = getCodexSwHome;
7
+ exports.getConfigPath = getConfigPath;
8
+ exports.getPidPath = getPidPath;
9
+ exports.getServiceLogPath = getServiceLogPath;
10
+ exports.expandHome = expandHome;
11
+ exports.loadConfig = loadConfig;
12
+ exports.saveConfig = saveConfig;
13
+ exports.getManagedHome = getManagedHome;
14
+ exports.upsertAccount = upsertAccount;
15
+ const node_fs_1 = __importDefault(require("node:fs"));
16
+ const node_os_1 = __importDefault(require("node:os"));
17
+ const node_path_1 = __importDefault(require("node:path"));
18
+ const yaml_1 = __importDefault(require("yaml"));
19
+ const zod_1 = require("zod");
20
+ const managedAccountSchema = zod_1.z.object({
21
+ id: zod_1.z.string().min(1),
22
+ name: zod_1.z.string().min(1),
23
+ codex_home: zod_1.z.string().min(1),
24
+ email: zod_1.z.string().email().optional(),
25
+ enabled: zod_1.z.boolean().default(true),
26
+ imported_at: zod_1.z.string().optional()
27
+ });
28
+ const configSchema = zod_1.z.object({
29
+ version: zod_1.z.number().int().default(1),
30
+ server: zod_1.z
31
+ .object({
32
+ host: zod_1.z.string().default("127.0.0.1"),
33
+ port: zod_1.z.number().int().default(4389),
34
+ api_key: zod_1.z.string().default("cslot-defaultkey"),
35
+ body_limit_mb: zod_1.z.number().positive().default(512)
36
+ })
37
+ .default({
38
+ host: "127.0.0.1",
39
+ port: 4389,
40
+ api_key: "cslot-defaultkey",
41
+ body_limit_mb: 512
42
+ }),
43
+ upstream: zod_1.z
44
+ .object({
45
+ codex_base_url: zod_1.z.string().default("https://chatgpt.com/backend-api/codex"),
46
+ auth_base_url: zod_1.z.string().default("https://auth.openai.com"),
47
+ oauth_client_id: zod_1.z.string().default("app_EMoamEEZ73f0CkXaXp7hrann")
48
+ })
49
+ .default({
50
+ codex_base_url: "https://chatgpt.com/backend-api/codex",
51
+ auth_base_url: "https://auth.openai.com",
52
+ oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann"
53
+ }),
54
+ accounts: zod_1.z.array(managedAccountSchema).default([])
55
+ });
56
+ /**
57
+ * 返回 cslot 的根目录,并确保基础目录结构存在。
58
+ *
59
+ * @returns cslot 根目录绝对路径。
60
+ * @throws 当目录无法创建时抛出文件系统错误。
61
+ */
62
+ function getCodexSwHome() {
63
+ const home = node_path_1.default.join(node_os_1.default.homedir(), ".cslot");
64
+ const legacyHome = node_path_1.default.join(node_os_1.default.homedir(), ".codexsw");
65
+ if (!node_fs_1.default.existsSync(home) && node_fs_1.default.existsSync(legacyHome)) {
66
+ node_fs_1.default.cpSync(legacyHome, home, { recursive: true });
67
+ }
68
+ // 先创建 cslot 根目录,后续命令统一基于该目录读写状态。
69
+ node_fs_1.default.mkdirSync(home, { recursive: true });
70
+ node_fs_1.default.mkdirSync(node_path_1.default.join(home, "homes"), { recursive: true });
71
+ node_fs_1.default.mkdirSync(node_path_1.default.join(home, "logs"), { recursive: true });
72
+ return home;
73
+ }
74
+ /**
75
+ * 返回 cslot 配置文件路径。
76
+ *
77
+ * @returns 配置文件绝对路径。
78
+ */
79
+ function getConfigPath() {
80
+ return node_path_1.default.join(getCodexSwHome(), "config.yaml");
81
+ }
82
+ /**
83
+ * 返回后台服务 PID 文件路径。
84
+ *
85
+ * @returns PID 文件绝对路径。
86
+ */
87
+ function getPidPath() {
88
+ return node_path_1.default.join(getCodexSwHome(), "cslot.pid");
89
+ }
90
+ /**
91
+ * 返回后台服务日志文件路径。
92
+ *
93
+ * @returns 日志文件绝对路径。
94
+ */
95
+ function getServiceLogPath() {
96
+ return node_path_1.default.join(getCodexSwHome(), "logs", "service.log");
97
+ }
98
+ /**
99
+ * 将路径中的 `~` 展开为当前用户家目录。
100
+ *
101
+ * @param input 原始路径,允许以 `~` 开头。
102
+ * @returns 展开后的绝对或原始路径。
103
+ */
104
+ function expandHome(input) {
105
+ if (input === "~") {
106
+ return node_os_1.default.homedir();
107
+ }
108
+ if (input.startsWith("~/")) {
109
+ return node_path_1.default.join(node_os_1.default.homedir(), input.slice(2));
110
+ }
111
+ return input;
112
+ }
113
+ /**
114
+ * 读取 cslot 配置;若配置不存在则返回默认配置。
115
+ *
116
+ * @returns 经过 schema 校验后的配置对象。
117
+ * @throws 当配置存在但内容非法时抛出错误。
118
+ */
119
+ function loadConfig() {
120
+ const configPath = getConfigPath();
121
+ const legacyConfigPath = node_path_1.default.join(node_os_1.default.homedir(), ".codexsw", "config.yaml");
122
+ if (!node_fs_1.default.existsSync(configPath)) {
123
+ const defaultConfig = {
124
+ version: 1,
125
+ server: {
126
+ host: "127.0.0.1",
127
+ port: 4389,
128
+ api_key: "cslot-defaultkey",
129
+ body_limit_mb: 512
130
+ },
131
+ upstream: {
132
+ codex_base_url: "https://chatgpt.com/backend-api/codex",
133
+ auth_base_url: "https://auth.openai.com",
134
+ oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann"
135
+ },
136
+ accounts: []
137
+ };
138
+ saveConfig(defaultConfig);
139
+ return defaultConfig;
140
+ }
141
+ const raw = node_fs_1.default.readFileSync(configPath, "utf8");
142
+ const parsed = raw.trim() ? yaml_1.default.parse(raw) : {};
143
+ const normalized = configSchema.parse(parsed);
144
+ let changed = JSON.stringify(parsed) !== JSON.stringify(normalized);
145
+ if (normalized.accounts.length === 0 &&
146
+ node_fs_1.default.existsSync(legacyConfigPath)) {
147
+ const legacyRaw = node_fs_1.default.readFileSync(legacyConfigPath, "utf8");
148
+ const legacyParsed = legacyRaw.trim() ? yaml_1.default.parse(legacyRaw) : {};
149
+ const legacyConfig = configSchema.parse(legacyParsed);
150
+ if (legacyConfig.accounts.length > 0) {
151
+ normalized.accounts = legacyConfig.accounts;
152
+ changed = true;
153
+ }
154
+ }
155
+ // 兼容历史默认值,统一迁移到新的简短本地 key。
156
+ if (normalized.server.api_key === "local-only-key" ||
157
+ normalized.server.api_key === "codexsw-defaultkey") {
158
+ normalized.server.api_key = "cslot-defaultkey";
159
+ changed = true;
160
+ }
161
+ // 当旧配置缺少新字段时,将补全后的配置回写,便于用户直接编辑查看。
162
+ if (changed) {
163
+ saveConfig(normalized);
164
+ }
165
+ return normalized;
166
+ }
167
+ /**
168
+ * 持久化 cslot 配置文件。
169
+ *
170
+ * @param config 待写入的配置对象。
171
+ * @returns 无返回值。
172
+ * @throws 当配置写入失败时抛出文件系统错误。
173
+ */
174
+ function saveConfig(config) {
175
+ const configPath = getConfigPath();
176
+ const text = yaml_1.default.stringify(config);
177
+ node_fs_1.default.writeFileSync(configPath, text, "utf8");
178
+ }
179
+ /**
180
+ * 根据账号标识生成其独立的 HOME 目录。
181
+ *
182
+ * @param accountId 账号标识,仅用于本地目录名。
183
+ * @returns 该账号对应的 HOME 目录绝对路径。
184
+ */
185
+ function getManagedHome(accountId) {
186
+ return node_path_1.default.join(getCodexSwHome(), "homes", accountId);
187
+ }
188
+ /**
189
+ * 将账号追加到配置中;若已存在相同 id 则覆盖更新。
190
+ *
191
+ * @param account 待写入的账号配置。
192
+ * @returns 更新后的完整配置对象。
193
+ */
194
+ function upsertAccount(account) {
195
+ const config = loadConfig();
196
+ const index = config.accounts.findIndex((item) => item.id === account.id);
197
+ if (index >= 0) {
198
+ config.accounts[index] = account;
199
+ }
200
+ else {
201
+ config.accounts.push(account);
202
+ }
203
+ saveConfig(config);
204
+ return config;
205
+ }
package/dist/login.js ADDED
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.loginManagedAccount = loginManagedAccount;
7
+ const node_child_process_1 = require("node:child_process");
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const account_store_1 = require("./account-store");
10
+ const config_1 = require("./config");
11
+ /**
12
+ * 使用独立 HOME 目录拉起官方 `codex login`,完成单账号录入。
13
+ *
14
+ * @param accountId 本地账号标识。
15
+ * @returns Promise,成功时返回导入后的 HOME 目录。
16
+ * @throws 当 `codex login` 执行失败时抛出错误。
17
+ */
18
+ async function loginManagedAccount(accountId) {
19
+ const managedHome = (0, config_1.getManagedHome)(accountId);
20
+ node_fs_1.default.mkdirSync(managedHome, { recursive: true });
21
+ return await new Promise((resolve, reject) => {
22
+ const child = (0, node_child_process_1.spawn)("codex", ["login"], {
23
+ env: {
24
+ ...process.env,
25
+ HOME: managedHome
26
+ },
27
+ stdio: "inherit"
28
+ });
29
+ child.on("exit", (code) => {
30
+ if (code === 0) {
31
+ if (!(0, account_store_1.hasCompleteCodexAuthState)(managedHome)) {
32
+ reject(new Error("codex login 已退出,但未检测到完整登录态,请重新登录"));
33
+ return;
34
+ }
35
+ (0, account_store_1.registerManagedAccount)(accountId, managedHome);
36
+ resolve(managedHome);
37
+ return;
38
+ }
39
+ reject(new Error(`codex login 失败,退出码: ${code ?? "unknown"}`));
40
+ });
41
+ child.on("error", (error) => {
42
+ reject(error);
43
+ });
44
+ });
45
+ }
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pickBestAccount = pickBestAccount;
4
+ exports.listCandidateAccounts = listCandidateAccounts;
5
+ const config_1 = require("./config");
6
+ const status_1 = require("./status");
7
+ function nextResetWeight(resetAt) {
8
+ if (!resetAt) {
9
+ return Number.MAX_SAFE_INTEGER;
10
+ }
11
+ const diff = resetAt * 1000 - Date.now();
12
+ return diff > 0 ? diff : Number.MAX_SAFE_INTEGER;
13
+ }
14
+ /**
15
+ * 判断账号当前是否仅命中可忽略的短期本地熔断。
16
+ *
17
+ * 这类熔断通常由瞬时网络抖动、上游 5xx 或短暂 token 刷新失败触发,
18
+ * 当系统只剩一个可调度账号时,不应因此立刻把它排除掉。
19
+ *
20
+ * @param status 账号运行时状态。
21
+ * @returns `true` 表示仅存在可回退的短期本地熔断;否则返回 `false`。
22
+ */
23
+ function isSoftLocalBlocked(status) {
24
+ if (!status.localBlockUntil || status.localBlockUntil * 1000 <= Date.now()) {
25
+ return false;
26
+ }
27
+ return [
28
+ "request_failed",
29
+ "upstream_5xx",
30
+ "temporary_5m_limit",
31
+ "token_refresh_failed"
32
+ ].includes(status.localBlockReason ?? "");
33
+ }
34
+ /**
35
+ * 对未命中额度限制的候选账号按剩余额度与重置时间排序。
36
+ *
37
+ * @param statuses 待排序的账号状态列表。
38
+ * @returns 排序后的账号状态列表,优先返回更适合尝试的账号。
39
+ */
40
+ function rankEligibleStatuses(statuses) {
41
+ return [...statuses].sort((left, right) => {
42
+ const fiveHourDiff = (right.fiveHourLeftPercent ?? -1) - (left.fiveHourLeftPercent ?? -1);
43
+ if (fiveHourDiff !== 0) {
44
+ return fiveHourDiff;
45
+ }
46
+ const weeklyDiff = (right.weeklyLeftPercent ?? -1) - (left.weeklyLeftPercent ?? -1);
47
+ if (weeklyDiff !== 0) {
48
+ return weeklyDiff;
49
+ }
50
+ return nextResetWeight(left.fiveHourResetsAt) - nextResetWeight(right.fiveHourResetsAt);
51
+ });
52
+ }
53
+ /**
54
+ * 选择当前最适合激活的账号。
55
+ *
56
+ * 业务规则:
57
+ * 1. 仅在账号启用且存在凭据时参与调度。
58
+ * 2. 优先选择当前 5 小时和周窗口都未受限的账号。
59
+ * 3. 在多个可用账号间,优先选择 5 小时剩余额度更高的账号。
60
+ *
61
+ * @returns 调度结果;若没有可用账号则返回 `null`。
62
+ */
63
+ function pickBestAccount() {
64
+ return listCandidateAccounts()[0] ?? null;
65
+ }
66
+ /**
67
+ * 返回按优先级排序后的可用账号列表,供代理重试链路使用。
68
+ *
69
+ * @returns 候选账号列表,已按优先级从高到低排序。
70
+ */
71
+ function listCandidateAccounts() {
72
+ const config = (0, config_1.loadConfig)();
73
+ const statuses = (0, status_1.collectAccountStatuses)();
74
+ const accountMap = new Map(config.accounts.map((item) => [item.id, item]));
75
+ const eligible = statuses.filter((item) => item.enabled && item.exists && !item.isFiveHourLimited && !item.isWeeklyLimited);
76
+ const available = rankEligibleStatuses(statuses.filter((item) => item.isAvailable));
77
+ const ranked = available.length > 0 ? available : [];
78
+ // 当所有未限额账号都只命中短期本地熔断时,仍允许继续兜底尝试,避免把网络抖动误判成“无可用账号”。
79
+ if (ranked.length === 0 && eligible.length > 0 && eligible.every(isSoftLocalBlocked)) {
80
+ ranked.push(...rankEligibleStatuses(eligible));
81
+ }
82
+ return ranked
83
+ .map((winner) => {
84
+ const account = accountMap.get(winner.id);
85
+ if (!account) {
86
+ return null;
87
+ }
88
+ return {
89
+ account,
90
+ status: winner,
91
+ reason: winner.isAvailable
92
+ ? "优先选择 5 小时窗口剩余额度最高且当前可用的账号"
93
+ : "当前仅剩一个可调度账号,忽略短期本地熔断后继续兜底尝试"
94
+ };
95
+ })
96
+ .filter((item) => item !== null);
97
+ }
package/dist/serve.js ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const config_1 = require("./config");
5
+ const server_1 = require("./server");
6
+ /**
7
+ * 后台服务进程入口。
8
+ *
9
+ * @returns Promise,无返回值。
10
+ * @throws 当端口参数非法或服务启动失败时抛出异常。
11
+ */
12
+ async function main() {
13
+ const config = (0, config_1.loadConfig)();
14
+ const portArgIndex = process.argv.findIndex((item) => item === "--port");
15
+ const port = portArgIndex >= 0 && process.argv[portArgIndex + 1]
16
+ ? Number(process.argv[portArgIndex + 1])
17
+ : config.server.port;
18
+ await (0, server_1.startServer)(port);
19
+ }
20
+ void main().catch((error) => {
21
+ const message = error instanceof Error ? error.message : String(error);
22
+ console.error(`cslot service 启动失败: ${message}`);
23
+ process.exit(1);
24
+ });