@openxiaobu/codexl 0.1.5 → 0.1.7
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 +21 -9
- package/dist/cli.js +89 -41
- package/dist/server.js +60 -11
- package/dist/status.js +59 -3
- package/dist/usage-sync.js +47 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
- Reuse the official `~/.codex` login state
|
|
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
13
|
- Apply local block rules for temporary, 5-hour, and weekly limits
|
|
14
|
-
- Automatically switch `~/.codex/config.toml` to the `codexl` provider while the local proxy is running
|
|
14
|
+
- Automatically switch `~/.codex/config.toml` to the `codexl` provider while the local proxy is running (and restore it on stop)
|
|
15
15
|
|
|
16
16
|
## Installation
|
|
17
17
|
|
|
@@ -44,6 +44,21 @@ Check latest usage:
|
|
|
44
44
|
codexl status
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
+
By default, `status` will:
|
|
48
|
+
|
|
49
|
+
- Refresh usage for all managed accounts
|
|
50
|
+
- Render a compact table with:
|
|
51
|
+
- Remaining 5-hour / weekly quotas
|
|
52
|
+
- Reset times
|
|
53
|
+
- A status column with local block reasons and countdowns (for example: `5h_limited(2h27m)`)
|
|
54
|
+
- Enter an interactive mode where you can toggle `enabled` for any account by `NAME`
|
|
55
|
+
|
|
56
|
+
If you only want a non-interactive snapshot of the current state:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
codexl status --no-interactive
|
|
60
|
+
```
|
|
61
|
+
|
|
47
62
|
Start the local proxy:
|
|
48
63
|
|
|
49
64
|
```bash
|
|
@@ -81,24 +96,21 @@ Instead it:
|
|
|
81
96
|
|
|
82
97
|
## Managed Codex Config
|
|
83
98
|
|
|
84
|
-
`codexl start` writes a
|
|
99
|
+
`codexl start` writes or updates a provider block like this, based on the current `~/.codexl/config.yaml`:
|
|
85
100
|
|
|
86
101
|
```toml
|
|
87
|
-
# >>> codexl managed start >>>
|
|
88
102
|
[model_providers.codexl]
|
|
89
103
|
name = "codexl"
|
|
90
104
|
base_url = "http://127.0.0.1:4389/v1"
|
|
91
105
|
http_headers = { Authorization = "Bearer codexl-defaultkey" }
|
|
92
106
|
wire_api = "responses"
|
|
93
|
-
# <<< codexl managed end <<<
|
|
94
107
|
```
|
|
95
108
|
|
|
96
109
|
Behavior:
|
|
97
110
|
|
|
98
|
-
- If `
|
|
99
|
-
- If
|
|
100
|
-
-
|
|
101
|
-
- Global `model` is kept unchanged
|
|
111
|
+
- If global `model_provider` or `# model_provider = ...` exists, it is normalized to `model_provider = "codexl"`
|
|
112
|
+
- If `[model_providers.codexl]` already exists, only that provider block is replaced with the fresh one above
|
|
113
|
+
- Other providers and settings in `config.toml` are left untouched
|
|
102
114
|
- If you start with `--port`, the port is saved to `~/.codexl/config.yaml`
|
|
103
115
|
- `codexl stop` comments out the active `model_provider = "codexl"` line and keeps the rest of the file unchanged
|
|
104
116
|
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
7
7
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
8
|
const node_path_1 = __importDefault(require("node:path"));
|
|
9
9
|
const node_child_process_1 = require("node:child_process");
|
|
10
|
+
const node_readline_1 = __importDefault(require("node:readline"));
|
|
10
11
|
const commander_1 = require("commander");
|
|
11
12
|
const account_store_1 = require("./account-store");
|
|
12
13
|
const config_1 = require("./config");
|
|
@@ -39,7 +40,7 @@ function getCliVersion() {
|
|
|
39
40
|
*
|
|
40
41
|
* @returns Promise,无返回值。
|
|
41
42
|
*/
|
|
42
|
-
async function handleStatus() {
|
|
43
|
+
async function handleStatus(options) {
|
|
43
44
|
await (0, usage_sync_1.refreshAllAccountUsage)();
|
|
44
45
|
const statuses = (0, status_1.collectAccountStatuses)();
|
|
45
46
|
console.log((0, status_1.renderStatusTable)(statuses));
|
|
@@ -48,6 +49,47 @@ async function handleStatus() {
|
|
|
48
49
|
const weeklyLimited = statuses.filter((item) => item.isWeeklyLimited).length;
|
|
49
50
|
console.log("");
|
|
50
51
|
console.log(`available=${available} 5h_limited=${fiveHourLimited} weekly_limited=${weeklyLimited}`);
|
|
52
|
+
const interactive = options?.interactive ?? true;
|
|
53
|
+
if (interactive) {
|
|
54
|
+
await handleInteractiveToggle();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function handleInteractiveToggle() {
|
|
58
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
59
|
+
console.log("当前环境不支持交互式操作,请直接编辑配置文件或使用 --no-interactive 选项。");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const rl = node_readline_1.default.createInterface({
|
|
63
|
+
input: process.stdin,
|
|
64
|
+
output: process.stdout
|
|
65
|
+
});
|
|
66
|
+
const ask = (question) => new Promise((resolve) => {
|
|
67
|
+
rl.question(question, (answer) => resolve(answer));
|
|
68
|
+
});
|
|
69
|
+
try {
|
|
70
|
+
// 允许连续切换多个账号,回车空行退出。
|
|
71
|
+
// 这里使用账号 NAME 作为标识,与 status 表格中的首列一致。
|
|
72
|
+
for (;;) {
|
|
73
|
+
const name = (await ask("输入要切换启用状态的账号 NAME(直接回车退出):")).trim();
|
|
74
|
+
if (!name) {
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
const config = (0, config_1.loadConfig)();
|
|
78
|
+
const index = config.accounts.findIndex((item) => item.name === name);
|
|
79
|
+
if (index < 0) {
|
|
80
|
+
console.log(`未找到 NAME 为 "${name}" 的账号,请检查后重试。`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const account = config.accounts[index];
|
|
84
|
+
account.enabled = !account.enabled;
|
|
85
|
+
config.accounts[index] = account;
|
|
86
|
+
(0, config_1.saveConfig)(config);
|
|
87
|
+
console.log(`账号 ${account.name} 已${account.enabled ? "启用" : "禁用"}(id=${account.id},source=${account.codex_home})。`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
rl.close();
|
|
92
|
+
}
|
|
51
93
|
}
|
|
52
94
|
/**
|
|
53
95
|
* 将已有的 Codex HOME 目录中的登录态复制到 codexl 自己的隔离目录并纳入管理。
|
|
@@ -175,6 +217,15 @@ function handleStop() {
|
|
|
175
217
|
function escapeRegExp(input) {
|
|
176
218
|
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
177
219
|
}
|
|
220
|
+
function ensureParentDir(filePath) {
|
|
221
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(filePath), { recursive: true });
|
|
222
|
+
}
|
|
223
|
+
function writeFileAtomic(targetFile, content) {
|
|
224
|
+
ensureParentDir(targetFile);
|
|
225
|
+
const tmpFile = `${targetFile}.tmp-${process.pid}-${Date.now()}`;
|
|
226
|
+
node_fs_1.default.writeFileSync(tmpFile, content, "utf8");
|
|
227
|
+
node_fs_1.default.renameSync(tmpFile, targetFile);
|
|
228
|
+
}
|
|
178
229
|
/**
|
|
179
230
|
* 返回默认的 `codex config.toml` 路径。
|
|
180
231
|
*
|
|
@@ -184,22 +235,18 @@ function getDefaultCodexConfigPath() {
|
|
|
184
235
|
return node_path_1.default.join(process.env.HOME ?? "", ".codex", "config.toml");
|
|
185
236
|
}
|
|
186
237
|
/**
|
|
187
|
-
* 生成 codexl
|
|
238
|
+
* 生成 codexl provider 配置块。
|
|
188
239
|
*
|
|
189
240
|
* @returns 可直接写入 `config.toml` 的配置块内容。
|
|
190
241
|
*/
|
|
191
242
|
function buildManagedConfigBlock() {
|
|
192
243
|
const config = (0, config_1.loadConfig)();
|
|
193
|
-
const startMarker = "# >>> codexl managed start >>>";
|
|
194
|
-
const endMarker = "# <<< codexl managed end <<<";
|
|
195
244
|
return [
|
|
196
|
-
startMarker,
|
|
197
245
|
"[model_providers.codexl]",
|
|
198
246
|
'name = "codexl"',
|
|
199
247
|
`base_url = "http://${config.server.host}:${config.server.port}/v1"`,
|
|
200
248
|
`http_headers = { Authorization = "Bearer ${config.server.api_key}" }`,
|
|
201
|
-
'wire_api = "responses"'
|
|
202
|
-
endMarker
|
|
249
|
+
'wire_api = "responses"'
|
|
203
250
|
].join("\n");
|
|
204
251
|
}
|
|
205
252
|
/**
|
|
@@ -211,73 +258,71 @@ function buildManagedConfigBlock() {
|
|
|
211
258
|
function applyManagedCodexConfig(targetPathOrDir, options) {
|
|
212
259
|
const rawTarget = targetPathOrDir ? (0, config_1.expandHome)(targetPathOrDir) : getDefaultCodexConfigPath();
|
|
213
260
|
const targetFile = rawTarget.endsWith(".toml") ? rawTarget : node_path_1.default.join(rawTarget, "config.toml");
|
|
214
|
-
const startMarker = "# >>> codexl managed start >>>";
|
|
215
|
-
const endMarker = "# <<< codexl managed end <<<";
|
|
216
261
|
const block = buildManagedConfigBlock();
|
|
217
262
|
let original = "";
|
|
218
263
|
if (node_fs_1.default.existsSync(targetFile)) {
|
|
219
264
|
original = node_fs_1.default.readFileSync(targetFile, "utf8");
|
|
220
265
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const
|
|
225
|
-
const lines = original.replace(managedBlockPattern, "").split(/\r?\n/);
|
|
226
|
-
let insertAfterIndex = -1;
|
|
227
|
-
let hasGlobalModelProvider = false;
|
|
266
|
+
// 更稳定的策略:仅按 provider 名称和 model_provider 改动,不引入额外的 marker。
|
|
267
|
+
const lines = original.length > 0 ? original.split(/\r?\n/) : [];
|
|
268
|
+
let replacedModelProvider = false;
|
|
269
|
+
const modelProviderLine = 'model_provider = "codexl"';
|
|
228
270
|
for (let i = 0; i < lines.length; i += 1) {
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
if (/^#\s*model_provider\s*=/.test(trimmed)) {
|
|
232
|
-
lines[i] =
|
|
233
|
-
|
|
271
|
+
const trimmed = lines[i].trim();
|
|
272
|
+
// 优先替换被注释掉的默认行(只替换第一处,减少对用户文件的干扰)。
|
|
273
|
+
if (!replacedModelProvider && /^#\s*model_provider\s*=/.test(trimmed)) {
|
|
274
|
+
lines[i] = modelProviderLine;
|
|
275
|
+
replacedModelProvider = true;
|
|
234
276
|
continue;
|
|
235
277
|
}
|
|
236
|
-
|
|
278
|
+
// 替换真实生效的 model_provider 行。
|
|
279
|
+
if (/^model_provider\s*=/.test(trimmed) && !trimmed.startsWith("#")) {
|
|
280
|
+
lines[i] = modelProviderLine;
|
|
281
|
+
replacedModelProvider = true;
|
|
237
282
|
continue;
|
|
238
283
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
284
|
+
}
|
|
285
|
+
if (!replacedModelProvider) {
|
|
286
|
+
const firstNonEmptyIndex = lines.findIndex((line) => line.trim() !== "");
|
|
287
|
+
if (firstNonEmptyIndex >= 0) {
|
|
288
|
+
lines.splice(firstNonEmptyIndex, 0, modelProviderLine, "");
|
|
243
289
|
}
|
|
290
|
+
else {
|
|
291
|
+
lines.push(modelProviderLine, "");
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// 替换或追加 [model_providers.codexl] 这一段配置。
|
|
295
|
+
const blockLines = block.split("\n");
|
|
296
|
+
let insertAfterIndex = -1;
|
|
297
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
298
|
+
const trimmed = lines[i].trim();
|
|
244
299
|
if (trimmed === "[model_providers.codexl]") {
|
|
245
300
|
let j = i;
|
|
246
301
|
while (j < lines.length) {
|
|
247
|
-
const
|
|
248
|
-
const currentTrimmed = current.trim();
|
|
302
|
+
const currentTrimmed = lines[j].trim();
|
|
249
303
|
if (j > i && currentTrimmed.startsWith("[") && !currentTrimmed.startsWith("[[")) {
|
|
250
304
|
break;
|
|
251
305
|
}
|
|
252
306
|
insertAfterIndex = j;
|
|
253
307
|
j += 1;
|
|
254
308
|
}
|
|
309
|
+
// 删除旧的 codexl provider 表块,准备写入新的。
|
|
255
310
|
lines.splice(i, j - i);
|
|
256
311
|
insertAfterIndex = i - 1;
|
|
257
312
|
i = j - 1;
|
|
258
313
|
}
|
|
259
314
|
}
|
|
260
|
-
const blockLines = block.split("\n");
|
|
261
315
|
if (insertAfterIndex >= 0) {
|
|
262
316
|
lines.splice(insertAfterIndex + 1, 0, "", ...blockLines);
|
|
263
317
|
}
|
|
264
318
|
else {
|
|
265
|
-
if (!hasGlobalModelProvider) {
|
|
266
|
-
const firstNonEmptyIndex = lines.findIndex((line) => line.trim() !== "");
|
|
267
|
-
if (firstNonEmptyIndex >= 0) {
|
|
268
|
-
lines.splice(firstNonEmptyIndex, 0, 'model_provider = "codexl"', "");
|
|
269
|
-
}
|
|
270
|
-
else {
|
|
271
|
-
lines.push('model_provider = "codexl"', "");
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
319
|
if (lines.length > 0 && lines[lines.length - 1].trim() !== "") {
|
|
275
320
|
lines.push("");
|
|
276
321
|
}
|
|
277
322
|
lines.push(...blockLines);
|
|
278
323
|
}
|
|
279
324
|
const nextContent = `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
|
|
280
|
-
|
|
325
|
+
writeFileAtomic(targetFile, nextContent);
|
|
281
326
|
if (!options?.silent) {
|
|
282
327
|
const config = (0, config_1.loadConfig)();
|
|
283
328
|
console.log(`已写入: ${targetFile}`);
|
|
@@ -312,6 +357,8 @@ function deactivateManagedCodexConfig() {
|
|
|
312
357
|
*/
|
|
313
358
|
async function main() {
|
|
314
359
|
const program = new commander_1.Command();
|
|
360
|
+
// 禁用内置 help 子命令,仅保留 --help / -h 形式。
|
|
361
|
+
program.addHelpCommand(false);
|
|
315
362
|
(0, config_1.getCodexSwHome)();
|
|
316
363
|
(0, config_1.loadConfig)();
|
|
317
364
|
program
|
|
@@ -339,8 +386,9 @@ async function main() {
|
|
|
339
386
|
program
|
|
340
387
|
.command("status")
|
|
341
388
|
.description("刷新并查看所有已录入账号或工作空间的最新额度")
|
|
342
|
-
.
|
|
343
|
-
|
|
389
|
+
.option("--no-interactive", "仅输出状态表,不进入交互式切换")
|
|
390
|
+
.action(async (options) => {
|
|
391
|
+
await handleStatus(options);
|
|
344
392
|
});
|
|
345
393
|
program
|
|
346
394
|
.command("start")
|
package/dist/server.js
CHANGED
|
@@ -49,7 +49,7 @@ function resolveBlockWindow(picked, errorText) {
|
|
|
49
49
|
picked.status.isFiveHourLimited) {
|
|
50
50
|
return {
|
|
51
51
|
until: picked.status.fiveHourResetsAt ?? Math.floor(Date.now() / 1000) + 5 * 60,
|
|
52
|
-
reason: "
|
|
52
|
+
reason: "5h_limited"
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
return {
|
|
@@ -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
|
|
149
|
-
|
|
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,62 @@ function formatReset(unixSeconds) {
|
|
|
31
31
|
hour12: false
|
|
32
32
|
});
|
|
33
33
|
}
|
|
34
|
+
function formatLimitStatus(label, resetAt) {
|
|
35
|
+
const remaining = formatRemainingDuration(resetAt);
|
|
36
|
+
if (!remaining) {
|
|
37
|
+
return label;
|
|
38
|
+
}
|
|
39
|
+
return `${label}(${remaining})`;
|
|
40
|
+
}
|
|
41
|
+
function normalizeBlockReason(reason) {
|
|
42
|
+
if (!reason) {
|
|
43
|
+
return "blocked";
|
|
44
|
+
}
|
|
45
|
+
if (reason === "five_hour_limited") {
|
|
46
|
+
return "5h_limited";
|
|
47
|
+
}
|
|
48
|
+
return reason;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 将剩余秒数格式化为紧凑的人类可读文本,便于在状态列中展示熔断剩余时间。
|
|
52
|
+
*
|
|
53
|
+
* @param unixSeconds 熔断截止时间,Unix 秒时间戳。
|
|
54
|
+
* @returns 格式化后的剩余时长;当时间为空或已过期时返回 `null`。
|
|
55
|
+
*/
|
|
56
|
+
function formatRemainingDuration(unixSeconds) {
|
|
57
|
+
if (!unixSeconds) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const diffSeconds = unixSeconds - Math.floor(Date.now() / 1000);
|
|
61
|
+
if (diffSeconds <= 0) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const hours = Math.floor(diffSeconds / 3600);
|
|
65
|
+
const minutes = Math.floor((diffSeconds % 3600) / 60);
|
|
66
|
+
const seconds = diffSeconds % 60;
|
|
67
|
+
if (hours > 0) {
|
|
68
|
+
return `${hours}h${minutes}m`;
|
|
69
|
+
}
|
|
70
|
+
if (minutes > 0) {
|
|
71
|
+
return `${minutes}m${seconds}s`;
|
|
72
|
+
}
|
|
73
|
+
return `${seconds}s`;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 将本地熔断原因与剩余时间格式化为更直观的状态文本。
|
|
77
|
+
*
|
|
78
|
+
* @param reason 熔断原因。
|
|
79
|
+
* @param until 熔断截止时间,Unix 秒时间戳。
|
|
80
|
+
* @returns 适合在终端表格中展示的状态文本。
|
|
81
|
+
*/
|
|
82
|
+
function formatBlockedStatus(reason, until) {
|
|
83
|
+
const label = normalizeBlockReason(reason);
|
|
84
|
+
const remaining = formatRemainingDuration(until ?? null);
|
|
85
|
+
if (!remaining) {
|
|
86
|
+
return label;
|
|
87
|
+
}
|
|
88
|
+
return `${label}(${remaining})`;
|
|
89
|
+
}
|
|
34
90
|
/**
|
|
35
91
|
* 汇总所有受管账号的运行状态,供状态展示与调度复用。
|
|
36
92
|
*
|
|
@@ -94,13 +150,13 @@ function renderStatusTable(statuses) {
|
|
|
94
150
|
status = "disabled";
|
|
95
151
|
}
|
|
96
152
|
else if (item.localBlockUntil && item.localBlockUntil * 1000 > Date.now()) {
|
|
97
|
-
status = item.localBlockReason
|
|
153
|
+
status = formatBlockedStatus(item.localBlockReason, item.localBlockUntil);
|
|
98
154
|
}
|
|
99
155
|
else if (item.isWeeklyLimited) {
|
|
100
|
-
status = "weekly_limited";
|
|
156
|
+
status = formatLimitStatus("weekly_limited", item.weeklyResetsAt);
|
|
101
157
|
}
|
|
102
158
|
else if (item.isFiveHourLimited) {
|
|
103
|
-
status = "5h_limited";
|
|
159
|
+
status = formatLimitStatus("5h_limited", item.fiveHourResetsAt);
|
|
104
160
|
}
|
|
105
161
|
else if (item.isAvailable) {
|
|
106
162
|
status = "available";
|
package/dist/usage-sync.js
CHANGED
|
@@ -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
|
*
|