codex-slot 0.1.17 → 0.1.19

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.
@@ -71,15 +71,50 @@ function resolveEmailFromAuth(auth) {
71
71
  return undefined;
72
72
  }
73
73
  }
74
+ /**
75
+ * 将来源 HOME 下的账号级 `.auth.json` 文件集合镜像到目标 HOME。
76
+ *
77
+ * 业务含义:
78
+ * 1. 仅同步 `accounts` 目录下的账号级认证文件。
79
+ * 2. 来源不存在某个 `.auth.json` 时,会删除目标中的同名残留,避免旧登录态混入。
80
+ * 3. 来源缺少 `accounts` 目录时,视为没有任何账号级认证文件。
81
+ *
82
+ * @param sourceAccountsDir 来源 `accounts` 目录绝对路径。
83
+ * @param targetAccountsDir 目标 `accounts` 目录绝对路径。
84
+ * @returns 无返回值。
85
+ * @throws 当文件复制或删除失败时透传文件系统错误。
86
+ */
87
+ function syncAccountAuthFiles(sourceAccountsDir, targetAccountsDir) {
88
+ const sourceAuthFiles = new Set();
89
+ if (node_fs_1.default.existsSync(sourceAccountsDir)) {
90
+ for (const entry of node_fs_1.default.readdirSync(sourceAccountsDir, { withFileTypes: true })) {
91
+ if (!entry.isFile() || !entry.name.endsWith(".auth.json")) {
92
+ continue;
93
+ }
94
+ sourceAuthFiles.add(entry.name);
95
+ node_fs_1.default.copyFileSync(node_path_1.default.join(sourceAccountsDir, entry.name), node_path_1.default.join(targetAccountsDir, entry.name));
96
+ }
97
+ }
98
+ for (const entry of node_fs_1.default.readdirSync(targetAccountsDir, { withFileTypes: true })) {
99
+ if (!entry.isFile() || !entry.name.endsWith(".auth.json")) {
100
+ continue;
101
+ }
102
+ if (!sourceAuthFiles.has(entry.name)) {
103
+ node_fs_1.default.rmSync(node_path_1.default.join(targetAccountsDir, entry.name), { force: true });
104
+ }
105
+ }
106
+ }
74
107
  /**
75
108
  * 将来源 HOME 下的官方 `.codex` 登录态复制到目标 HOME。
76
109
  *
77
110
  * 只复制认证和账号元数据所需文件,不复制历史日志、缓存等无关内容。
111
+ * 其中 `auth.json` 为必需文件,`accounts/registry.json` 与账号级 `.auth.json`
112
+ * 允许缺失;缺失时会同步删除目标中的对应残留文件,避免旧缓存污染当前登录态。
78
113
  *
79
114
  * @param sourceHome 来源 HOME 目录。
80
115
  * @param targetHome 目标 HOME 目录。
81
116
  * @returns 无返回值。
82
- * @throws 当来源目录缺少关键认证文件时抛出错误。
117
+ * @throws 当来源目录缺少 `auth.json` 或复制失败时抛出错误。
83
118
  */
