codex-slot 0.1.19 → 0.1.21

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,216 +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
- const state_1 = require("./state");
8
- const CRITICAL_WEEKLY_LEFT_PERCENT = 5;
9
- const LOW_WEEKLY_LEFT_PERCENT = 15;
10
- const WASTE_HORIZON_SECONDS = 5 * 60 * 60;
11
- const WEEKLY_WASTE_HORIZON_SECONDS = 7 * 24 * 60 * 60;
12
- const RECENT_USE_RECOVERY_SECONDS = 30 * 60;
13
- function nextResetWeight(resetAt) {
14
- if (!resetAt) {
15
- return Number.MAX_SAFE_INTEGER;
16
- }
17
- const diff = resetAt * 1000 - Date.now();
18
- return diff > 0 ? diff : Number.MAX_SAFE_INTEGER;
19
- }
20
- /**
21
- * 判断账号当前是否仅命中可忽略的短期本地熔断。
22
- *
23
- * 这类熔断通常由瞬时网络抖动、上游 5xx 或短暂 token 刷新失败触发,
24
- * 当系统只剩一个可调度账号时,不应因此立刻把它排除掉。
25
- *
26
- * @param status 账号运行时状态。
27
- * @returns `true` 表示仅存在可回退的短期本地熔断;否则返回 `false`。
28
- */
29
- function isSoftLocalBlocked(status) {
30
- if (!status.localBlockUntil || status.localBlockUntil * 1000 <= Date.now()) {
31
- return false;
32
- }
33
- return [
34
- "request_failed",
35
- "upstream_5xx",
36
- "temporary_5m_limit",
37
- "token_refresh_failed"
38
- ].includes(status.localBlockReason ?? "");
39
- }
8
+ const state_repository_1 = require("./state-repository");
40
9
  /**
41
- * 将百分比字段归一化为 0 到 1 的评分,缺失时按中性值处理。
10
+ * 构建一次账号调度所需的完整上下文快照。
42
11
  *
43
- * @param value 原始百分比。
44
- * @returns 归一化后的评分。
45
- */
46
- function normalizePercent(value) {
47
- if (value === null || value === undefined || Number.isNaN(value)) {
48
- return 0.5;
49
- }
50
- return Math.max(0, Math.min(1, value / 100));
51
- }
52
- /**
53
- * 计算 5 小时窗口的余额浪费压力,越接近重置且剩余额度越多分数越高。
12
+ * 业务含义:
13
+ * 1. 调度过程中所有配置、状态与统计都来自同一次快照,避免评分时反复读取文件。
14
+ * 2. 该函数是调度应用层边界,负责把外部存储数据转成纯策略可消费的输入。
54
15
  *
55
- * @param status 账号运行时状态。
56
- * @returns 0 到 1 之间的浪费压力评分。
16
+ * @returns 调度上下文,包含账号配置、运行状态、使用统计与统一时间基准。
17
+ * @throws 当配置、状态或调度统计读取失败时透传底层异常。
57
18
  */
58
- function computeFiveHourWastePressure(status) {
59
- const leftScore = normalizePercent(status.fiveHourLeftPercent);
60
- if (!status.fiveHourResetsAt) {
61
- return leftScore * 0.2;
62
- }
63
- const secondsUntilReset = status.fiveHourResetsAt - Math.floor(Date.now() / 1000);
64
- if (secondsUntilReset <= 0) {
65
- return 0;
66
- }
67
- const urgency = Math.max(0.1, Math.min(1, (WASTE_HORIZON_SECONDS - secondsUntilReset) / WASTE_HORIZON_SECONDS));
68
- return leftScore * urgency;
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
+ };
69
27
  }
