codex-slot 0.1.19 → 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 -18
- 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 +77 -206
- package/dist/server.js +19 -290
- package/dist/state-repository.js +41 -0
- package/dist/state.js +115 -92
- 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
|
@@ -23,7 +23,7 @@ const text_1 = require("../text");
|
|
|
23
23
|
* @throws 当源目录缺少必要认证文件或写入失败时抛出异常。
|
|
24
24
|
*/
|
|
25
25
|
function importAccount(slotName, codexHome) {
|
|
26
|
-
const sourceHome = codexHome ? (0, config_1.expandHome)(codexHome) :
|
|
26
|
+
const sourceHome = codexHome ? (0, config_1.expandHome)(codexHome) : (0, config_1.getUserHomeDir)();
|
|
27
27
|
const managedHome = (0, config_1.getManagedHome)(slotName);
|
|
28
28
|
(0, account_store_1.cloneCodexAuthState)(sourceHome, managedHome);
|
|
29
29
|
return {
|
|
@@ -104,22 +104,22 @@ function renameAccount(oldName, newName) {
|
|
|
104
104
|
};
|
|
105
105
|
config.accounts[index] = renamedAccount;
|
|
106
106
|
(0, config_1.saveConfig)(config);
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
107
|
+
(0, state_1.updateState)((state) => {
|
|
108
|
+
if (state.account_blocks[oldName]) {
|
|
109
|
+
state.account_blocks[newName] = state.account_blocks[oldName];
|
|
110
|
+
delete state.account_blocks[oldName];
|
|
111
|
+
}
|
|
112
|
+
if (state.usage_cache[oldName]) {
|
|
113
|
+
state.usage_cache[newName] = {
|
|
114
|
+
...state.usage_cache[oldName],
|
|
115
|
+
accountId: newName
|
|
116
|
+
};
|
|
117
|
+
delete state.usage_cache[oldName];
|
|
118
|
+
}
|
|
119
|
+
if (state.scheduler_stats[oldName]) {
|
|
120
|
+
state.scheduler_stats[newName] = state.scheduler_stats[oldName];
|
|
121
|
+
delete state.scheduler_stats[oldName];
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
124
|
return renamedAccount;
|
|
125
125
|
}
|
package/dist/codex-auth.js
CHANGED
|
@@ -9,6 +9,7 @@ exports.deactivateManagedCodexAuth = deactivateManagedCodexAuth;
|
|
|
9
9
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
10
10
|
const node_path_1 = __importDefault(require("node:path"));
|
|
11
11
|
const account_store_1 = require("./account-store");
|
|
12
|
+
const config_1 = require("./config");
|
|
12
13
|
const state_1 = require("./state");
|
|
13
14
|
/**
|
|
14
15
|
* 返回默认的 Codex HOME 目录。
|
|
@@ -16,7 +17,7 @@ const state_1 = require("./state");
|
|
|
16
17
|
* @returns 当前进程 HOME;未设置时返回空字符串。
|
|
17
18
|
*/
|
|
18
19
|
function getDefaultCodexHome() {
|
|
19
|
-
return
|
|
20
|
+
return (0, config_1.getUserHomeDir)();
|
|
20
21
|
}
|
|
21
22
|
/**
|
|
22
23
|
* 读取目标 HOME 下 `.codex/accounts` 目录中的所有 `.auth.json` 文件内容。
|
package/dist/codex-config.js
CHANGED
|
@@ -21,7 +21,7 @@ const PROVIDER_BLOCK_END_MARKER = "# <<< cslot provider:cslot <<<";
|
|
|
21
21
|
* @returns 默认 `config.toml` 绝对路径。
|
|
22
22
|
*/
|
|
23
23
|
function getDefaultCodexConfigPath() {
|
|
24
|
-
return node_path_1.default.join(
|
|
24
|
+
return node_path_1.default.join((0, config_1.getUserHomeDir)(), ".codex", "config.toml");
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
27
|
* 原子方式写入目标文件,避免写入过程中留下半截配置。
|
package/dist/config.js
CHANGED
|
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getUserHomeDir = getUserHomeDir;
|
|
6
7
|
exports.generateServerApiKey = generateServerApiKey;
|
|
7
8
|
exports.getCslotHome = getCslotHome;
|
|
8
9
|
exports.getConfigPath = getConfigPath;
|
|
@@ -56,6 +57,14 @@ const configSchema = zod_1.z.object({
|
|
|
56
57
|
}),
|
|
57
58
|
accounts: zod_1.z.array(managedAccountSchema).default([])
|
|
58
59
|
});
|
|
60
|
+
/**
|
|
61
|
+
* 解析当前进程应使用的用户 HOME 目录,兼容 Windows 缺少 `HOME` 的场景。
|
|
62
|
+
*
|
|
63
|
+
* @returns 当前用户 HOME 目录;优先复用显式环境变量,兜底使用 `os.homedir()`。
|
|
64
|
+
*/
|
|
65
|
+
function getUserHomeDir() {
|
|
66
|
+
return process.env.HOME || process.env.USERPROFILE || node_os_1.default.homedir();
|
|
67
|
+
}
|
|
59
68
|
/**
|
|
60
69
|
* 生成新的本地服务 API Key。
|
|
61
70
|
*
|
|
@@ -74,7 +83,7 @@ function generateServerApiKey() {
|
|
|
74
83
|
* @throws 当目录无法创建时抛出文件系统错误。
|
|
75
84
|
*/
|
|
76
85
|
function getCslotHome() {
|
|
77
|
-
const home = node_path_1.default.join(
|
|
86
|
+
const home = node_path_1.default.join(getUserHomeDir(), ".cslot");
|
|
78
87
|
// 先创建 cslot 根目录,后续命令统一基于该目录读写状态。
|
|
79
88
|
node_fs_1.default.mkdirSync(home, { recursive: true });
|
|
80
89
|
node_fs_1.default.mkdirSync(node_path_1.default.join(home, "homes"), { recursive: true });
|
|
@@ -112,11 +121,12 @@ function getServiceLogPath() {
|
|
|
112
121
|
* @returns 展开后的绝对或原始路径。
|
|
113
122
|
*/
|
|
114
123
|
function expandHome(input) {
|
|
124
|
+
const homeDir = getUserHomeDir();
|
|
115
125
|
if (input === "~") {
|
|
116
|
-
return
|
|
126
|
+
return homeDir;
|
|
117
127
|
}
|
|
118
128
|
if (input.startsWith("~/")) {
|
|
119
|
-
return node_path_1.default.join(
|
|
129
|
+
return node_path_1.default.join(homeDir, input.slice(2));
|
|
120
130
|
}
|
|
121
131
|
return input;
|
|
122
132
|
}
|
package/dist/login.js
CHANGED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.proxyResponsesWithRetry = void 0;
|
|
4
|
+
exports.createProxyRetryService = createProxyRetryService;
|
|
5
|
+
const account_store_1 = require("./account-store");
|
|
6
|
+
const config_1 = require("./config");
|
|
7
|
+
const scheduler_1 = require("./scheduler");
|
|
8
|
+
const state_repository_1 = require("./state-repository");
|
|
9
|
+
const state_1 = require("./state");
|
|
10
|
+
const text_1 = require("./text");
|
|
11
|
+
const upstream_client_1 = require("./upstream-client");
|
|
12
|
+
const upstream_error_policy_1 = require("./upstream-error-policy");
|
|
13
|
+
const usage_sync_1 = require("./usage-sync");
|
|
14
|
+
/**
|
|
15
|
+
* 为当前请求失败的账号设置临时熔断状态,避免短时间内被重复选中。
|
|
16
|
+
*
|
|
17
|
+
* @param accountId 账号标识。
|
|
18
|
+
* @param reason 本地状态中记录的失败原因。
|
|
19
|
+
* @param blockSeconds 熔断持续秒数。
|
|
20
|
+
* @returns 无返回值。
|
|
21
|
+
* @throws 当状态写入失败时透传底层异常。
|
|
22
|
+
*/
|
|
23
|
+
function markAccountFailure(dependencies, accountId, reason, blockSeconds) {
|
|
24
|
+
dependencies.setAccountBlock(accountId, Math.floor(Date.now() / 1000) + blockSeconds, reason);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* 提取上游响应中允许透传给客户端的响应头。
|
|
28
|
+
*
|
|
29
|
+
* @param headers 上游响应头对象。
|
|
30
|
+
* @returns 可透传响应头。
|
|
31
|
+
* @throws 无显式抛出。
|
|
32
|
+
*/
|
|
33
|
+
function pickResponseHeaders(headers) {
|
|
34
|
+
const picked = {};
|
|
35
|
+
const contentType = headers["content-type"];
|
|
36
|
+
const cacheControl = headers["cache-control"];
|
|
37
|
+
if (typeof contentType === "string") {
|
|
38
|
+
picked["content-type"] = contentType;
|
|
39
|
+
}
|
|
40
|
+
if (typeof cacheControl === "string") {
|
|
41
|
+
picked["cache-control"] = cacheControl;
|
|
42
|
+
}
|
|
43
|
+
return picked;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 构造统一错误响应结果。
|
|
47
|
+
*
|
|
48
|
+
* @param statusCode HTTP 状态码。
|
|
49
|
+
* @param payload 响应体。
|
|
50
|
+
* @param headers 可选响应头。
|
|
51
|
+
* @returns 代理服务可直接写回的 send 结果。
|
|
52
|
+
* @throws 无显式抛出。
|
|
53
|
+
*/
|
|
54
|
+
function buildSendResult(statusCode, payload, headers) {
|
|
55
|
+
return {
|
|
56
|
+
type: "send",
|
|
57
|
+
statusCode,
|
|
58
|
+
payload,
|
|
59
|
+
headers
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 对单个候选账号发送上游请求。
|
|
64
|
+
*
|
|
65
|
+
* @param picked 当前候选账号。
|
|
66
|
+
* @param accessToken 可用 access token。
|
|
67
|
+
* @param requestHeaders 原始请求头。
|
|
68
|
+
* @param requestBody 原始请求体。
|
|
69
|
+
* @returns 上游响应。
|
|
70
|
+
* @throws 当网络层或 undici 请求失败时透传底层异常。
|
|
71
|
+
*/
|
|
72
|
+
async function sendWithAccount(dependencies, picked, accessToken, requestHeaders, requestBody) {
|
|
73
|
+
const config = dependencies.loadConfig();
|
|
74
|
+
const auth = dependencies.readAuthFile(picked.account.codex_home);
|
|
75
|
+
return await dependencies.sendCodexResponsesRequest({
|
|
76
|
+
codexBaseUrl: config.upstream.codex_base_url,
|
|
77
|
+
requestHeaders,
|
|
78
|
+
accessToken,
|
|
79
|
+
accountIdHeader: auth?.tokens?.account_id,
|
|
80
|
+
body: requestBody
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* 创建代理重试服务。
|
|
85
|
+
*
|
|
86
|
+
* 业务含义:
|
|
87
|
+
* 1. 默认依赖绑定真实配置、账号、状态和上游请求。
|
|
88
|
+
* 2. 测试或未来扩展可注入替代依赖,避免业务重试逻辑硬绑 I/O 实现。
|
|
89
|
+
*
|
|
90
|
+
* @param overrides 可选依赖覆盖项。
|
|
91
|
+
* @returns 代理重试服务实例。
|
|
92
|
+
* @throws 无显式抛出。
|
|
93
|
+
*/
|
|
94
|
+
function createProxyRetryService(overrides) {
|
|
95
|
+
const dependencies = {
|
|
96
|
+
loadConfig: config_1.loadConfig,
|
|
97
|
+
listCandidateAccounts: scheduler_1.listCandidateAccounts,
|
|
98
|
+
readAuthFile: account_store_1.readAuthFile,
|
|
99
|
+
sendCodexResponsesRequest: upstream_client_1.sendCodexResponsesRequest,
|
|
100
|
+
refreshAccountTokens: usage_sync_1.refreshAccountTokens,
|
|
101
|
+
setAccountBlock: state_1.setAccountBlock,
|
|
102
|
+
recordAccountScheduleSuccess: state_repository_1.recordAccountScheduleSuccess,
|
|
103
|
+
...overrides
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
async proxyResponsesWithRetry(requestHeaders, requestBody) {
|
|
107
|
+
const candidates = dependencies.listCandidateAccounts();
|
|
108
|
+
if (candidates.length === 0) {
|
|
109
|
+
return buildSendResult(503, {
|
|
110
|
+
error: {
|
|
111
|
+
message: (0, text_1.bi)("当前没有可用账号", "No available account"),
|
|
112
|
+
type: "no_available_account"
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
let lastErrorPayload = {
|
|
117
|
+
error: {
|
|
118
|
+
message: (0, text_1.bi)("所有账号都请求失败", "All accounts failed"),
|
|
119
|
+
type: "all_accounts_failed"
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
let lastStatusCode = 503;
|
|
123
|
+
for (const picked of candidates) {
|
|
124
|
+
const auth = dependencies.readAuthFile(picked.account.codex_home);
|
|
125
|
+
let accessToken = auth?.tokens?.access_token;
|
|
126
|
+
if (!accessToken) {
|
|
127
|
+
markAccountFailure(dependencies, picked.account.id, "invalid_account_auth", 10 * 60);
|
|
128
|
+
lastStatusCode = 503;
|
|
129
|
+
lastErrorPayload = {
|
|
130
|
+
error: {
|
|
131
|
+
message: (0, text_1.bi)(`账号 ${picked.account.id} 缺少 access_token`, `Account ${picked.account.id} is missing access_token`),
|
|
132
|
+
type: "invalid_account_auth"
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
let upstream;
|
|
138
|
+
try {
|
|
139
|
+
upstream = await sendWithAccount(dependencies, picked, accessToken, requestHeaders, requestBody);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
lastStatusCode = 503;
|
|
143
|
+
if ((0, upstream_error_policy_1.isNetworkUnavailableError)(error)) {
|
|
144
|
+
lastErrorPayload = (0, upstream_error_policy_1.buildNetworkUnavailablePayload)(picked.account.id, error);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
markAccountFailure(dependencies, picked.account.id, "request_failed", 60);
|
|
148
|
+
lastErrorPayload = {
|
|
149
|
+
error: {
|
|
150
|
+
message: `账号 ${picked.account.id} 请求上游失败: ${error instanceof Error ? error.message : String(error)}`,
|
|
151
|
+
type: "account_request_failed"
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (upstream.statusCode === 401) {
|
|
157
|
+
try {
|
|
158
|
+
const refreshed = await dependencies.refreshAccountTokens(picked.account.id);
|
|
159
|
+
accessToken = refreshed.tokens?.access_token ?? accessToken;
|
|
160
|
+
upstream = await sendWithAccount(dependencies, picked, accessToken, requestHeaders, requestBody);
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
lastStatusCode = 503;
|
|
164
|
+
if ((0, upstream_error_policy_1.isNetworkUnavailableError)(error)) {
|
|
165
|
+
lastErrorPayload = (0, upstream_error_policy_1.buildNetworkUnavailablePayload)(picked.account.id, error);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
markAccountFailure(dependencies, picked.account.id, "token_refresh_failed", 10 * 60);
|
|
169
|
+
lastErrorPayload = {
|
|
170
|
+
error: {
|
|
171
|
+
message: `账号 ${picked.account.id} 刷新 token 失败: ${error instanceof Error ? error.message : String(error)}`,
|
|
172
|
+
type: "account_token_refresh_failed"
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const responseHeaders = pickResponseHeaders(upstream.headers);
|
|
179
|
+
if (upstream.statusCode === 429 || upstream.statusCode === 403) {
|
|
180
|
+
const errorText = await upstream.body.text();
|
|
181
|
+
const block = (0, upstream_error_policy_1.resolveBlockWindow)(picked, errorText);
|
|
182
|
+
dependencies.setAccountBlock(picked.account.id, block.until, block.reason);
|
|
183
|
+
lastStatusCode = upstream.statusCode;
|
|
184
|
+
lastErrorPayload = {
|
|
185
|
+
error: {
|
|
186
|
+
message: `账号 ${picked.account.id} 受限: ${errorText}`,
|
|
187
|
+
type: "account_rate_limited"
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (upstream.statusCode >= 400) {
|
|
193
|
+
const errorText = await upstream.body.text();
|
|
194
|
+
if ((0, upstream_error_policy_1.isUsageLimitErrorText)(errorText)) {
|
|
195
|
+
const block = (0, upstream_error_policy_1.resolveBlockWindow)(picked, errorText);
|
|
196
|
+
dependencies.setAccountBlock(picked.account.id, block.until, block.reason);
|
|
197
|
+
lastStatusCode = upstream.statusCode;
|
|
198
|
+
lastErrorPayload = {
|
|
199
|
+
error: {
|
|
200
|
+
message: `账号 ${picked.account.id} 命中额度限制: ${errorText}`,
|
|
201
|
+
type: "account_usage_limited"
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (upstream.statusCode >= 500) {
|
|
207
|
+
markAccountFailure(dependencies, picked.account.id, "upstream_5xx", 60);
|
|
208
|
+
lastStatusCode = upstream.statusCode;
|
|
209
|
+
lastErrorPayload = {
|
|
210
|
+
error: {
|
|
211
|
+
message: `账号 ${picked.account.id} 上游异常: ${errorText}`,
|
|
212
|
+
type: "account_upstream_failed"
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
return buildSendResult(upstream.statusCode, errorText, {
|
|
218
|
+
"content-type": responseHeaders["content-type"] ?? "application/json",
|
|
219
|
+
...responseHeaders
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
dependencies.recordAccountScheduleSuccess(picked.account.id);
|
|
223
|
+
return {
|
|
224
|
+
type: "proxy",
|
|
225
|
+
statusCode: upstream.statusCode,
|
|
226
|
+
headers: {
|
|
227
|
+
...responseHeaders,
|
|
228
|
+
connection: "keep-alive"
|
|
229
|
+
},
|
|
230
|
+
body: upstream.body
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return buildSendResult(lastStatusCode, lastErrorPayload);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
exports.proxyResponsesWithRetry = createProxyRetryService().proxyResponsesWithRetry;
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isSoftLocalBlocked = isSoftLocalBlocked;
|
|
4
|
+
exports.rankAccountStatuses = rankAccountStatuses;
|
|
5
|
+
const CRITICAL_WEEKLY_LEFT_PERCENT = 5;
|
|
6
|
+
const LOW_WEEKLY_LEFT_PERCENT = 15;
|
|
7
|
+
const FIVE_HOUR_WASTE_HORIZON_SECONDS = 5 * 60 * 60;
|
|
8
|
+
const WEEKLY_WASTE_HORIZON_SECONDS = 7 * 24 * 60 * 60;
|
|
9
|
+
const RECENT_USE_RECOVERY_SECONDS = 30 * 60;
|
|
10
|
+
/**
|
|
11
|
+
* 判断账号当前是否仅命中可忽略的短期本地熔断。
|
|
12
|
+
*
|
|
13
|
+
* 业务含义:
|
|
14
|
+
* 1. 这类熔断通常来自瞬时请求失败、上游 5xx 或 token 短暂刷新失败。
|
|
15
|
+
* 2. 当所有候选账号都只剩这类熔断时,调度器允许兜底尝试,避免把网络抖动误判成无账号可用。
|
|
16
|
+
*
|
|
17
|
+
* @param status 账号运行时状态;必须来自当前调度快照,不能混用历史状态。
|
|
18
|
+
* @returns `true` 表示账号只存在可兜底忽略的短期熔断;否则返回 `false`。
|
|
19
|
+
* @throws 无显式抛出。
|
|
20
|
+
*/
|
|
21
|
+
function isSoftLocalBlocked(status) {
|
|
22
|
+
if (!status.localBlockUntil || status.localBlockUntil * 1000 <= Date.now()) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return [
|
|
26
|
+
"request_failed",
|
|
27
|
+
"upstream_5xx",
|
|
28
|
+
"temporary_5m_limit",
|
|
29
|
+
"token_refresh_failed"
|
|
30
|
+
].includes(status.localBlockReason ?? "");
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 将账号额度百分比归一化成评分字段。
|
|
34
|
+
*
|
|
35
|
+
* 业务含义:
|
|
36
|
+
* 1. 百分比越高代表剩余额度越多。
|
|
37
|
+
* 2. 缺失值按中性值处理,避免因为远端 usage 暂时不可取就把账号完全打死。
|
|
38
|
+
*
|
|
39
|
+
* @param value 原始百分比;允许为空、缺失或非数字。
|
|
40
|
+
* @returns 0 到 1 之间的评分;缺失或非法值返回 0.5。
|
|
41
|
+
* @throws 无显式抛出。
|
|
42
|
+
*/
|
|
43
|
+
function normalizePercent(value) {
|
|
44
|
+
if (value === null || value === undefined || Number.isNaN(value)) {
|
|
45
|
+
return 0.5;
|
|
46
|
+
}
|
|
47
|
+
return Math.max(0, Math.min(1, value / 100));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 计算指定重置窗口的浪费紧迫度。
|
|
51
|
+
*
|
|
52
|
+
* 业务含义:
|
|
53
|
+
* 1. 越接近重置,当前剩余额度越容易浪费。
|
|
54
|
+
* 2. 超过观察窗口的重置时间只保留最低紧迫度,避免远期窗口主导当前调度。
|
|
55
|
+
*
|
|
56
|
+
* @param resetAt 重置时间,Unix 秒时间戳;缺失时代表无法确定重置时间。
|
|
57
|
+
* @param horizonSeconds 观察窗口秒数;必须大于 0。
|
|
58
|
+
* @param nowMs 当前时间毫秒数;调用方应在一次调度内传入同一个时间,保证评分一致。
|
|
59
|
+
* @returns 0 到 1 之间的紧迫度;已过期或无效时间返回 0。
|
|
60
|
+
* @throws 无显式抛出。
|
|
61
|
+
*/
|
|
62
|
+
function computeResetUrgency(resetAt, horizonSeconds, nowMs) {
|
|
63
|
+
if (!resetAt || horizonSeconds <= 0) {
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
const secondsUntilReset = resetAt - Math.floor(nowMs / 1000);
|
|
67
|
+
if (secondsUntilReset <= 0) {
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
return Math.max(0.05, Math.min(1, (horizonSeconds - secondsUntilReset) / horizonSeconds));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 计算 5 小时窗口的额度浪费压力。
|
|
74
|
+
*
|
|
75
|
+
* 业务含义:
|
|
76
|
+
* 1. 5 小时剩余额度越多,且越接近重置,越应该尽快消费。
|
|
77
|
+
* 2. 缺失重置时间时仅保留低权重余额信号,避免短窗口误导周窗口调度。
|
|
78
|
+
*
|
|
79
|
+
* @param status 账号运行时状态;必须包含当前 5 小时剩余额度与重置时间。
|
|
80
|
+
* @param nowMs 当前时间毫秒数;用于统一本次调度的时间基准。
|
|
81
|
+
* @returns 0 到 1 之间的 5 小时浪费压力评分。
|
|
82
|
+
* @throws 无显式抛出。
|
|
83
|
+
*/
|
|
84
|
+
function computeFiveHourWastePressure(status, nowMs) {
|
|
85
|
+
const leftScore = normalizePercent(status.fiveHourLeftPercent);
|
|
86
|
+
if (!status.fiveHourResetsAt) {
|
|
87
|
+
return leftScore * 0.2;
|
|
88
|
+
}
|
|
89
|
+
return leftScore * computeResetUrgency(status.fiveHourResetsAt, FIVE_HOUR_WASTE_HORIZON_SECONDS, nowMs);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 计算周窗口的额度浪费压力。
|
|
93
|
+
*
|
|
94
|
+
* 业务含义:
|
|
95
|
+
* 1. 周窗口是更大的不可累积额度窗口,快重置且剩余额度多时应优先使用。
|
|
96
|
+
* 2. 缺失周重置时间时仅保留低权重余额信号,避免未知数据压过明确窗口。
|
|
97
|
+
*
|
|
98
|
+
* @param status 账号运行时状态;必须包含当前周剩余额度与重置时间。
|
|
99
|
+
* @param nowMs 当前时间毫秒数;用于统一本次调度的时间基准。
|
|
100
|
+
* @returns 0 到 1 之间的周窗口浪费压力评分。
|
|
101
|
+
* @throws 无显式抛出。
|
|
102
|
+
*/
|
|
103
|
+
function computeWeeklyWastePressure(status, nowMs) {
|
|
104
|
+
const leftScore = normalizePercent(status.weeklyLeftPercent);
|
|
105
|
+
if (!status.weeklyResetsAt) {
|
|
106
|
+
return leftScore * 0.1;
|
|
107
|
+
}
|
|
108
|
+
return leftScore * computeResetUrgency(status.weeklyResetsAt, WEEKLY_WASTE_HORIZON_SECONDS, nowMs);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 计算 5 小时窗口调度时可借用的周额度承载系数。
|
|
112
|
+
*
|
|
113
|
+
* 业务含义:
|
|
114
|
+
* 1. 周额度越少,越不应该为了短窗口快重置继续打该账号。
|
|
115
|
+
* 2. 低于关键线时只保留极低短窗口权重,除非没有更健康的账号。
|
|
116
|
+
*
|
|
117
|
+
* @param status 账号运行时状态;必须包含当前周剩余额度。
|
|
118
|
+
* @returns 0 到 1 之间的周额度承载系数。
|
|
119
|
+
* @throws 无显式抛出。
|
|
120
|
+
*/
|
|
121
|
+
function computeWeeklyCapacityFactor(status) {
|
|
122
|
+
const weeklyLeft = status.weeklyLeftPercent;
|
|
123
|
+
if (weeklyLeft === null || weeklyLeft === undefined || Number.isNaN(weeklyLeft)) {
|
|
124
|
+
return 0.7;
|
|
125
|
+
}
|
|
126
|
+
if (weeklyLeft <= CRITICAL_WEEKLY_LEFT_PERCENT) {
|
|
127
|
+
return 0.05;
|
|
128
|
+
}
|
|
129
|
+
if (weeklyLeft < LOW_WEEKLY_LEFT_PERCENT) {
|
|
130
|
+
return 0.2;
|
|
131
|
+
}
|
|
132
|
+
if (weeklyLeft < 30) {
|
|
133
|
+
return 0.55;
|
|
134
|
+
}
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* 计算周额度健康度。
|
|
139
|
+
*
|
|
140
|
+
* 业务含义:
|
|
141
|
+
* 1. 该分数不是“周额度多就绝对优先”,而是用于保护低周余额账号。
|
|
142
|
+
* 2. 低于保护线时非线性降权,避免个别账号提前打穿周窗口。
|
|
143
|
+
*
|
|
144
|
+
* @param status 账号运行时状态;必须包含当前周剩余额度。
|
|
145
|
+
* @returns 0 到 1 之间的周额度健康评分。
|
|
146
|
+
* @throws 无显式抛出。
|
|
147
|
+
*/
|
|
148
|
+
function computeWeeklyHealthScore(status) {
|
|
149
|
+
const weeklyLeft = status.weeklyLeftPercent;
|
|
150
|
+
if (weeklyLeft === null || weeklyLeft === undefined || Number.isNaN(weeklyLeft)) {
|
|
151
|
+
return 0.5;
|
|
152
|
+
}
|
|
153
|
+
if (weeklyLeft <= CRITICAL_WEEKLY_LEFT_PERCENT) {
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
if (weeklyLeft < LOW_WEEKLY_LEFT_PERCENT) {
|
|
157
|
+
return normalizePercent(weeklyLeft) * 0.35;
|
|
158
|
+
}
|
|
159
|
+
return normalizePercent(weeklyLeft);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* 计算账号使用分散度。
|
|
163
|
+
*
|
|
164
|
+
* 业务含义:
|
|
165
|
+
* 1. 成功次数更少的账号应获得更高分,保证长期均匀。
|
|
166
|
+
* 2. 最近刚使用的账号短时间内降权,减少连续命中同一账号。
|
|
167
|
+
*
|
|
168
|
+
* @param stats 当前账号的调度统计;缺失时按从未使用处理。
|
|
169
|
+
* @param minSuccessCount 当前候选池中的最小成功次数。
|
|
170
|
+
* @param maxSuccessCount 当前候选池中的最大成功次数。
|
|
171
|
+
* @param nowMs 当前时间毫秒数;用于统一本次调度的时间基准。
|
|
172
|
+
* @returns 0 到 1 之间的分散调度评分。
|
|
173
|
+
* @throws 无显式抛出。
|
|
174
|
+
*/
|
|
175
|
+
function computeSpreadScore(stats, minSuccessCount, maxSuccessCount, nowMs) {
|
|
176
|
+
const current = stats ?? {
|
|
177
|
+
success_count: 0,
|
|
178
|
+
last_success_at: null
|
|
179
|
+
};
|
|
180
|
+
const countRange = Math.max(1, maxSuccessCount - minSuccessCount);
|
|
181
|
+
const countScore = 1 - (current.success_count - minSuccessCount) / countRange;
|
|
182
|
+
if (!current.last_success_at) {
|
|
183
|
+
return Math.max(0, Math.min(1, countScore * 0.6 + 0.4));
|
|
184
|
+
}
|
|
185
|
+
const secondsSinceLastUse = (nowMs - new Date(current.last_success_at).getTime()) / 1000;
|
|
186
|
+
const recencyScore = Math.max(0, Math.min(1, secondsSinceLastUse / RECENT_USE_RECOVERY_SECONDS));
|
|
187
|
+
return Math.max(0, Math.min(1, countScore * 0.6 + recencyScore * 0.4));
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* 计算单个账号的调度评分与可解释分解。
|
|
191
|
+
*
|
|
192
|
+
* 业务含义:
|
|
193
|
+
* 1. 周窗口浪费压力是主权重。
|
|
194
|
+
* 2. 5 小时窗口必须经过周额度承载系数修正。
|
|
195
|
+
* 3. 周健康度、使用分散度与 5 小时余额只做辅助平衡。
|
|
196
|
+
*
|
|
197
|
+
* @param status 账号运行时状态;必须来自当前候选池。
|
|
198
|
+
* @param stats 当前账号调度统计;缺失时按从未使用处理。
|
|
199
|
+
* @param minSuccessCount 当前候选池中的最小成功次数。
|
|
200
|
+
* @param maxSuccessCount 当前候选池中的最大成功次数。
|
|
201
|
+
* @param nowMs 当前时间毫秒数;用于统一本次调度的时间基准。
|
|
202
|
+
* @returns 调度评分与分项 breakdown。
|
|
203
|
+
* @throws 无显式抛出。
|
|
204
|
+
*/
|
|
205
|
+
function computeScheduleScore(status, stats, minSuccessCount, maxSuccessCount, nowMs) {
|
|
206
|
+
const weeklyWaste = computeWeeklyWastePressure(status, nowMs);
|
|
207
|
+
const fiveHourWaste = computeFiveHourWastePressure(status, nowMs) * computeWeeklyCapacityFactor(status);
|
|
208
|
+
const weeklyHealth = computeWeeklyHealthScore(status);
|
|
209
|
+
const spread = computeSpreadScore(stats, minSuccessCount, maxSuccessCount, nowMs);
|
|
210
|
+
const fiveHourLeft = normalizePercent(status.fiveHourLeftPercent);
|
|
211
|
+
const breakdown = {
|
|
212
|
+
weeklyWaste,
|
|
213
|
+
fiveHourWaste,
|
|
214
|
+
weeklyHealth,
|
|
215
|
+
spread,
|
|
216
|
+
fiveHourLeft
|
|
217
|
+
};
|
|
218
|
+
return {
|
|
219
|
+
score: weeklyWaste * 0.6 +
|
|
220
|
+
fiveHourWaste * 0.2 +
|
|
221
|
+
weeklyHealth * 0.1 +
|
|
222
|
+
spread * 0.07 +
|
|
223
|
+
fiveHourLeft * 0.03,
|
|
224
|
+
breakdown
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* 根据评分分解生成可读的调度原因。
|
|
229
|
+
*
|
|
230
|
+
* @param breakdown 调度评分分解;必须来自同一次评分。
|
|
231
|
+
* @returns 当前账号最主要的调度原因。
|
|
232
|
+
* @throws 无显式抛出。
|
|
233
|
+
*/
|
|
234
|
+
function resolveScheduleReason(breakdown) {
|
|
235
|
+
const entries = Object.entries(breakdown);
|
|
236
|
+
const [primary] = entries.sort((left, right) => right[1] - left[1]);
|
|
237
|
+
if (!primary) {
|
|
238
|
+
return "综合评分最高";
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
weeklyWaste: "周额度快重置且剩余额度可用,优先避免周窗口浪费",
|
|
242
|
+
fiveHourWaste: "周额度仍可承载,优先消耗快重置的 5 小时余额",
|
|
243
|
+
weeklyHealth: "周额度更健康,避免低周余额账号提前打穿",
|
|
244
|
+
spread: "近期使用更少,优先做多账号均匀分摊",
|
|
245
|
+
fiveHourLeft: "5 小时剩余额度更高,作为兜底排序优势"
|
|
246
|
+
}[primary[0]];
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* 计算单个候选池中的调度决策列表。
|
|
250
|
+
*
|
|
251
|
+
* @param statuses 候选账号状态列表;调用方应已完成可用性过滤。
|
|
252
|
+
* @param statsByAccountId 账号调度统计表;key 为账号 id。
|
|
253
|
+
* @param nowMs 当前时间毫秒数;用于统一本次调度的时间基准。
|
|
254
|
+
* @returns 按优先级从高到低排序后的调度决策。
|
|
255
|
+
* @throws 无显式抛出。
|
|
256
|
+
*/
|
|
257
|
+
function rankPool(statuses, statsByAccountId, nowMs) {
|
|
258
|
+
const successCounts = statuses.map((item) => statsByAccountId[item.id]?.success_count ?? 0);
|
|
259
|
+
const minSuccessCount = Math.min(...successCounts, 0);
|
|
260
|
+
const maxSuccessCount = Math.max(...successCounts, 0);
|
|
261
|
+
return statuses
|
|
262
|
+
.map((status) => {
|
|
263
|
+
const scored = computeScheduleScore(status, statsByAccountId[status.id], minSuccessCount, maxSuccessCount, nowMs);
|
|
264
|
+
return {
|
|
265
|
+
status,
|
|
266
|
+
score: scored.score,
|
|
267
|
+
breakdown: scored.breakdown,
|
|
268
|
+
reason: resolveScheduleReason(scored.breakdown)
|
|
269
|
+
};
|
|
270
|
+
})
|
|
271
|
+
.sort((left, right) => {
|
|
272
|
+
const scoreDiff = right.score - left.score;
|
|
273
|
+
if (Math.abs(scoreDiff) > Number.EPSILON) {
|
|
274
|
+
return scoreDiff;
|
|
275
|
+
}
|
|
276
|
+
const leftResetWeight = left.status.fiveHourResetsAt ?? Number.MAX_SAFE_INTEGER;
|
|
277
|
+
const rightResetWeight = right.status.fiveHourResetsAt ?? Number.MAX_SAFE_INTEGER;
|
|
278
|
+
if (leftResetWeight !== rightResetWeight) {
|
|
279
|
+
return leftResetWeight - rightResetWeight;
|
|
280
|
+
}
|
|
281
|
+
return (right.status.weeklyLeftPercent ?? -1) - (left.status.weeklyLeftPercent ?? -1);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* 按防浪费、周额度保护与均匀使用策略排序候选账号。
|
|
286
|
+
*
|
|
287
|
+
* 业务含义:
|
|
288
|
+
* 1. 先保护周额度低于关键线的账号;除非所有账号都低,否则后置。
|
|
289
|
+
* 2. 主池内按周窗口浪费、受周额度约束的 5 小时浪费、健康度和使用分散度综合评分。
|
|
290
|
+
* 3. 返回完整决策对象,便于 CLI、server 或测试解释“为什么选这个号”。
|
|
291
|
+
*
|
|
292
|
+
* @param statuses 候选账号状态列表;调用方应已完成启用、登录态、限额与熔断过滤。
|
|
293
|
+
* @param statsByAccountId 账号调度统计表;key 为账号 id。
|
|
294
|
+
* @param nowMs 当前时间毫秒数;默认使用当前系统时间。
|
|
295
|
+
* @returns 按优先级从高到低排序后的调度决策。
|
|
296
|
+
* @throws 无显式抛出。
|
|
297
|
+
*/
|
|
298
|
+
function rankAccountStatuses(statuses, statsByAccountId, nowMs = Date.now()) {
|
|
299
|
+
const primaryPool = statuses.some((item) => item.weeklyLeftPercent === null ||
|
|
300
|
+
item.weeklyLeftPercent === undefined ||
|
|
301
|
+
item.weeklyLeftPercent > CRITICAL_WEEKLY_LEFT_PERCENT)
|
|
302
|
+
? statuses.filter((item) => item.weeklyLeftPercent === null ||
|
|
303
|
+
item.weeklyLeftPercent === undefined ||
|
|
304
|
+
item.weeklyLeftPercent > CRITICAL_WEEKLY_LEFT_PERCENT)
|
|
305
|
+
: statuses;
|
|
306
|
+
const deferredPool = statuses.filter((item) => !primaryPool.includes(item));
|
|
307
|
+
return [
|
|
308
|
+
...rankPool(primaryPool, statsByAccountId, nowMs),
|
|
309
|
+
...rankPool(deferredPool, statsByAccountId, nowMs)
|
|
310
|
+
];
|
|
311
|
+
}
|