@openxiaobu/codexl 0.1.5 → 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/server.js +59 -10
- package/dist/status.js +41 -1
- package/dist/usage-sync.js +47 -1
- package/package.json +1 -1
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
|
|
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,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
|
|
137
|
+
status = formatBlockedStatus(item.localBlockReason, item.localBlockUntil);
|
|
98
138
|
}
|
|
99
139
|
else if (item.isWeeklyLimited) {
|
|
100
140
|
status = "weekly_limited";
|
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
|
*
|