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/app/account-service.js +18 -14
- package/dist/codex-auth.js +2 -1
- package/dist/codex-config.js +1 -1
- package/dist/config.js +13 -3
- package/dist/login.js +2 -1
- package/dist/proxy-retry-service.js +237 -0
- package/dist/scheduler-strategy.js +311 -0
- package/dist/scheduler.js +86 -61
- package/dist/server.js +19 -289
- package/dist/state-repository.js +41 -0
- package/dist/state.js +115 -56
- package/dist/upstream-client.js +63 -0
- package/dist/upstream-error-policy.js +114 -0
- package/dist/usage-sync.js +23 -0
- package/package.json +3 -3
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
*
|
|
18
|
-
*
|
|
31
|
+
* 业务含义:
|
|
32
|
+
* 1. 只保留已启用、登录态完整、未触发刷新错误、未限额、未本地熔断的账号。
|
|
33
|
+
* 2. 具体排序不在这里处理,排序交给纯调度策略。
|
|
19
34
|
*
|
|
20
|
-
* @param
|
|
21
|
-
* @returns
|
|
35
|
+
* @param statuses 当前账号运行状态快照。
|
|
36
|
+
* @returns 可直接调度的账号状态列表。
|
|
37
|
+
* @throws 无显式抛出。
|
|
22
38
|
*/
|
|
23
|
-
function
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
*
|
|
38
|
-
*
|
|
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
|
|
41
|
-
return
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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.
|
|
59
|
-
* 3.
|
|
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
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
|
222
|
-
if (
|
|
223
|
-
reply.code(
|
|
224
|
-
|
|
225
|
-
|
|
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.
|
|
367
|
-
reply.
|
|
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
|
+
}
|