70
28
  /**
71
- * 计算周窗口的余额浪费压力,周额度越接近重置且剩余越多分数越高。
29
+ * 过滤当前可直接参与调度的账号状态。
72
30
  *
73
- * @param status 账号运行时状态。
74
- * @returns 0 到 1 之间的周窗口浪费压力评分。
75
- */
76
- function computeWeeklyWastePressure(status) {
77
- const leftScore = normalizePercent(status.weeklyLeftPercent);
78
- if (!status.weeklyResetsAt) {
79
- return leftScore * 0.1;
80
- }
81
- const secondsUntilReset = status.weeklyResetsAt - Math.floor(Date.now() / 1000);
82
- if (secondsUntilReset <= 0) {
83
- return 0;
84
- }
85
- const urgency = Math.max(0.05, Math.min(1, (WEEKLY_WASTE_HORIZON_SECONDS - secondsUntilReset) / WEEKLY_WASTE_HORIZON_SECONDS));
86
- return leftScore * urgency;
87
- }
88
- /**
89
- * 计算 5 小时窗口调度时可借用的周额度承载系数,周余额越低越抑制短窗口冲动。
31
+ * 业务含义:
32
+ * 1. 只保留已启用、登录态完整、未触发刷新错误、未限额、未本地熔断的账号。
33
+ * 2. 具体排序不在这里处理,排序交给纯调度策略。
90
34
  *
91
- * @param status 账号运行时状态。
92
- * @returns 0 到 1 之间的周额度承载系数。
35
+ * @param statuses 当前账号运行状态快照。
36
+ * @returns 可直接调度的账号状态列表。
37
+ * @throws 无显式抛出。
93
38
  */
