codex-slot 0.1.18 → 0.1.20

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/scheduler.js CHANGED
@@ -3,62 +3,103 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.pickBestAccount = pickBestAccount;
4
4
  exports.listCandidateAccounts = listCandidateAccounts;
5
5
  const config_1 = require("./config");
6
+ const scheduler_strategy_1 = require("./scheduler-strategy");
6
7
  const status_1 = require("./status");
7
- function nextResetWeight(resetAt) {
8
- if (!resetAt) {
9
- return Number.MAX_SAFE_INTEGER;
10
- }
11
- const diff = resetAt * 1000 - Date.now();
12
- return diff > 0 ? diff : Number.MAX_SAFE_INTEGER;
8
+ const state_repository_1 = require("./state-repository");
9
+ /**
10
+ * 构建一次账号调度所需的完整上下文快照。
11
+ *
12
+ * 业务含义:
13
+ * 1. 调度过程中所有配置、状态与统计都来自同一次快照,避免评分时反复读取文件。
14
+ * 2. 该函数是调度应用层边界,负责把外部存储数据转成纯策略可消费的输入。
15
+ *
16
+ * @returns 调度上下文,包含账号配置、运行状态、使用统计与统一时间基准。
17
+ * @throws 当配置、状态或调度统计读取失败时透传底层异常。
18
+ */
19
+ function buildSchedulerContext() {
20
+ const config = (0, config_1.loadConfig)();
21
+ return {
22
+ accounts: config.accounts,
23
+ statuses: (0, status_1.collectAccountStatuses)(),
24
+ schedulerStats: (0, state_repository_1.loadSchedulerStatsSnapshot)(),
25
+ nowMs: Date.now()
26
+ };
13
27
  }
14
28
  /**
15
- * 判断账号当前是否仅命中可忽略的短期本地熔断。
29
+ * 过滤当前可直接参与调度的账号状态。
16
30
  *
17
- * 这类熔断通常由瞬时网络抖动、上游 5xx 或短暂 token 刷新失败触发,
18
- * 当系统只剩一个可调度账号时,不应因此立刻把它排除掉。
31
+ * 业务含义:
32
+ * 1. 只保留已启用、登录态完整、未触发刷新错误、未限额、未本地熔断的账号。
33
+ * 2. 具体排序不在这里处理,排序交给纯调度策略。
19
34
  *
20
- * @param status 账号运行时状态。
21
- * @returns `true` 表示仅存在可回退的短期本地熔断;否则返回 `false`。
35
+ * @param statuses 当前账号运行状态快照。
36
+ * @returns 可直接调度的账号状态列表。
37
+ * @throws 无显式抛出。
22
38
  */
23
- function isSoftLocalBlocked(status) {
24
- if (!status.localBlockUntil || status.localBlockUntil * 1000 <= Date.now()) {
25
- return false;
39
+ function resolveAvailableStatuses(statuses) {
40
+ return statuses.filter((item) => item.isAvailable);
41
+ }
42
+ /**
43
+ * 过滤软熔断兜底候选账号。
44
+ *
45
+ * 业务含义:
46
+ * 1. 当所有未限额账号都只剩短期软熔断时,允许这些账号进入兜底调度。
47
+ * 2. 硬限额、禁用、登录态缺失仍不会进入兜底池。
48
+ *
49
+ * @param statuses 当前账号运行状态快照。
50
+ * @returns 可用于软熔断兜底的账号状态列表;不满足兜底条件时返回空列表。
51
+ * @throws 无显式抛出。
52
+ */
53
+ function resolveSoftFallbackStatuses(statuses) {
54
+ const eligible = statuses.filter((item) => item.enabled && item.exists && !item.isFiveHourLimited && !item.isWeeklyLimited);
55
+ if (eligible.length === 0 || !eligible.every(scheduler_strategy_1.isSoftLocalBlocked)) {
56
+ return [];
26
57
  }
27
- return [
28
- "request_failed",
29
- "upstream_5xx",
30
- "temporary_5m_limit",
31
- "token_refresh_failed"
32
- ].includes(status.localBlockReason ?? "");
58
+ return eligible;
33
59
  }
34
60
  /**
35
- * 对未命中额度限制的候选账号按剩余额度与重置时间排序。
61
+ * 将纯调度决策映射为包含账号配置的调度结果。
36
62
  *
37
- * @param statuses 待排序的账号状态列表。
38
- * @returns 排序后的账号状态列表,优先返回更适合尝试的账号。
63
+ * 业务含义:
64
+ * 1. 调度策略只认识运行状态,不直接依赖配置存储。
65
+ * 2. 应用层在这里补齐账号 HOME、名称等后续代理链路需要的配置字段。
66
+ *
67
+ * @param decisions 纯策略返回的调度决策列表。
68
+ * @param accountMap 账号配置索引;key 为账号 id。
69
+ * @param fallback 是否来自软熔断兜底调度。
70
+ * @returns 可供代理或 CLI 使用的账号候选列表。
71
+ * @throws 无显式抛出。
39
72
  */
40
- 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;
73
+ function mapDecisionsToPicks(decisions, accountMap, fallback) {
74
+ return decisions
75
+ .map((decision) => {
76
+ const account = accountMap.get(decision.status.id);
77
+ if (!account) {
78
+ return null;
49
79
  }
50
- return nextResetWeight(left.fiveHourResetsAt) - nextResetWeight(right.fiveHourResetsAt);
51
- });
80
+ const pick = {
81
+ account,
82
+ status: decision.status,
83
+ reason: fallback
84
+ ? `当前仅剩软熔断账号,兜底尝试;${decision.reason}`
85
+ : decision.reason,
86
+ score: decision.score,
87
+ breakdown: decision.breakdown
88
+ };
89
+ return pick;
90
+ })
91
+ .filter((item) => item !== null);
52
92
  }
53
93
  /**
54
94
  * 选择当前最适合激活的账号。
55
95
  *
56
96
  * 业务规则:
57
- * 1. 仅在账号启用且存在凭据时参与调度。
58
- * 2. 优先选择当前 5 小时和周窗口都未受限的账号。
59
- * 3. 在多个可用账号间,优先选择 5 小时剩余额度更高的账号。
97
+ * 1. 仅在账号启用且存在完整凭据时参与调度。
98
+ * 2. 优先避免周窗口额度浪费,再在周额度健康时消耗 5 小时窗口余额。
99
+ * 3. 同等条件下根据本地成功使用统计做均匀分摊。
60
100
  *
61
101
  * @returns 调度结果;若没有可用账号则返回 `null`。
102
+ * @throws 当配置、状态或调度统计读取失败时透传底层异常。
62
103
  */
63
104
  function pickBestAccount() {
64
105
  return listCandidateAccounts()[0] ?? null;
@@ -67,31 +108,15 @@ function pickBestAccount() {
67
108
  * 返回按优先级排序后的可用账号列表,供代理重试链路使用。
68
109
  *
69
110
  * @returns 候选账号列表,已按优先级从高到低排序。
111
+ * @throws 当配置、状态或调度统计读取失败时透传底层异常。
70
112
  */
71
113
  function listCandidateAccounts() {
72
- const config = (0, config_1.loadConfig)();
73
- const statuses = (0, status_1.collectAccountStatuses)();
74
- const accountMap = new Map(config.accounts.map((item) => [item.id, item]));
75
- const eligible = statuses.filter((item) => item.enabled && item.exists && !item.isFiveHourLimited && !item.isWeeklyLimited);
76
- const available = rankEligibleStatuses(statuses.filter((item) => item.isAvailable));
77
- const ranked = available.length > 0 ? available : [];
78
- // 当所有未限额账号都只命中短期本地熔断时,仍允许继续兜底尝试,避免把网络抖动误判成“无可用账号”。
79
- if (ranked.length === 0 && eligible.length > 0 && eligible.every(isSoftLocalBlocked)) {
80
- ranked.push(...rankEligibleStatuses(eligible));
114
+ const context = buildSchedulerContext();
115
+ const accountMap = new Map(context.accounts.map((item) => [item.id, item]));
116
+ const availableStatuses = resolveAvailableStatuses(context.statuses);
117
+ if (availableStatuses.length > 0) {
118
+ return mapDecisionsToPicks((0, scheduler_strategy_1.rankAccountStatuses)(availableStatuses, context.schedulerStats, context.nowMs), accountMap, false);
81
119
  }
82
- return ranked
83
- .map((winner) => {
84
- const account = accountMap.get(winner.id);
85
- if (!account) {
86
- return null;
87
- }
88
- return {
89
- account,
90
- status: winner,
91
- reason: winner.isAvailable
92
- ? "优先选择 5 小时窗口剩余额度最高且当前可用的账号"
93
- : "当前仅剩一个可调度账号,忽略短期本地熔断后继续兜底尝试"
94
- };
95
- })
96
- .filter((item) => item !== null);
120
+ const fallbackStatuses = resolveSoftFallbackStatuses(context.statuses);
121
+ return mapDecisionsToPicks((0, scheduler_strategy_1.rankAccountStatuses)(fallbackStatuses, context.schedulerStats, context.nowMs), accountMap, true);
97
122
  }
package/dist/server.js CHANGED
@@ -5,12 +5,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.startServer = startServer;
7
7
  const fastify_1 = __importDefault(require("fastify"));
8
- const undici_1 = require("undici");
9
- const account_store_1 = require("./account-store");
10
8
  const config_1 = require("./config");
9
+ const proxy_retry_service_1 = require("./proxy-retry-service");
11
10
  const status_1 = require("./status");
12
11
  const scheduler_1 = require("./scheduler");
13
- const state_1 = require("./state");
14
12
  const text_1 = require("./text");
15
13
  const usage_sync_1 = require("./usage-sync");
16
14
  function getBearerToken(headerValue) {
@@ -20,146 +18,6 @@ function getBearerToken(headerValue) {
20
18
  const match = /^Bearer\s+(.+)$/i.exec(headerValue);
21
19
  return match?.[1] ?? null;
22
20
  }
23
- /**
24
- * 根据错误文本与当前账号状态,决定本地禁用时长。
25
- *
26
- * 业务规则:
27
- * 1. 周限制优先,直到周窗口重置时间。
28
- * 2. 5 小时额度限制次之,直到 5 小时窗口重置时间。
29
- * 3. 未能明确识别时,按 5 分钟临时熔断处理。
30
- *
31
- * @param picked 当前被选中的账号及状态。
32
- * @param errorText 上游返回的错误文本。
33
- * @returns 本地禁用窗口与原因。
34
- */
35
- function resolveBlockWindow(picked, errorText) {
36
- const lowerText = errorText.toLowerCase();
37
- if (lowerText.includes("weekly") ||
38
- lowerText.includes("7 day") ||
39
- lowerText.includes("7-day") ||
40
- picked.status.isWeeklyLimited) {
41
- return {
42
- until: picked.status.weeklyResetsAt ?? Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60,
43
- reason: "weekly_limited"
44
- };
45
- }
46
- if (lowerText.includes("5 hour") ||
47
- lowerText.includes("5-hour") ||
48
- lowerText.includes("5h") ||
49
- lowerText.includes("usage limit") ||
50
- picked.status.isFiveHourLimited) {
51
- return {
52
- until: picked.status.fiveHourResetsAt ?? Math.floor(Date.now() / 1000) + 5 * 60,
53
- reason: "5h_limited"
54
- };
55
- }
56
- return {
57
- until: Math.floor(Date.now() / 1000) + 5 * 60,
58
- reason: "temporary_5m_limit"
59
- };
60
- }
61
- /**
62
- * 为当前请求失败的账号设置临时熔断状态,避免短时间内被重复选中。
63
- *
64
- * @param accountId 账号标识。
65
- * @param reason 本地状态中记录的失败原因。
66
- * @param blockSeconds 熔断持续秒数。
67
- * @returns 无返回值。
68
- */
69
- function markAccountFailure(accountId, reason, blockSeconds) {
70
- // 请求链路中的短期失败通常是瞬时异常,记录一个较短的本地熔断窗口即可。
71
- (0, state_1.setAccountBlock)(accountId, Math.floor(Date.now() / 1000) + blockSeconds, reason);
72
- }
73
- /**
74
- * 提取错误对象中最接近底层网络层的错误码,便于区分网络不可达与上游业务异常。
75
- *
76
- * @param error 捕获到的异常对象。
77
- * @returns 错误码;若无法识别则返回 `null`。
78
- */
79
- function extractErrorCode(error) {
80
- if (!error || typeof error !== "object") {
81
- return null;
82
- }
83
- const errnoError = error;
84
- if (typeof errnoError.code === "string" && errnoError.code.length > 0) {
85
- return errnoError.code;
86
- }
87
- return extractErrorCode(errnoError.cause);
88
- }
89
- /**
90
- * 判断一次请求失败是否属于本机到上游之间的网络不可达场景。
91
- *
92
- * @param error 捕获到的异常对象。
93
- * @returns `true` 表示网络层异常,不应写入账号熔断;否则返回 `false`。
94
- */
95
- function isNetworkUnavailableError(error) {
96
- const errorCode = extractErrorCode(error);
97
- return [
98
- "ECONNREFUSED",
99
- "ECONNRESET",
100
- "EHOSTUNREACH",
101
- "ENETDOWN",
102
- "ENETUNREACH",
103
- "ENOTFOUND",
104
- "EAI_AGAIN",
105
- "ETIMEDOUT",
106
- "UND_ERR_CONNECT_TIMEOUT",
107
- "UND_ERR_SOCKET"
108
- ].includes(errorCode ?? "");
109
- }
110
- /**
111
- * 将网络层异常转换为统一的响应体,避免误导成“当前没有可用账号”。
112
- *
113
- * @param accountId 当前尝试的账号标识。
114
- * @param error 捕获到的异常对象。
115
- * @returns 统一的网络异常响应体。
116
- */
117
- function buildNetworkUnavailablePayload(accountId, error) {
118
- const message = error instanceof Error ? error.message : String(error);
119
- return {
120
- error: {
121
- message: (0, text_1.bi)(`网络不可用,账号 ${accountId} 无法连接上游: ${message}`, `Network unavailable. Account ${accountId} cannot reach upstream: ${message}`),
122
- type: "network_unavailable"
123
- }
124
- };
125
- }
126
- /**
127
- * 构造发往上游的请求头,并移除仅属于本地代理链路的头信息。
128
- *
129
- * @param requestHeaders 客户端发到本地服务的原始请求头。
130
- * @param accessToken 当前候选账号可用的上游访问令牌。
131
- * @param accountIdHeader 可选的 ChatGPT 账号标识头。
132
- * @returns 可直接传给上游请求的请求头对象。
133
- */
134
- function buildUpstreamHeaders(requestHeaders, accessToken, bodyLength, accountIdHeader) {
135
- const headers = {};
136
- for (const [headerName, headerValue] of Object.entries(requestHeaders)) {
137
- const normalizedName = headerName.toLowerCase();
138
- if (headerValue == null ||
139
- normalizedName === "authorization" ||
140
- normalizedName === "host" ||
141
- normalizedName === "connection" ||
142
- normalizedName === "content-length") {
143
- continue;
144
- }
145
- headers[normalizedName] = Array.isArray(headerValue)
146
- ? headerValue.join(", ")
147
- : headerValue;
148
- }
149
- // 本地服务使用独立 api_key 鉴权,转发时必须替换为真实上游 access token。
150
- headers.authorization = `Bearer ${accessToken}`;
151
- // 未显式传入 Accept 时,补上兼容 SSE 与 JSON 的默认值。
152
- if (!headers.accept) {
153
- headers.accept = "text/event-stream, application/json";
154
- }
155
- // body 会在本地先读取成 Buffer 以支持失败后切换账号重试,因此这里重算长度。
156
- headers["content-length"] = String(bodyLength);
157
- headers["user-agent"] = "codex-slot/0.1.1";
158
- if (accountIdHeader) {
159
- headers["chatgpt-account-id"] = accountIdHeader;
160
- }
161
- return headers;
162
- }
163
21
  /**
164
22
  * 读取代理请求的原始 body 字节,供多账号重试时重复发送同一份载荷。
165
23
  *
@@ -199,8 +57,12 @@ async function startServer(port) {
199
57
  }
200
58
  const bearer = getBearerToken(request.headers.authorization);
201
59
  if (bearer !== config.server.api_key) {
202
- reply.code(401);
203
- throw new Error((0, text_1.bi)("本地 API Key 无效", "Invalid local API key"));
60
+ return reply.code(401).send({
61
+ error: {
62
+ message: (0, text_1.bi)("本地 API Key 无效", "Invalid local API key"),
63
+ type: "invalid_local_api_key"
64
+ }
65
+ });
204
66
  }
205
67
  });
206
68
  app.get("/health", async () => {
@@ -218,153 +80,21 @@ async function startServer(port) {
218
80
  });
219
81
  const proxyHandler = async (requestBodyStream, requestHeaders, reply) => {
220
82
  const requestBody = await readRawRequestBody(requestBodyStream);
221
- const candidates = (0, scheduler_1.listCandidateAccounts)();
222
- if (candidates.length === 0) {
223
- reply.code(503);
224
- reply.send({
225
- error: {
226
- message: (0, text_1.bi)("当前没有可用账号", "No available account"),
227
- type: "no_available_account"
228
- }
229
- });
230
- return;
231
- }
232
- let lastErrorPayload = {
233
- error: {
234
- message: (0, text_1.bi)("所有账号都请求失败", "All accounts failed"),
235
- type: "all_accounts_failed"
83
+ const result = await (0, proxy_retry_service_1.proxyResponsesWithRetry)(requestHeaders, requestBody);
84
+ if (result.type === "send") {
85
+ reply.code(result.statusCode);
86
+ for (const [headerName, headerValue] of Object.entries(result.headers ?? {})) {
87
+ reply.header(headerName, headerValue);
236
88
  }
237
- };
238
- let lastStatusCode = 503;
239
- for (const picked of candidates) {
240
- const auth = (0, account_store_1.readAuthFile)(picked.account.codex_home);
241
- let accessToken = auth?.tokens?.access_token;
242
- const accountIdHeader = auth?.tokens?.account_id;
243
- if (!accessToken) {
244
- // 当前账号认证信息不完整时,先做短时熔断,再切到下一个账号。
245
- markAccountFailure(picked.account.id, "invalid_account_auth", 10 * 60);
246
- lastErrorPayload = {
247
- error: {
248
- message: (0, text_1.bi)(`账号 ${picked.account.id} 缺少 access_token`, `Account ${picked.account.id} is missing access_token`),
249
- type: "invalid_account_auth"
250
- }
251
- };
252
- lastStatusCode = 503;
253
- continue;
254
- }
255
- const sendUpstream = async (upstreamAccessToken) => await (0, undici_1.request)(`${config.upstream.codex_base_url}/responses`, {
256
- method: "POST",
257
- headers: buildUpstreamHeaders(requestHeaders, upstreamAccessToken, requestBody.length, accountIdHeader),
258
- body: requestBody
259
- });
260
- let upstream;
261
- try {
262
- upstream = await sendUpstream(accessToken);
263
- }
264
- catch (error) {
265
- lastStatusCode = 503;
266
- if (isNetworkUnavailableError(error)) {
267
- lastErrorPayload = buildNetworkUnavailablePayload(picked.account.id, error);
268
- continue;
269
- }
270
- // 非网络层异常仍视为当前账号请求链路异常,短时熔断后继续尝试下一个账号。
271
- markAccountFailure(picked.account.id, "request_failed", 60);
272
- lastErrorPayload = {
273
- error: {
274
- message: `账号 ${picked.account.id} 请求上游失败: ${error instanceof Error ? error.message : String(error)}`,
275
- type: "account_request_failed"
276
- }
277
- };
278
- continue;
279
- }
280
- if (upstream.statusCode === 401) {
281
- try {
282
- const refreshed = await (0, usage_sync_1.refreshAccountTokens)(picked.account.id);
283
- accessToken = refreshed.tokens?.access_token ?? accessToken;
284
- upstream = await sendUpstream(accessToken);
285
- }
286
- catch (error) {
287
- lastStatusCode = 503;
288
- if (isNetworkUnavailableError(error)) {
289
- lastErrorPayload = buildNetworkUnavailablePayload(picked.account.id, error);
290
- continue;
291
- }
292
- // token 刷新失败说明该账号短期内不可用,先熔断再切换。
293
- markAccountFailure(picked.account.id, "token_refresh_failed", 10 * 60);
294
- lastErrorPayload = {
295
- error: {
296
- message: `账号 ${picked.account.id} 刷新 token 失败: ${error instanceof Error ? error.message : String(error)}`,
297
- type: "account_token_refresh_failed"
298
- }
299
- };
300
- continue;
301
- }
302
- }
303
- if (upstream.statusCode === 429 || upstream.statusCode === 403) {
304
- const errorText = await upstream.body.text();
305
- const block = resolveBlockWindow(picked, errorText);
306
- (0, state_1.setAccountBlock)(picked.account.id, block.until, block.reason);
307
- lastStatusCode = upstream.statusCode;
308
- lastErrorPayload = {
309
- error: {
310
- message: `账号 ${picked.account.id} 受限: ${errorText}`,
311
- type: "account_rate_limited"
312
- }
313
- };
314
- continue;
315
- }
316
- if (upstream.statusCode >= 400) {
317
- const errorText = await upstream.body.text();
318
- const lowerText = errorText.toLowerCase();
319
- if (lowerText.includes("usage limit") || lowerText.includes("try again later")) {
320
- const block = resolveBlockWindow(picked, errorText);
321
- (0, state_1.setAccountBlock)(picked.account.id, block.until, block.reason);
322
- lastStatusCode = upstream.statusCode;
323
- lastErrorPayload = {
324
- error: {
325
- message: `账号 ${picked.account.id} 命中额度限制: ${errorText}`,
326
- type: "account_usage_limited"
327
- }
328
- };
329
- continue;
330
- }
331
- if (upstream.statusCode >= 500) {
332
- // 上游 5xx 先视为当前账号链路失败,短时熔断并切到下一个账号。
333
- markAccountFailure(picked.account.id, "upstream_5xx", 60);
334
- lastStatusCode = upstream.statusCode;
335
- lastErrorPayload = {
336
- error: {
337
- message: `账号 ${picked.account.id} 上游异常: ${errorText}`,
338
- type: "account_upstream_failed"
339
- }
340
- };
341
- continue;
342
- }
343
- reply.raw.writeHead(upstream.statusCode, {
344
- "content-type": "application/json"
345
- });
346
- reply.raw.end(errorText);
347
- return;
348
- }
349
- const headers = {};
350
- const contentType = upstream.headers["content-type"];
351
- const cacheControl = upstream.headers["cache-control"];
352
- if (typeof contentType === "string") {
353
- headers["content-type"] = contentType;
354
- }
355
- if (typeof cacheControl === "string") {
356
- headers["cache-control"] = cacheControl;
357
- }
358
- headers.connection = "keep-alive";
359
- reply.raw.writeHead(upstream.statusCode, headers);
360
- for await (const chunk of upstream.body) {
361
- reply.raw.write(chunk);
362
- }
363
- reply.raw.end();
89
+ reply.send(result.payload);
364
90
  return;
365
91
  }
366
- reply.code(lastStatusCode);
367
- reply.send(lastErrorPayload);
92
+ reply.hijack();
93
+ reply.raw.writeHead(result.statusCode, result.headers);
94
+ for await (const chunk of result.body) {
95
+ reply.raw.write(chunk);
96
+ }
97
+ reply.raw.end();
368
98
  };
369
99
  await app.register(async (proxyApp) => {
370
100
  // 代理路由需要原样透传 body,不能在本地先做 JSON 解析与大小限制拦截。
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.loadSchedulerStatsSnapshot = loadSchedulerStatsSnapshot;
4
+ exports.recordAccountScheduleSuccess = recordAccountScheduleSuccess;
5
+ const state_1 = require("./state");
6
+ /**
7
+ * 读取全部账号的调度统计快照。
8
+ *
9
+ * 业务含义:
10
+ * 1. 调度器一次调度只读取一次统计,避免评分过程中反复触碰 state 文件。
11
+ * 2. 返回值是普通对象快照,调用方不得把它当作可自动持久化的引用。
12
+ *
13
+ * @returns 账号调度统计表;key 为账号 id。
14
+ * @throws 当 state 文件读取或 JSON 解析失败时透传底层异常。
15
+ */
16
+ function loadSchedulerStatsSnapshot() {
17
+ return (0, state_1.loadState)().scheduler_stats;
18
+ }
19
+ /**
20
+ * 记录指定账号完成一次成功代理请求。
21
+ *
22
+ * 业务含义:
23
+ * 1. 该统计只服务于调度均匀分摊,不参与额度判断。
24
+ * 2. 每次成功上游响应后递增成功次数并刷新最近成功时间。
25
+ *
26
+ * @param accountId 账号标识;必须是当前配置中的受管账号 id。
27
+ * @returns 无返回值。
28
+ * @throws 当 state 文件写入失败时透传底层异常。
29
+ */
30
+ function recordAccountScheduleSuccess(accountId) {
31
+ (0, state_1.updateState)((state) => {
32
+ const current = state.scheduler_stats[accountId] ?? {
33
+ success_count: 0,
34
+ last_success_at: null
35
+ };
36
+ state.scheduler_stats[accountId] = {
37
+ success_count: current.success_count + 1,
38
+ last_success_at: new Date().toISOString()
39
+ };
40
+ });
41
+ }