84
119
  function cloneCodexAuthState(sourceHome, targetHome) {
85
120
  const sourceCodexDir = getCodexDataDir(sourceHome);
@@ -87,25 +122,21 @@ function cloneCodexAuthState(sourceHome, targetHome) {
87
122
  const sourceAuthPath = node_path_1.default.join(sourceCodexDir, "auth.json");
88
123
  const sourceAccountsDir = node_path_1.default.join(sourceCodexDir, "accounts");
89
124
  const sourceRegistryPath = node_path_1.default.join(sourceAccountsDir, "registry.json");
125
+ const targetAccountsDir = node_path_1.default.join(targetCodexDir, "accounts");
126
+ const targetRegistryPath = node_path_1.default.join(targetAccountsDir, "registry.json");
90
127
  if (!node_fs_1.default.existsSync(sourceAuthPath)) {
91
128
  throw new Error((0, text_1.bi)(`来源目录缺少 auth.json: ${sourceAuthPath}`, `Source directory is missing auth.json: ${sourceAuthPath}`));
92
129
  }
93
- if (!node_fs_1.default.existsSync(sourceRegistryPath)) {
94
- throw new Error((0, text_1.bi)(`来源目录缺少 registry.json: ${sourceRegistryPath}`, `Source directory is missing registry.json: ${sourceRegistryPath}`));
95
- }
96
130
  node_fs_1.default.mkdirSync(targetCodexDir, { recursive: true });
97
- node_fs_1.default.mkdirSync(node_path_1.default.join(targetCodexDir, "accounts"), { recursive: true });
131
+ node_fs_1.default.mkdirSync(targetAccountsDir, { recursive: true });
98
132
  node_fs_1.default.copyFileSync(sourceAuthPath, node_path_1.default.join(targetCodexDir, "auth.json"));
99
- node_fs_1.default.copyFileSync(sourceRegistryPath, node_path_1.default.join(targetCodexDir, "accounts", "registry.json"));
100
- for (const entry of node_fs_1.default.readdirSync(sourceAccountsDir, { withFileTypes: true })) {
101
- if (!entry.isFile()) {
102
- continue;
103
- }
104
- if (!entry.name.endsWith(".auth.json")) {
105
- continue;
106
- }
107
- node_fs_1.default.copyFileSync(node_path_1.default.join(sourceAccountsDir, entry.name), node_path_1.default.join(targetCodexDir, "accounts", entry.name));
133
+ if (node_fs_1.default.existsSync(sourceRegistryPath)) {
134
+ node_fs_1.default.copyFileSync(sourceRegistryPath, targetRegistryPath);
135
+ }
136
+ else {
137
+ node_fs_1.default.rmSync(targetRegistryPath, { force: true });
108
138
  }
139
+ syncAccountAuthFiles(sourceAccountsDir, targetAccountsDir);
109
140
  }
110
141
  /**
111
142
  * 检查某个 HOME 下的官方登录态是否完整。
@@ -116,6 +116,10 @@ function renameAccount(oldName, newName) {
116
116
  };
117
117
  delete state.usage_cache[oldName];
118
118
  }
119
+ if (state.scheduler_stats[oldName]) {
120
+ state.scheduler_stats[newName] = state.scheduler_stats[oldName];
121
+ delete state.scheduler_stats[oldName];
122
+ }
119
123
  (0, state_1.saveState)(state);
120
124
  return renamedAccount;
121
125
  }
package/dist/scheduler.js CHANGED
@@ -4,6 +4,12 @@ exports.pickBestAccount = pickBestAccount;
4
4
  exports.listCandidateAccounts = listCandidateAccounts;
5
5
  const config_1 = require("./config");
6
6
  const status_1 = require("./status");
7
+ const state_1 = require("./state");
8
+ const CRITICAL_WEEKLY_LEFT_PERCENT = 5;
9
+ const LOW_WEEKLY_LEFT_PERCENT = 15;
10
+ const WASTE_HORIZON_SECONDS = 5 * 60 * 60;
11
+ const WEEKLY_WASTE_HORIZON_SECONDS = 7 * 24 * 60 * 60;
12
+ const RECENT_USE_RECOVERY_SECONDS = 30 * 60;
7
13
  function nextResetWeight(resetAt) {
8
14
  if (!resetAt) {
9
15
  return Number.MAX_SAFE_INTEGER;
@@ -32,23 +38,171 @@ function isSoftLocalBlocked(status) {
32
38
  ].includes(status.localBlockReason ?? "");
33
39
  }
34
40
  /**
35
- * 对未命中额度限制的候选账号按剩余额度与重置时间排序。
41
+ * 将百分比字段归一化为 0 到 1 的评分,缺失时按中性值处理。
42
+ *
43
+ * @param value 原始百分比。
44
+ * @returns 归一化后的评分。
45
+ */
46
+ function normalizePercent(value) {
47
+ if (value === null || value === undefined || Number.isNaN(value)) {
48
+ return 0.5;
49
+ }
50
+ return Math.max(0, Math.min(1, value / 100));
51
+ }
52
+ /**
53
+ * 计算 5 小时窗口的余额浪费压力,越接近重置且剩余额度越多分数越高。
54
+ *
55
+ * @param status 账号运行时状态。
56
+ * @returns 0 到 1 之间的浪费压力评分。
57
+ */
58
+ function computeFiveHourWastePressure(status) {
59
+ const leftScore = normalizePercent(status.fiveHourLeftPercent);
60
+ if (!status.fiveHourResetsAt) {
61
+ return leftScore * 0.2;
62
+ }
63
+ const secondsUntilReset = status.fiveHourResetsAt - Math.floor(Date.now() / 1000);
64
+ if (secondsUntilReset <= 0) {
65
+ return 0;
66
+ }
67
+ const urgency = Math.max(0.1, Math.min(1, (WASTE_HORIZON_SECONDS - secondsUntilReset) / WASTE_HORIZON_SECONDS));
68
+ return leftScore * urgency;
69
+ }
70
+ /**
71
+ * 计算周窗口的余额浪费压力,周额度越接近重置且剩余越多分数越高。
72
+ *
73
+ * @param status 账号运行时状态。
74
+ * @returns 0 到 1 之间的周窗口浪费压力评分。
75
+ */
76
+ function computeWeeklyWastePressure(status) {
77
+ const leftScore = normalizePercent(status.weeklyLeftPercent);
78
+ if (!status.weeklyResetsAt) {
79
+ return leftScore * 0.1;
80
+ }
81
+ const secondsUntilReset = status.weeklyResetsAt - Math.floor(Date.now() / 1000);
82
+ if (secondsUntilReset <= 0) {
83
+ return 0;
84
+ }
85
+ const urgency = Math.max(0.05, Math.min(1, (WEEKLY_WASTE_HORIZON_SECONDS - secondsUntilReset) / WEEKLY_WASTE_HORIZON_SECONDS));
86
+ return leftScore * urgency;
87
+ }
88
+ /**
89
+ * 计算 5 小时窗口调度时可借用的周额度承载系数,周余额越低越抑制短窗口冲动。
90
+ *
91
+ * @param status 账号运行时状态。
92
+ * @returns 0 到 1 之间的周额度承载系数。
93
+ */
94
+ function computeWeeklyCapacityFactor(status) {
95
+ const weeklyLeft = status.weeklyLeftPercent;
96
+ if (weeklyLeft === null || weeklyLeft === undefined || Number.isNaN(weeklyLeft)) {
97
+ return 0.7;
98
+ }
99
+ if (weeklyLeft <= CRITICAL_WEEKLY_LEFT_PERCENT) {
100
+ return 0.05;
101
+ }
102
+ if (weeklyLeft < LOW_WEEKLY_LEFT_PERCENT) {
103
+ return 0.2;
104
+ }
105
+ if (weeklyLeft < 30) {
106
+ return 0.55;
107
+ }
108
+ return 1;
109
+ }
110
+ /**
111
+ * 计算周额度健康度,低于保护线时非线性降权,避免个别账号过早打穿周窗口。
112
+ *
113
+ * @param status 账号运行时状态。
114
+ * @returns 0 到 1 之间的周额度健康评分。
115
+ */
116
+ function computeWeeklyHealthScore(status) {
117
+ const weeklyLeft = status.weeklyLeftPercent;
118
+ if (weeklyLeft === null || weeklyLeft === undefined || Number.isNaN(weeklyLeft)) {
119
+ return 0.5;
120
+ }
121
+ if (weeklyLeft <= CRITICAL_WEEKLY_LEFT_PERCENT) {
122
+ return 0;
123
+ }
124
+ if (weeklyLeft < LOW_WEEKLY_LEFT_PERCENT) {
125
+ return normalizePercent(weeklyLeft) * 0.35;
126
+ }
127
+ return normalizePercent(weeklyLeft);
128
+ }
129
+ /**
130
+ * 计算账号使用分散度,成功次数更少且最近未使用的账号分数更高。
131
+ *
132
+ * @param status 账号运行时状态。
133
+ * @param minSuccessCount 当前候选账号中的最小成功次数。
134
+ * @param maxSuccessCount 当前候选账号中的最大成功次数。
135
+ * @returns 0 到 1 之间的分散调度评分。
136
+ */
137
+ function computeSpreadScore(status, minSuccessCount, maxSuccessCount) {
138
+ const stats = (0, state_1.getSchedulerStats)(status.id);
139
+ const countRange = Math.max(1, maxSuccessCount - minSuccessCount);
140
+ const countScore = 1 - (stats.success_count - minSuccessCount) / countRange;
141
+ if (!stats.last_success_at) {
142
+ return Math.max(0, Math.min(1, countScore * 0.6 + 0.4));
143
+ }
144
+ const secondsSinceLastUse = (Date.now() - new Date(stats.last_success_at).getTime()) / 1000;
145
+ const recencyScore = Math.max(0, Math.min(1, secondsSinceLastUse / RECENT_USE_RECOVERY_SECONDS));
146
+ return Math.max(0, Math.min(1, countScore * 0.6 + recencyScore * 0.4));
147
+ }
148
+ /**
149
+ * 计算候选账号的综合调度评分。
150
+ *
151
+ * 业务含义:
152
+ * 1. 周窗口承担主防浪费压力,快重置且余额多的账号优先。
153
+ * 2. 5 小时窗口在周额度健康时参与放大,低周余额会抑制短窗口冲动。
154
+ * 3. 本地成功使用历史用于打散连续请求,降低单账号被持续命中的概率。
155
+ *
156
+ * @param status 账号运行时状态。
157
+ * @param minSuccessCount 当前候选账号中的最小成功次数。
158
+ * @param maxSuccessCount 当前候选账号中的最大成功次数。
159
+ * @returns 综合评分,分数越高越优先。
160
+ */
161
+ function computeScheduleScore(status, minSuccessCount, maxSuccessCount) {
162
+ const weeklyWasteScore = computeWeeklyWastePressure(status);
163
+ const fiveHourWasteScore = computeFiveHourWastePressure(status) * computeWeeklyCapacityFactor(status);
164
+ const weeklyHealthScore = computeWeeklyHealthScore(status);
165
+ const fiveHourLeftScore = normalizePercent(status.fiveHourLeftPercent);
166
+ const spreadScore = computeSpreadScore(status, minSuccessCount, maxSuccessCount);
167
+ return (weeklyWasteScore * 0.5 +
168
+ fiveHourWasteScore * 0.25 +
169
+ weeklyHealthScore * 0.1 +
170
+ spreadScore * 0.1 +
171
+ fiveHourLeftScore * 0.05);
172
+ }
173
+ /**
174
+ * 对候选账号按防浪费、周额度保护与均匀使用策略排序。
36
175
  *
37
176
  * @param statuses 待排序的账号状态列表。
38
177
  * @returns 排序后的账号状态列表,优先返回更适合尝试的账号。
39
178
  */
40
179
  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
- });
180
+ const primaryPool = statuses.some((item) => item.weeklyLeftPercent === null ||
181
+ item.weeklyLeftPercent === undefined ||
182
+ item.weeklyLeftPercent > CRITICAL_WEEKLY_LEFT_PERCENT)
183
+ ? statuses.filter((item) => item.weeklyLeftPercent === null ||
184
+ item.weeklyLeftPercent === undefined ||
185
+ item.weeklyLeftPercent > CRITICAL_WEEKLY_LEFT_PERCENT)
186
+ : statuses;
187
+ const deferredPool = statuses.filter((item) => !primaryPool.includes(item));
188
+ const rankPool = (items) => {
189
+ const successCounts = items.map((item) => (0, state_1.getSchedulerStats)(item.id).success_count);
190
+ const minSuccessCount = Math.min(...successCounts, 0);
191
+ const maxSuccessCount = Math.max(...successCounts, 0);
192
+ return [...items].sort((left, right) => {
193
+ const scoreDiff = computeScheduleScore(right, minSuccessCount, maxSuccessCount) -
194
+ computeScheduleScore(left, minSuccessCount, maxSuccessCount);
195
+ if (Math.abs(scoreDiff) > Number.EPSILON) {
196
+ return scoreDiff;
197
+ }
198
+ const resetDiff = nextResetWeight(left.fiveHourResetsAt) - nextResetWeight(right.fiveHourResetsAt);
199
+ if (resetDiff !== 0) {
200
+ return resetDiff;
201
+ }
202
+ return (right.weeklyLeftPercent ?? -1) - (left.weeklyLeftPercent ?? -1);
203
+ });
204
+ };
205
+ return [...rankPool(primaryPool), ...rankPool(deferredPool)];
52
206
  }