94
- function computeWeeklyCapacityFactor(status) {
95
- const weeklyLeft = status.weeklyLeftPercent;
96
- if (weeklyLeft === null || weeklyLeft === undefined || Number.isNaN(weeklyLeft)) {
97
- return 0.7;
98
- }
99
- if (weeklyLeft <= CRITICAL_WEEKLY_LEFT_PERCENT) {
100
- return 0.05;
101
- }
102
- if (weeklyLeft < LOW_WEEKLY_LEFT_PERCENT) {
103
- return 0.2;
104
- }
105
- if (weeklyLeft < 30) {
106
- return 0.55;
107
- }
108
- return 1;
39
+ function resolveAvailableStatuses(statuses) {
40
+ return statuses.filter((item) => item.isAvailable);
109
41
  }
110
42
  /**
111
- * 计算周额度健康度,低于保护线时非线性降权,避免个别账号过早打穿周窗口。
43
+ * 过滤软熔断兜底候选账号。
112
44
  *
113
- * @param status 账号运行时状态。
114
- * @returns 0 到 1 之间的周额度健康评分。
115
- */
116
- function computeWeeklyHealthScore(status) {
117
- const weeklyLeft = status.weeklyLeftPercent;
118
- if (weeklyLeft === null || weeklyLeft === undefined || Number.isNaN(weeklyLeft)) {
119
- return 0.5;
120
- }
121
- if (weeklyLeft <= CRITICAL_WEEKLY_LEFT_PERCENT) {
122
- return 0;
123
- }
124
- if (weeklyLeft < LOW_WEEKLY_LEFT_PERCENT) {
125
- return normalizePercent(weeklyLeft) * 0.35;
126
- }
127
- return normalizePercent(weeklyLeft);
128
- }
129
- /**
130
- * 计算账号使用分散度,成功次数更少且最近未使用的账号分数更高。
45
+ * 业务含义:
46
+ * 1. 当所有未限额账号都只剩短期软熔断时,允许这些账号进入兜底调度。
47
+ * 2. 硬限额、禁用、登录态缺失仍不会进入兜底池。
131
48
  *
132
- * @param status 账号运行时状态。
133
- * @param minSuccessCount 当前候选账号中的最小成功次数。
134
- * @param maxSuccessCount 当前候选账号中的最大成功次数。
135
- * @returns 0 到 1 之间的分散调度评分。
49
+ * @param statuses 当前账号运行状态快照。
50
+ * @returns 可用于软熔断兜底的账号状态列表;不满足兜底条件时返回空列表。
51
+ * @throws 无显式抛出。
136
52
  */
137
- function computeSpreadScore(status, minSuccessCount, maxSuccessCount) {
138
- const stats = (0, state_1.getSchedulerStats)(status.id);
139
- const countRange = Math.max(1, maxSuccessCount - minSuccessCount);
140
- const countScore = 1 - (stats.success_count - minSuccessCount) / countRange;
141
- if (!stats.last_success_at) {
142
- return Math.max(0, Math.min(1, countScore * 0.6 + 0.4));
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 [];
143
57
  }
144
- const secondsSinceLastUse = (Date.now() - new Date(stats.last_success_at).getTime()) / 1000;
145
- const recencyScore = Math.max(0, Math.min(1, secondsSinceLastUse / RECENT_USE_RECOVERY_SECONDS));
146
- return Math.max(0, Math.min(1, countScore * 0.6 + recencyScore * 0.4));
58
+ return eligible;
147
59
  }
148
60
  /**
149
- * 计算候选账号的综合调度评分。
61
+ * 将纯调度决策映射为包含账号配置的调度结果。
150
62
  *
151
63
  * 业务含义:
152
- * 1. 周窗口承担主防浪费压力,快重置且余额多的账号优先。
153
- * 2. 5 小时窗口在周额度健康时参与放大,低周余额会抑制短窗口冲动。
154
- * 3. 本地成功使用历史用于打散连续请求,降低单账号被持续命中的概率。
155
- *
156
- * @param status 账号运行时状态。
157
- * @param minSuccessCount 当前候选账号中的最小成功次数。
158
- * @param maxSuccessCount 当前候选账号中的最大成功次数。
159
- * @returns 综合评分,分数越高越优先。
160
- */
161
- function computeScheduleScore(status, minSuccessCount, maxSuccessCount) {
162
- const weeklyWasteScore = computeWeeklyWastePressure(status);
163
- const fiveHourWasteScore = computeFiveHourWastePressure(status) * computeWeeklyCapacityFactor(status);
164
- const weeklyHealthScore = computeWeeklyHealthScore(status);
165
- const fiveHourLeftScore = normalizePercent(status.fiveHourLeftPercent);
166
- const spreadScore = computeSpreadScore(status, minSuccessCount, maxSuccessCount);
167
- return (weeklyWasteScore * 0.5 +
168
- fiveHourWasteScore * 0.25 +
169
- weeklyHealthScore * 0.1 +
170
- spreadScore * 0.1 +
171
- fiveHourLeftScore * 0.05);
172
- }
173
- /**
174
- * 对候选账号按防浪费、周额度保护与均匀使用策略排序。
64
+ * 1. 调度策略只认识运行状态,不直接依赖配置存储。
65
+ * 2. 应用层在这里补齐账号 HOME、名称等后续代理链路需要的配置字段。
175
66
  *
176
- * @param statuses 待排序的账号状态列表。
177
- * @returns 排序后的账号状态列表,优先返回更适合尝试的账号。
67
+ * @param decisions 纯策略返回的调度决策列表。
68
+ * @param accountMap 账号配置索引;key 为账号 id。
69
+ * @param fallback 是否来自软熔断兜底调度。
70
+ * @returns 可供代理或 CLI 使用的账号候选列表。
71
+ * @throws 无显式抛出。
178
72
  */
179
- function rankEligibleStatuses(statuses) {
180
- const primaryPool = statuses.some((item) => item.weeklyLeftPercent === null ||
181
- item.weeklyLeftPercent === undefined ||
182
- item.weeklyLeftPercent > CRITICAL_WEEKLY_LEFT_PERCENT)
183
- ? statuses.filter((item) => item.weeklyLeftPercent === null ||
184
- item.weeklyLeftPercent === undefined ||
185
- item.weeklyLeftPercent > CRITICAL_WEEKLY_LEFT_PERCENT)
186
- : statuses;
187
- const deferredPool = statuses.filter((item) => !primaryPool.includes(item));
188
- const rankPool = (items) => {
189
- const successCounts = items.map((item) => (0, state_1.getSchedulerStats)(item.id).success_count);
190
- const minSuccessCount = Math.min(...successCounts, 0);
191
- const maxSuccessCount = Math.max(...successCounts, 0);
192
- return [...items].sort((left, right) => {
193
- const scoreDiff = computeScheduleScore(right, minSuccessCount, maxSuccessCount) -
194
- computeScheduleScore(left, minSuccessCount, maxSuccessCount);
195
- if (Math.abs(scoreDiff) > Number.EPSILON) {
196
- return scoreDiff;
197
- }
198
- const resetDiff = nextResetWeight(left.fiveHourResetsAt) - nextResetWeight(right.fiveHourResetsAt);
199
- if (resetDiff !== 0) {
200
- return resetDiff;
201
- }
202
- return (right.weeklyLeftPercent ?? -1) - (left.weeklyLeftPercent ?? -1);
203
- });
204
- };
205
- return [...rankPool(primaryPool), ...rankPool(deferredPool)];
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;
79
+ }
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);
206
92
  }
