@openxiaobu/codexl 0.1.12 → 0.1.14
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 +2 -3
- package/dist/cli.js +119 -72
- package/dist/scheduler.js +30 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -53,9 +53,8 @@ By default, `status` will:
|
|
|
53
53
|
- A status column with local block reasons and countdowns (for example: `5h_limited(2h27m)`)
|
|
54
54
|
- Enter an interactive mode where you can toggle `enabled` for accounts:
|
|
55
55
|
- Up/Down: move selection
|
|
56
|
-
- Space: toggle `[x]` enabled / `[ ]` disabled
|
|
57
|
-
- Enter
|
|
58
|
-
- `q`: quit
|
|
56
|
+
- Space: toggle `[x]` enabled / `[ ]` disabled and save immediately
|
|
57
|
+
- Enter / `q`: exit the interactive mode
|
|
59
58
|
|
|
60
59
|
If you only want a non-interactive snapshot of the current state:
|
|
61
60
|
|
package/dist/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ const commander_1 = require("commander");
|
|
|
12
12
|
const account_store_1 = require("./account-store");
|
|
13
13
|
const config_1 = require("./config");
|
|
14
14
|
const login_1 = require("./login");
|
|
15
|
+
const scheduler_1 = require("./scheduler");
|
|
15
16
|
const status_1 = require("./status");
|
|
16
17
|
const usage_sync_1 = require("./usage-sync");
|
|
17
18
|
/**
|
|
@@ -43,18 +44,32 @@ function getCliVersion() {
|
|
|
43
44
|
async function handleStatus(options) {
|
|
44
45
|
await (0, usage_sync_1.refreshAllAccountUsage)();
|
|
45
46
|
const statuses = (0, status_1.collectAccountStatuses)();
|
|
46
|
-
|
|
47
|
+
const interactive = options?.interactive ?? true;
|
|
48
|
+
if (interactive) {
|
|
49
|
+
await handleInteractiveToggle(statuses);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const selected = (0, scheduler_1.pickBestAccount)();
|
|
53
|
+
const displayStatuses = statuses.map((item) => ({
|
|
54
|
+
...item,
|
|
55
|
+
name: item.id === selected?.account.id ? `${item.name}*` : item.name
|
|
56
|
+
}));
|
|
47
57
|
const available = statuses.filter((item) => item.isAvailable).length;
|
|
48
58
|
const fiveHourLimited = statuses.filter((item) => item.isFiveHourLimited && !item.isWeeklyLimited).length;
|
|
49
59
|
const weeklyLimited = statuses.filter((item) => item.isWeeklyLimited).length;
|
|
60
|
+
console.log((0, status_1.renderStatusTable)(displayStatuses));
|
|
50
61
|
console.log("");
|
|
51
62
|
console.log(`available=${available} 5h_limited=${fiveHourLimited} weekly_limited=${weeklyLimited}`);
|
|
52
|
-
|
|
53
|
-
if (interactive) {
|
|
54
|
-
await handleInteractiveToggle();
|
|
55
|
-
}
|
|
63
|
+
console.log(`selected=${selected ? selected.account.name : "none"}`);
|
|
56
64
|
}
|
|
57
|
-
|
|
65
|
+
/**
|
|
66
|
+
* 进入账号启用状态的交互式切换界面,并在用户确认退出后恢复终端状态。
|
|
67
|
+
*
|
|
68
|
+
* @param initialStatuses 进入交互前刚刷新的账号状态快照,用于首屏复用同一块展示区域。
|
|
69
|
+
* @returns Promise,在用户按下 `Enter`、`q` 或 `Ctrl+C` 退出交互后完成。
|
|
70
|
+
* @throws 无显式抛出;终端读写异常将沿调用链透出。
|
|
71
|
+
*/
|
|
72
|
+
async function handleInteractiveToggle(initialStatuses) {
|
|
58
73
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
59
74
|
console.log("当前环境不支持交互式操作,请直接编辑配置文件或使用 --no-interactive 选项。");
|
|
60
75
|
return;
|
|
@@ -73,79 +88,111 @@ async function handleInteractiveToggle() {
|
|
|
73
88
|
let cursor = 0;
|
|
74
89
|
let changed = false;
|
|
75
90
|
let renderedLines = 0;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
91
|
+
return await new Promise((resolve) => {
|
|
92
|
+
let closed = false;
|
|
93
|
+
const render = () => {
|
|
94
|
+
const latestStatuses = (0, status_1.collectAccountStatuses)();
|
|
95
|
+
const selected = (0, scheduler_1.pickBestAccount)();
|
|
96
|
+
const statusSource = changed ? latestStatuses : (initialStatuses ?? latestStatuses);
|
|
97
|
+
const displayStatuses = statusSource.map((item) => ({
|
|
98
|
+
...item,
|
|
99
|
+
name: item.id === selected?.account.id ? `${item.name}*` : item.name
|
|
100
|
+
}));
|
|
101
|
+
const available = statusSource.filter((item) => item.isAvailable).length;
|
|
102
|
+
const fiveHourLimited = statusSource.filter((item) => item.isFiveHourLimited && !item.isWeeklyLimited).length;
|
|
103
|
+
const weeklyLimited = statusSource.filter((item) => item.isWeeklyLimited).length;
|
|
104
|
+
const autoSelectedId = selected?.account.id ?? null;
|
|
105
|
+
const lines = [
|
|
106
|
+
(0, status_1.renderStatusTable)(displayStatuses),
|
|
107
|
+
"",
|
|
108
|
+
`available=${available} 5h_limited=${fiveHourLimited} weekly_limited=${weeklyLimited}`,
|
|
109
|
+
`selected=${selected ? selected.account.name : "none"}`,
|
|
110
|
+
"",
|
|
111
|
+
"空格切换选中账号启用状态,Enter / q 退出:"
|
|
112
|
+
];
|
|
113
|
+
for (let i = 0; i < accounts.length; i += 1) {
|
|
114
|
+
const account = accounts[i];
|
|
115
|
+
const checkbox = account.enabled ? "[x]" : "[ ]";
|
|
116
|
+
const displayName = account.id === autoSelectedId ? `${account.name}*` : account.name;
|
|
117
|
+
const cursorSuffix = i === cursor ? " <" : "";
|
|
118
|
+
lines.push(`${checkbox} ${displayName} (${account.codex_home})${cursorSuffix}`);
|
|
119
|
+
}
|
|
120
|
+
// 首次渲染时先换一行,避免粘在上一行输出后面。
|
|
121
|
+
if (renderedLines === 0) {
|
|
122
|
+
process.stdout.write("\n");
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
// 将光标移动到上一轮渲染块的起始行,保证整块内容原地刷新。
|
|
126
|
+
process.stdout.write(`\x1b[${renderedLines}A`);
|
|
127
|
+
}
|
|
128
|
+
renderedLines = lines.length;
|
|
129
|
+
process.stdout.write("\x1b[J");
|
|
130
|
+
process.stdout.write(lines.join("\n"));
|
|
87
131
|
process.stdout.write("\n");
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
renderedLines = lines.length;
|
|
94
|
-
process.stdout.write(lines.join("\n"));
|
|
95
|
-
process.stdout.write("\n");
|
|
96
|
-
};
|
|
97
|
-
const applyChanges = () => {
|
|
98
|
-
if (!changed)
|
|
99
|
-
return;
|
|
100
|
-
const latest = (0, config_1.loadConfig)();
|
|
101
|
-
for (const account of accounts) {
|
|
102
|
-
const index = latest.accounts.findIndex((item) => item.id === account.id);
|
|
103
|
-
if (index >= 0) {
|
|
104
|
-
latest.accounts[index] = account;
|
|
132
|
+
};
|
|
133
|
+
const applyChanges = () => {
|
|
134
|
+
if (!changed) {
|
|
135
|
+
return;
|
|
105
136
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
if (key.name === "space") {
|
|
123
|
-
accounts[cursor].enabled = !accounts[cursor].enabled;
|
|
124
|
-
changed = true;
|
|
125
|
-
applyChanges();
|
|
126
|
-
render();
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
if (key.name === "return" || key.name === "enter") {
|
|
130
|
-
if (changed) {
|
|
131
|
-
applyChanges();
|
|
137
|
+
const latest = (0, config_1.loadConfig)();
|
|
138
|
+
for (const account of accounts) {
|
|
139
|
+
const index = latest.accounts.findIndex((item) => item.id === account.id);
|
|
140
|
+
if (index >= 0) {
|
|
141
|
+
latest.accounts[index] = account;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
(0, config_1.saveConfig)(latest);
|
|
145
|
+
changed = false;
|
|
146
|
+
initialStatuses = (0, status_1.collectAccountStatuses)();
|
|
147
|
+
};
|
|
148
|
+
const exitInteractive = () => {
|
|
149
|
+
if (closed) {
|
|
150
|
+
return;
|
|
132
151
|
}
|
|
152
|
+
closed = true;
|
|
153
|
+
// 退出前先持久化本轮勾选变更,避免用户误以为切换未生效。
|
|
154
|
+
applyChanges();
|
|
133
155
|
stdin.off("keypress", onKeypress);
|
|
134
156
|
stdin.setRawMode?.(false);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
157
|
+
stdin.pause();
|
|
158
|
+
// 将光标移回交互块顶部并清空,确保命令行提示符直接回到正常位置。
|
|
159
|
+
if (renderedLines > 0) {
|
|
160
|
+
process.stdout.write(`\x1b[${renderedLines}A`);
|
|
161
|
+
process.stdout.write("\x1b[J");
|
|
162
|
+
}
|
|
163
|
+
console.log("已退出账号启用状态编辑。");
|
|
164
|
+
resolve();
|
|
165
|
+
};
|
|
166
|
+
const onKeypress = (_str, key) => {
|
|
167
|
+
if (key.name === "up") {
|
|
168
|
+
cursor = (cursor - 1 + accounts.length) % accounts.length;
|
|
169
|
+
render();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (key.name === "down") {
|
|
173
|
+
cursor = (cursor + 1) % accounts.length;
|
|
174
|
+
render();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (key.name === "space") {
|
|
178
|
+
// 空格直接切换当前选中账号的启用状态,并立即写回配置。
|
|
179
|
+
accounts[cursor].enabled = !accounts[cursor].enabled;
|
|
180
|
+
changed = true;
|
|
140
181
|
applyChanges();
|
|
182
|
+
render();
|
|
183
|
+
return;
|
|
141
184
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
185
|
+
if (key.name === "return" || key.name === "enter") {
|
|
186
|
+
exitInteractive();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (key.name === "q" || (key.ctrl && key.name === "c")) {
|
|
190
|
+
exitInteractive();
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
render();
|
|
194
|
+
stdin.on("keypress", onKeypress);
|
|
195
|
+
});
|
|
149
196
|
}
|
|
150
197
|
/**
|
|
151
198
|
* 将已有的 Codex HOME 目录中的登录态复制到 codexl 自己的隔离目录并纳入管理。
|
package/dist/scheduler.js
CHANGED
|
@@ -11,6 +11,26 @@ function nextResetWeight(resetAt) {
|
|
|
11
11
|
const diff = resetAt * 1000 - Date.now();
|
|
12
12
|
return diff > 0 ? diff : Number.MAX_SAFE_INTEGER;
|
|
13
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
|
+
}
|
|
14
34
|
/**
|
|
15
35
|
* 选择当前最适合激活的账号。
|
|
16
36
|
*
|
|
@@ -33,6 +53,7 @@ function listCandidateAccounts() {
|
|
|
33
53
|
const config = (0, config_1.loadConfig)();
|
|
34
54
|
const statuses = (0, status_1.collectAccountStatuses)();
|
|
35
55
|
const accountMap = new Map(config.accounts.map((item) => [item.id, item]));
|
|
56
|
+
const eligible = statuses.filter((item) => item.enabled && item.exists && !item.isFiveHourLimited && !item.isWeeklyLimited);
|
|
36
57
|
const available = statuses
|
|
37
58
|
.filter((item) => item.isAvailable)
|
|
38
59
|
.sort((left, right) => {
|
|
@@ -46,7 +67,12 @@ function listCandidateAccounts() {
|
|
|
46
67
|
}
|
|
47
68
|
return nextResetWeight(left.fiveHourResetsAt) - nextResetWeight(right.fiveHourResetsAt);
|
|
48
69
|
});
|
|
49
|
-
|
|
70
|
+
const ranked = available.length > 0 ? available : [];
|
|
71
|
+
// 当系统只剩一个具备真实凭据且未命中额度限制的账号时,允许忽略短期本地熔断继续兜底尝试。
|
|
72
|
+
if (ranked.length === 0 && eligible.length === 1 && isSoftLocalBlocked(eligible[0])) {
|
|
73
|
+
ranked.push(eligible[0]);
|
|
74
|
+
}
|
|
75
|
+
return ranked
|
|
50
76
|
.map((winner) => {
|
|
51
77
|
const account = accountMap.get(winner.id);
|
|
52
78
|
if (!account) {
|
|
@@ -55,7 +81,9 @@ function listCandidateAccounts() {
|
|
|
55
81
|
return {
|
|
56
82
|
account,
|
|
57
83
|
status: winner,
|
|
58
|
-
reason:
|
|
84
|
+
reason: winner.isAvailable
|
|
85
|
+
? "优先选择 5 小时窗口剩余额度最高且当前可用的账号"
|
|
86
|
+
: "当前仅剩一个可调度账号,忽略短期本地熔断后继续兜底尝试"
|
|
59
87
|
};
|
|
60
88
|
})
|
|
61
89
|
.filter((item) => item !== null);
|