@openxiaobu/codexl 0.1.4 → 0.1.6

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/cli.js CHANGED
@@ -13,6 +13,27 @@ const config_1 = require("./config");
13
13
  const login_1 = require("./login");
14
14
  const status_1 = require("./status");
15
15
  const usage_sync_1 = require("./usage-sync");
16
+ /**
17
+ * 读取当前 CLI 的发布版本号,优先与 npm 包元数据保持一致。
18
+ *
19
+ * @returns string,当前包版本号;当 package.json 不可读或字段缺失时返回 `0.0.0`。
20
+ * @throws 无显式抛出;内部异常会被吞掉并回退到默认版本号。
21
+ */
22
+ function getCliVersion() {
23
+ try {
24
+ const packageJsonPath = node_path_1.default.resolve(__dirname, "../package.json");
25
+ // 直接读取发布包内的 package.json,避免 CLI 版本号与 npm 发布版本脱节。
26
+ const packageJsonContent = node_fs_1.default.readFileSync(packageJsonPath, "utf8");
27
+ const packageJson = JSON.parse(packageJsonContent);
28
+ if (typeof packageJson.version === "string" && packageJson.version.trim().length > 0) {
29
+ return packageJson.version;
30
+ }
31
+ }
32
+ catch {
33
+ // 读取失败时使用保底版本,避免 `-V` 命令直接异常退出。
34
+ }
35
+ return "0.0.0";
36
+ }
16
37
  /**
17
38
  * 刷新所有已录入账号的远端额度,并输出最新状态表格。
18
39
  *
@@ -296,7 +317,7 @@ async function main() {
296
317
  program
297
318
  .name("codexl")
298
319
  .description("本地 Codex 多账号切换与状态管理工具")
299
- .version("0.1.2");
320
+ .version(getCliVersion());
300
321
  program
301
322
  .command("add")
302
323
  .description("登录并新增一个账号或工作空间")
package/dist/server.js CHANGED
@@ -57,6 +57,18 @@ function resolveBlockWindow(picked, errorText) {
57
57
  reason: "temporary_5m_limit"
58
58
  };
59
59
  }
60
+ /**
61
+ * 为当前请求失败的账号设置临时熔断状态,避免短时间内被重复选中。
62
+ *
63
+ * @param accountId 账号标识。
64
+ * @param reason 本地状态中记录的失败原因。
65
+ * @param blockSeconds 熔断持续秒数。
66
+ * @returns 无返回值。
67
+ */
68
+ function markAccountFailure(accountId, reason, blockSeconds) {
69
+ // 请求链路中的短期失败通常是瞬时异常,记录一个较短的本地熔断窗口即可。
70
+ (0, state_1.setAccountBlock)(accountId, Math.floor(Date.now() / 1000) + blockSeconds, reason);
71
+ }
60
72
  /**
61
73
  * 启动一个极轻量本地服务,供后续接入代理或脚本化查询使用。
62
74
  *
@@ -115,16 +127,12 @@ async function startServer(port) {
115
127
  };
116
128
  let lastStatusCode = 503;
117
129
  for (const picked of candidates) {
118
- try {
119
- await (0, usage_sync_1.refreshAccountUsage)(picked.account.id);
120
- }
121
- catch {
122
- // 刷新失败时继续使用本地缓存,不中断请求链路。
123
- }
124
130
  const auth = (0, account_store_1.readAuthFile)(picked.account.codex_home);
125
131
  let accessToken = auth?.tokens?.access_token;
126
132
  const accountIdHeader = auth?.tokens?.account_id;
127
133
  if (!accessToken) {
134
+ // 当前账号认证信息不完整时,先做短时熔断,再切到下一个账号。
135
+ markAccountFailure(picked.account.id, "invalid_account_auth", 10 * 60);
128
136
  lastErrorPayload = {
129
137
  error: {
130
138
  message: `账号 ${picked.account.id} 缺少 access_token`,
@@ -145,12 +153,41 @@ async function startServer(port) {
145
153
  },
146
154
  body: JSON.stringify(requestMessage)
147
155
  });
148
- let upstream = await sendUpstream();
149
- if (upstream.statusCode === 401) {
150
- const refreshed = await (0, usage_sync_1.refreshAccountTokens)(picked.account.id);
151
- accessToken = refreshed.tokens?.access_token ?? accessToken;
156
+ let upstream;
157
+ try {
152
158
  upstream = await sendUpstream();
153
159
  }
160
+ catch (error) {
161
+ // 上游连接异常通常是账号或链路瞬时问题,短时标记后继续尝试下一个账号。
162
+ markAccountFailure(picked.account.id, "request_failed", 60);
163
+ lastStatusCode = 503;
164
+ lastErrorPayload = {
165
+ error: {
166
+ message: `账号 ${picked.account.id} 请求上游失败: ${error instanceof Error ? error.message : String(error)}`,
167
+ type: "account_request_failed"
168
+ }
169
+ };
170
+ continue;
171
+ }
172
+ if (upstream.statusCode === 401) {
173
+ try {
174
+ const refreshed = await (0, usage_sync_1.refreshAccountTokens)(picked.account.id);
175
+ accessToken = refreshed.tokens?.access_token ?? accessToken;
176
+ upstream = await sendUpstream();
177
+ }
178
+ catch (error) {
179
+ // token 刷新失败说明该账号短期内不可用,先熔断再切换。
180
+ markAccountFailure(picked.account.id, "token_refresh_failed", 10 * 60);
181
+ lastStatusCode = 503;
182
+ lastErrorPayload = {
183
+ error: {
184
+ message: `账号 ${picked.account.id} 刷新 token 失败: ${error instanceof Error ? error.message : String(error)}`,
185
+ type: "account_token_refresh_failed"
186
+ }
187
+ };
188
+ continue;
189
+ }
190
+ }
154
191
  if (upstream.statusCode === 429 || upstream.statusCode === 403) {
155
192
  const errorText = await upstream.body.text();
156
193
  const block = resolveBlockWindow(picked, errorText);
@@ -179,6 +216,18 @@ async function startServer(port) {
179
216
  };
180
217
  continue;
181
218
  }
219
+ if (upstream.statusCode >= 500) {
220
+ // 上游 5xx 先视为当前账号链路失败,短时熔断并切到下一个账号。
221
+ markAccountFailure(picked.account.id, "upstream_5xx", 60);
222
+ lastStatusCode = upstream.statusCode;
223
+ lastErrorPayload = {
224
+ error: {
225
+ message: `账号 ${picked.account.id} 上游异常: ${errorText}`,
226
+ type: "account_upstream_failed"
227
+ }
228
+ };
229
+ continue;
230
+ }
182
231
  reply.raw.writeHead(upstream.statusCode, {
183
232
  "content-type": "application/json"
184
233
  });
package/dist/status.js CHANGED
@@ -31,6 +31,46 @@ function formatReset(unixSeconds) {
31
31
  hour12: false
32
32
  });
33
33
  }
34
+ /**
35
+ * 将剩余秒数格式化为紧凑的人类可读文本,便于在状态列中展示熔断剩余时间。
36
+ *
37
+ * @param unixSeconds 熔断截止时间,Unix 秒时间戳。
38
+ * @returns 格式化后的剩余时长;当时间为空或已过期时返回 `null`。
39
+ */
40
+ function formatRemainingDuration(unixSeconds) {
41
+ if (!unixSeconds) {
42
+ return null;
43
+ }
44
+ const diffSeconds = unixSeconds - Math.floor(Date.now() / 1000);
45
+ if (diffSeconds <= 0) {
46
+ return null;
47
+ }
48
+ const hours = Math.floor(diffSeconds / 3600);
49
+ const minutes = Math.floor((diffSeconds % 3600) / 60);
50
+ const seconds = diffSeconds % 60;
51
+ if (hours > 0) {
52
+ return `${hours}h${minutes}m`;
53
+ }
54
+ if (minutes > 0) {
55
+ return `${minutes}m${seconds}s`;
56
+ }
57
+ return `${seconds}s`;
58
+ }
59
+ /**
60
+ * 将本地熔断原因与剩余时间格式化为更直观的状态文本。
61
+ *
62
+ * @param reason 熔断原因。
63
+ * @param until 熔断截止时间,Unix 秒时间戳。
64
+ * @returns 适合在终端表格中展示的状态文本。
65
+ */
66
+ function formatBlockedStatus(reason, until) {
67
+ const label = reason ?? "blocked";
68
+ const remaining = formatRemainingDuration(until ?? null);
69
+ if (!remaining) {
70
+ return label;
71
+ }
72
+ return `${label}(${remaining})`;
73
+ }
34
74
  /**
35
75
  * 汇总所有受管账号的运行状态,供状态展示与调度复用。
36
76
  *
@@ -94,7 +134,7 @@ function renderStatusTable(statuses) {
94
134
  status = "disabled";
95
135
  }
96
136
  else if (item.localBlockUntil && item.localBlockUntil * 1000 > Date.now()) {
97
- status = item.localBlockReason ?? "blocked";
137
+ status = formatBlockedStatus(item.localBlockReason, item.localBlockUntil);
98
138
  }
99
139
  else if (item.isWeeklyLimited) {
100
140
  status = "weekly_limited";
@@ -2,11 +2,15 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.refreshAccountTokens = refreshAccountTokens;
4
4
  exports.refreshAccountUsage = refreshAccountUsage;
5
+ exports.isUsageCacheStale = isUsageCacheStale;
6
+ exports.refreshAccountUsageInBackgroundIfNeeded = refreshAccountUsageInBackgroundIfNeeded;
5
7
  exports.refreshAllAccountUsage = refreshAllAccountUsage;
6
8
  const undici_1 = require("undici");
7
9
  const account_store_1 = require("./account-store");
8
10
  const config_1 = require("./config");
9
11
  const state_1 = require("./state");
12
+ const USAGE_CACHE_TTL_MS = 60 * 1000;
13
+ const inflightUsageRefreshes = new Map();
10
14
  function normalizeResetAt(value, resetAfterSeconds) {
11
15
  if (typeof value === "number" && Number.isFinite(value)) {
12
16
  return value;
@@ -113,11 +117,53 @@ async function refreshAccountUsage(accountId) {
113
117
  fiveHourUsedPercent: payload.rate_limit?.primary_window?.used_percent ?? null,
114
118
  fiveHourResetAt: normalizeResetAt(payload.rate_limit?.primary_window?.reset_at, payload.rate_limit?.primary_window?.reset_after_seconds),
115
119
  weeklyUsedPercent: payload.rate_limit?.secondary_window?.used_percent ?? null,
116
- weeklyResetAt: normalizeResetAt(payload.rate_limit?.secondary_window?.reset_at, payload.rate_limit?.secondary_window?.reset_after_seconds)
120
+ weeklyResetAt: normalizeResetAt(payload.rate_limit?.secondary_window?.reset_at, payload.rate_limit?.secondary_window?.reset_after_seconds),
121
+ refreshedAt: new Date().toISOString()
117
122
  };
118
123
  (0, state_1.setUsageCache)(result);
119
124
  return result;
120
125
  }
126
+ /**
127
+ * 判断指定账号的额度缓存是否已经过期。
128
+ *
129
+ * @param accountId 账号标识。
130
+ * @returns `true` 表示不存在缓存或缓存已超过 TTL,需要重新刷新;`false` 表示缓存仍可直接复用。
131
+ */
132
+ function isUsageCacheStale(accountId) {
133
+ const usageCache = (0, state_1.getUsageCache)(accountId);
134
+ if (!usageCache?.refreshedAt) {
135
+ return true;
136
+ }
137
+ const refreshedAt = Date.parse(usageCache.refreshedAt);
138
+ if (Number.isNaN(refreshedAt)) {
139
+ return true;
140
+ }
141
+ return Date.now() - refreshedAt > USAGE_CACHE_TTL_MS;
142
+ }
143
+ /**
144
+ * 在不阻塞主请求链路的前提下,按需异步刷新指定账号的额度缓存。
145
+ *
146
+ * @param accountId 账号标识。
147
+ * @returns 无返回值;若缓存仍在 TTL 内或已有刷新任务进行中则直接跳过。
148
+ */
149
+ function refreshAccountUsageInBackgroundIfNeeded(accountId) {
150
+ if (!isUsageCacheStale(accountId) || inflightUsageRefreshes.has(accountId)) {
151
+ return;
152
+ }
153
+ // 同一账号同一时刻只保留一个后台刷新任务,避免高并发下重复打远端 usage 接口。
154
+ const refreshTask = (async () => {
155
+ try {
156
+ await refreshAccountUsage(accountId);
157
+ }
158
+ catch {
159
+ // 后台刷新失败时保留旧缓存,由正式转发请求中的错误处理继续兜底。
160
+ }
161
+ finally {
162
+ inflightUsageRefreshes.delete(accountId);
163
+ }
164
+ })();
165
+ inflightUsageRefreshes.set(accountId, refreshTask);
166
+ }
121
167
  /**
122
168
  * 批量刷新所有受管账号的额度信息。
123
169
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openxiaobu/codexl",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "本地 Codex 多账号切换与状态管理工具",
5
5
  "type": "commonjs",
6
6
  "main": "dist/cli.js",