207
93
  /**
208
94
  * 选择当前最适合激活的账号。
209
95
  *
210
96
  * 业务规则:
211
- * 1. 仅在账号启用且存在凭据时参与调度。
212
- * 2. 优先选择当前 5 小时和周窗口都未受限的账号。
213
- * 3. 在多个可用账号间,优先选择 5 小时剩余额度更高的账号。
97
+ * 1. 仅在账号启用且存在完整凭据时参与调度。
98
+ * 2. 优先避免周窗口额度浪费,再在周额度健康时消耗 5 小时窗口余额。
99
+ * 3. 同等条件下根据本地成功使用统计做均匀分摊。
214
100
  *
215
101
  * @returns 调度结果;若没有可用账号则返回 `null`。
102
+ * @throws 当配置、状态或调度统计读取失败时透传底层异常。
216
103
  */
217
104
  function pickBestAccount() {
218
105
  return listCandidateAccounts()[0] ?? null;
@@ -221,31 +108,15 @@ function pickBestAccount() {
221
108
  * 返回按优先级排序后的可用账号列表,供代理重试链路使用。
222
109
  *
223
110
  * @returns 候选账号列表,已按优先级从高到低排序。
111
+ * @throws 当配置、状态或调度统计读取失败时透传底层异常。
224
112
  */
225
113
  function listCandidateAccounts() {
226
- const config = (0, config_1.loadConfig)();
227
- const statuses = (0, status_1.collectAccountStatuses)();
228
- const accountMap = new Map(config.accounts.map((item) => [item.id, item]));
229
- const eligible = statuses.filter((item) => item.enabled && item.exists && !item.isFiveHourLimited && !item.isWeeklyLimited);
230
- const available = rankEligibleStatuses(statuses.filter((item) => item.isAvailable));
231
- const ranked = available.length > 0 ? available : [];
232
- // 当所有未限额账号都只命中短期本地熔断时,仍允许继续兜底尝试,避免把网络抖动误判成“无可用账号”。
233
- if (ranked.length === 0 && eligible.length > 0 && eligible.every(isSoftLocalBlocked)) {
234
- ranked.push(...rankEligibleStatuses(eligible));
235
- }
236
- return ranked
237
- .map((winner) => {
238
- const account = accountMap.get(winner.id);
239
- if (!account) {
240
- return null;
241
- }
242
- return {
243
- account,
244
- status: winner,
245
- reason: winner.isAvailable
246
- ? "优先选择 5 小时窗口剩余额度最高且当前可用的账号"
247
- : "当前仅剩一个可调度账号,忽略短期本地熔断后继续兜底尝试"
248
- };
249
- })
250
- .filter((item) => item !== null);
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);
119
+ }
120
+ const fallbackStatuses = resolveSoftFallbackStatuses(context.statuses);
121
+ return mapDecisionsToPicks((0, scheduler_strategy_1.rankAccountStatuses)(fallbackStatuses, context.schedulerStats, context.nowMs), accountMap, true);
251
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,154 +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
- (0, state_1.recordAccountScheduleSuccess)(picked.account.id);
359
- headers.connection = "keep-alive";
360
- reply.raw.writeHead(upstream.statusCode, headers);
361
- for await (const chunk of upstream.body) {
362
- reply.raw.write(chunk);
363
- }
364
- reply.raw.end();
89
+ reply.send(result.payload);
365
90
  return;
366
91
  }
367
- reply.code(lastStatusCode);
368
- 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();
369
98
  };
370
99
  await app.register(async (proxyApp) => {
371
100
  // 代理路由需要原样透传 body,不能在本地先做 JSON 解析与大小限制拦截。