53
207
  /**
54
208
  * 选择当前最适合激活的账号。
package/dist/server.js CHANGED
@@ -355,6 +355,7 @@ async function startServer(port) {
355
355
  if (typeof cacheControl === "string") {
356
356
  headers["cache-control"] = cacheControl;
357
357
  }
358
+ (0, state_1.recordAccountScheduleSuccess)(picked.account.id);
358
359
  headers.connection = "keep-alive";
359
360
  reply.raw.writeHead(upstream.statusCode, headers);
360
361
  for await (const chunk of upstream.body) {
package/dist/state.js CHANGED
@@ -13,6 +13,8 @@ exports.getUsageCache = getUsageCache;
13
13
  exports.setUsageRefreshError = setUsageRefreshError;
14
14
  exports.clearUsageRefreshError = clearUsageRefreshError;
15
15
  exports.getUsageRefreshError = getUsageRefreshError;
16
+ exports.getSchedulerStats = getSchedulerStats;
17
+ exports.recordAccountScheduleSuccess = recordAccountScheduleSuccess;
16
18
  exports.getManagedCodexConfigState = getManagedCodexConfigState;
17
19
  exports.getManagedCodexAuthState = getManagedCodexAuthState;
18
20
  exports.setManagedCodexConfigState = setManagedCodexConfigState;
@@ -37,6 +39,7 @@ function loadState() {
37
39
  account_blocks: {},
38
40
  usage_cache: {},
39
41
  usage_refresh_errors: {},
42
+ scheduler_stats: {},
40
43
  managed_codex_auth: null,
41
44
  managed_codex_config: null
42
45
  };
@@ -48,6 +51,7 @@ function loadState() {
48
51
  account_blocks: {},
49
52
  usage_cache: {},
50
53
  usage_refresh_errors: {},
54
+ scheduler_stats: {},
51
55
  managed_codex_auth: null,
52
56
  managed_codex_config: null
53
57
  };
@@ -55,6 +59,7 @@ function loadState() {
55
59
  account_blocks: parsed.account_blocks ?? {},
56
60
  usage_cache: parsed.usage_cache ?? {},
57
61
  usage_refresh_errors: parsed.usage_refresh_errors ?? {},
62
+ scheduler_stats: parsed.scheduler_stats ?? {},
58
63
  managed_codex_auth: parsed.managed_codex_auth ?? null,
59
64
  managed_codex_config: parsed.managed_codex_config ?? null
60
65
  };
@@ -172,6 +177,37 @@ function getUsageRefreshError(accountId) {
172
177
  const state = loadState();
173
178
  return state.usage_refresh_errors[accountId] ?? null;
174
179
  }
180
+ /**
181
+ * 读取指定账号的调度使用统计,用于在多账号可用时做均匀分摊。
182
+ *
183
+ * @param accountId 账号标识。
184
+ * @returns 调度统计;不存在时返回默认零值。
185
+ */
186
+ function getSchedulerStats(accountId) {
187
+ const state = loadState();
188
+ return state.scheduler_stats[accountId] ?? {
189
+ success_count: 0,
190
+ last_success_at: null
191
+ };
192
+ }
193
+ /**
194
+ * 记录指定账号完成一次成功代理请求,供后续调度降低连续命中同一账号的概率。
195
+ *
196
+ * @param accountId 账号标识。
197
+ * @returns 无返回值。
198
+ */
199
+ function recordAccountScheduleSuccess(accountId) {
200
+ const state = loadState();
201
+ const current = state.scheduler_stats[accountId] ?? {
202
+ success_count: 0,
203
+ last_success_at: null
204
+ };
205
+ state.scheduler_stats[accountId] = {
206
+ success_count: current.success_count + 1,
207
+ last_success_at: new Date().toISOString()
208
+ };
209
+ saveState(state);
210
+ }
175
211
  /**
176
212
  * 读取当前记录的 Codex `config.toml` 接管快照。
177
213
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-slot",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "本地 Codex 多账号切换与状态管理工具",
5
5
  "type": "commonjs",
6
6
  "main": "dist/cli.js",