creditdaddy 1.5.2 → 1.6.2
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/build.json +3 -3
- package/package.json +1 -1
- package/src/assets/donate-alipay.svg +1 -0
- package/src/assets/donate-ifdian.svg +1 -0
- package/src/assets/donate-wechat.svg +1 -0
- package/src/checkin.js +11 -10
- package/src/constants.js +3 -1
- package/src/cookieSites.js +30 -11
- package/src/daemon.js +2062 -1961
- package/src/doubaoClient.js +168 -0
- package/src/externalQuota.js +113 -9
- package/src/formatDuration.js +17 -0
- package/src/i18n/en.json +1734 -1755
- package/src/i18n/ja.json +1734 -1755
- package/src/i18n/ko.json +1734 -1755
- package/src/i18n/zh-TW.json +1734 -1755
- package/src/longcatClient.js +24 -15
- package/src/minimaxClient.js +29 -0
- package/src/minimaxGateway.js +459 -419
- package/src/mirasimClient.js +3 -0
- package/src/panel.html +393 -126
- package/src/providers.js +39 -3
- package/src/qoderApp.js +10 -1
- package/src/tenrouter.js +27 -3
- package/src/traeClient.js +23 -15
- package/src/traeGateway.js +52 -4
- package/src/traeLocal.js +14 -4
- package/src/usageSync.js +10 -2
- package/src/workbuddyClient.js +0 -45
- package/src/workbuddyLocal.js +40 -0
- package/src/zcodeAutoClaim.js +21 -4
- package/src/zcodeClient.js +8 -0
- package/src/zcodeGateway.js +763 -616
package/src/minimaxGateway.js
CHANGED
|
@@ -1,419 +1,459 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MiniMax Code 本地 Anthropic 兼容网关。
|
|
3
|
-
*
|
|
4
|
-
* 数据面端点:POST /gateway/minimax/v1/messages
|
|
5
|
-
* (上游为标准 Anthropic Messages 协议:https://agent.minimax.cn/mavis/api/v1/llm/v1/messages)
|
|
6
|
-
*
|
|
7
|
-
* 架构特性:
|
|
8
|
-
* 1. 多账号粘性主力(provider=minimax):按剩余积分选主力并保持,出错/限流/凭据失效自动切下一个
|
|
9
|
-
* 2. 自动刷新与重试:401 凭据失效时自动走 OAuth refresh 换取新 token 并重试,彻底失效才拉黑
|
|
10
|
-
* 3. 429 限流保护:429
|
|
11
|
-
* 4. 局域网访问控制:继承相同的白名单与回环检查机制
|
|
12
|
-
* 5. SSE 与 JSON 流式透明转发
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { fetchJsonRace } from './zcodeClient.js';
|
|
16
|
-
import { loadAccounts, loadSettings, saveSettings } from './store.js';
|
|
17
|
-
import { refreshMiniMaxToken, refreshAccountToken, fetchMiniMaxQuota, MINIMAX_MESSAGES_URL } from './minimaxClient.js';
|
|
18
|
-
import { clearRefreshDead, __resetRefreshGuardForTests } from './refreshGuard.js';
|
|
19
|
-
import { refreshContext } from './accounts.js';
|
|
20
|
-
import { gatewayHostSuggestion } from './tenrouter.js';
|
|
21
|
-
import { logger, summarizeAttempts } from './logger.js';
|
|
22
|
-
import { t } from './i18n.js';
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
function
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
return
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
res.
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
logger.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
},
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
};
|
|
419
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* MiniMax Code 本地 Anthropic 兼容网关。
|
|
3
|
+
*
|
|
4
|
+
* 数据面端点:POST /gateway/minimax/v1/messages
|
|
5
|
+
* (上游为标准 Anthropic Messages 协议:https://agent.minimax.cn/mavis/api/v1/llm/v1/messages)
|
|
6
|
+
*
|
|
7
|
+
* 架构特性:
|
|
8
|
+
* 1. 多账号粘性主力(provider=minimax):按剩余积分选主力并保持,出错/限流/凭据失效自动切下一个
|
|
9
|
+
* 2. 自动刷新与重试:401 凭据失效时自动走 OAuth refresh 换取新 token 并重试,彻底失效才拉黑
|
|
10
|
+
* 3. 429 限流保护:429 限流自动冷却(退避阶梯 30s → 1min → 2min → 5min → 10min 封顶,成功一次即清零)
|
|
11
|
+
* 4. 局域网访问控制:继承相同的白名单与回环检查机制
|
|
12
|
+
* 5. SSE 与 JSON 流式透明转发
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { fetchJsonRace } from './zcodeClient.js';
|
|
16
|
+
import { loadAccounts, loadSettings, saveSettings } from './store.js';
|
|
17
|
+
import { refreshMiniMaxToken, refreshAccountToken, fetchMiniMaxQuota, MINIMAX_MESSAGES_URL } from './minimaxClient.js';
|
|
18
|
+
import { clearRefreshDead, __resetRefreshGuardForTests } from './refreshGuard.js';
|
|
19
|
+
import { refreshContext } from './accounts.js';
|
|
20
|
+
import { gatewayHostSuggestion } from './tenrouter.js';
|
|
21
|
+
import { logger, summarizeAttempts } from './logger.js';
|
|
22
|
+
import { t } from './i18n.js';
|
|
23
|
+
import { formatDurationMs } from './formatDuration.js';
|
|
24
|
+
|
|
25
|
+
const ACCOUNT_COOLING_MS = 5 * 60_000;
|
|
26
|
+
const MAX_ATTEMPTS = 5;
|
|
27
|
+
const UPSTREAM_TIMEOUT_MS = 600_000;
|
|
28
|
+
const UPSTREAM_CONNECT_MS = 15_000;
|
|
29
|
+
|
|
30
|
+
// ── 开关(持久化在 settings.minimaxGateway) ──
|
|
31
|
+
|
|
32
|
+
export async function gatewayEnabled() {
|
|
33
|
+
return (await loadSettings()).minimaxGateway === true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function setGatewayEnabled(v) {
|
|
37
|
+
const on = v === true;
|
|
38
|
+
await saveSettings({ minimaxGateway: on });
|
|
39
|
+
// 开启网关视为「重新开始」:清掉进程内的拉黑/冷却状态,
|
|
40
|
+
// 否则重新授权后旧黑名单仍会把账号挡在轮转队列外(曾导致开关无效)。
|
|
41
|
+
if (on) {
|
|
42
|
+
dead.clear();
|
|
43
|
+
cooling.clear();
|
|
44
|
+
coolingStreak.clear();
|
|
45
|
+
clearRefreshDead();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── 账号轮换状态(进程内) ──
|
|
50
|
+
|
|
51
|
+
const cooling = new Map(); // accountId → 冷却截止(ms)
|
|
52
|
+
// accountId → 连续 429 次数(成功即清零):决定本次 429 走到退避阶梯的哪一档
|
|
53
|
+
const coolingStreak = new Map();
|
|
54
|
+
const dead = new Map(); // accountId → 拉黑时的凭据指纹(指纹变化 = 已重新授权/刷新,自动复活)
|
|
55
|
+
const weights = new Map(); // accountId → { w: 剩余积分权重, at: 查询时刻 }(查询失败时保留旧快照)
|
|
56
|
+
const WEIGHT_TTL_MS = 10 * 60_000; // 权重超过该时长重新查询额度,让用量变化能反映到分流上
|
|
57
|
+
|
|
58
|
+
export const stats = {
|
|
59
|
+
lastCallAt: null,
|
|
60
|
+
calls: 0,
|
|
61
|
+
lastAccount: null,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// 平滑加权轮询(SWRR):剩余积分多的账号分到更多请求,而不是雨露均沾地 round-robin。
|
|
65
|
+
// 查不到额度或为 0 时给保底权重,保证账号仍会被轮询(0 也可能只是查询失败/尚未刷新)。
|
|
66
|
+
const MIN_WEIGHT = 1;
|
|
67
|
+
|
|
68
|
+
function credFingerprint(a) {
|
|
69
|
+
return `${a.token || ''}|${a.refreshToken || ''}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function markCooling(id, ms = ACCOUNT_COOLING_MS) {
|
|
73
|
+
cooling.set(id, Date.now() + ms);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 429 退避阶梯:30s → 1min → 2min → 5min → 10min 封顶(与 ZCode 网关同款)。
|
|
77
|
+
const RATE_LIMIT_BACKOFF_MS = [30_000, 60_000, 120_000, 300_000, 600_000];
|
|
78
|
+
|
|
79
|
+
function markRateLimited(id) {
|
|
80
|
+
const streak = (coolingStreak.get(id) || 0) + 1;
|
|
81
|
+
coolingStreak.set(id, streak);
|
|
82
|
+
const ms = RATE_LIMIT_BACKOFF_MS[Math.min(streak, RATE_LIMIT_BACKOFF_MS.length) - 1];
|
|
83
|
+
cooling.set(id, Date.now() + ms);
|
|
84
|
+
return ms;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resetCoolingStreak(id) { coolingStreak.delete(id); }
|
|
88
|
+
|
|
89
|
+
function markDead(account) {
|
|
90
|
+
dead.set(account.id, credFingerprint(account));
|
|
91
|
+
cooling.set(account.id, Number.MAX_SAFE_INTEGER);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 账号是否仍处于拉黑状态。拉黑记的是「当时那份凭据」的指纹:
|
|
96
|
+
* 重新登录/重新导入/刷新回写换了 token 后指纹不匹配 → 自动复活(清黑名单与冷却)。
|
|
97
|
+
*/
|
|
98
|
+
function isDead(account) {
|
|
99
|
+
const fp = dead.get(account.id);
|
|
100
|
+
if (fp === undefined) return false;
|
|
101
|
+
if (fp === credFingerprint(account)) return true;
|
|
102
|
+
dead.delete(account.id);
|
|
103
|
+
cooling.delete(account.id);
|
|
104
|
+
coolingStreak.delete(account.id);
|
|
105
|
+
logger.info('MINIMAX-GW', '{label} 凭据已更新,解除拉黑', { label: account.name || account.uid || account.id });
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 测试专用:仅让权重快照过期(保留粘性/拉黑状态),用于模拟额度变化 */
|
|
110
|
+
export function __expireWeightsForTests() { weights.clear(); }
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 测试专用:把某账号的冷却截止拨到指定时间戳(0 = 立即到期,用于快进冷却窗口)。
|
|
114
|
+
* 生产不走这里——冷却窗口只能自然到期。与 zcodeGateway.__setCoolingForTests 同款。
|
|
115
|
+
*/
|
|
116
|
+
export function __setCoolingForTests(accountId, untilMs) { cooling.set(accountId, untilMs); }
|
|
117
|
+
|
|
118
|
+
/** 测试专用:清空进程内状态 */
|
|
119
|
+
export function __resetForTests() {
|
|
120
|
+
cooling.clear();
|
|
121
|
+
coolingStreak.clear();
|
|
122
|
+
dead.clear();
|
|
123
|
+
weights.clear();
|
|
124
|
+
stickyId = null;
|
|
125
|
+
stats.lastCallAt = null;
|
|
126
|
+
stats.calls = 0;
|
|
127
|
+
stats.lastAccount = null;
|
|
128
|
+
__resetRefreshGuardForTests();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 账号权重:剩余积分(带 TTL 的快照,避免每个请求都打额度接口)。查询失败时保留上次快照权重。 */
|
|
132
|
+
async function accountWeight(a) {
|
|
133
|
+
const hit = weights.get(a.id);
|
|
134
|
+
if (hit && Date.now() - hit.at < WEIGHT_TTL_MS) return hit.w;
|
|
135
|
+
let w = hit?.w ?? MIN_WEIGHT;
|
|
136
|
+
try {
|
|
137
|
+
const q = await fetchMiniMaxQuota(a, refreshContext(a, (m) => logger.info("MINIMAX-GW", t('{label} {msg}', { label: a.name || a.uid || a.id, msg: m }))));
|
|
138
|
+
w = Math.max(MIN_WEIGHT, Number(q?.remaining) || 0);
|
|
139
|
+
} catch {
|
|
140
|
+
// 额度查询失败时保留上次快照权重:一次网络抖动不该把主力账号踢成保底权重、引发主力来回切换
|
|
141
|
+
}
|
|
142
|
+
weights.set(a.id, { w, at: Date.now() });
|
|
143
|
+
return w;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── 粘性主力选型 ──
|
|
147
|
+
// 上次的主力账号仍健康时继续用它:两个账号积分接近时若逐请求轮换,会在两账号间
|
|
148
|
+
// 交替刷屏(「一主一备」困扰的来源,用户期望「出错/积分拉开差距才换」);
|
|
149
|
+
// 仅当另一账号剩余积分明显更多,或主力被拉黑/冷却,才主动换主力。
|
|
150
|
+
let stickyId = null;
|
|
151
|
+
const STICKY_MARGIN_RATIO = 0.1; // 积分领先超过 10% 才换
|
|
152
|
+
const STICKY_MARGIN_ABS = 50; // 且绝对差超过 50 积分
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 可用账号队列:队首 = 本次主力账号,其余按剩余积分降序作重试兜底。
|
|
156
|
+
* 拉黑/冷却在挂载时剔除;扭转主力粘性见上方说明。
|
|
157
|
+
*/
|
|
158
|
+
async function rotationQueue() {
|
|
159
|
+
const all = (await loadAccounts()).filter((a) => a.provider === 'minimax');
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
const ready = [];
|
|
162
|
+
for (const a of all) {
|
|
163
|
+
if (isDead(a)) continue;
|
|
164
|
+
if ((cooling.get(a.id) || 0) > now) continue;
|
|
165
|
+
if (!a.token) continue;
|
|
166
|
+
ready.push({ account: a, weight: await accountWeight(a) });
|
|
167
|
+
}
|
|
168
|
+
if (!ready.length) return [];
|
|
169
|
+
|
|
170
|
+
ready.sort((x, y) => y.weight - x.weight);
|
|
171
|
+
let head = ready[0];
|
|
172
|
+
const sticky = stickyId ? ready.find((it) => it.account.id === stickyId) : null;
|
|
173
|
+
if (sticky && sticky.account.id !== head.account.id) {
|
|
174
|
+
const margin = Math.max(STICKY_MARGIN_ABS, head.weight * STICKY_MARGIN_RATIO);
|
|
175
|
+
if (head.weight - sticky.weight < margin) head = sticky;
|
|
176
|
+
}
|
|
177
|
+
if (stickyId && stickyId !== head.account.id) {
|
|
178
|
+
const from = all.find((a) => a.id === stickyId);
|
|
179
|
+
logger.info('MINIMAX-GW', '主力账号 {from} → {to}(剩余积分领先)', { from: from?.name || from?.uid || stickyId, to: head.account.name || head.account.uid || head.account.id });
|
|
180
|
+
}
|
|
181
|
+
stickyId = head.account.id;
|
|
182
|
+
return [head.account, ...ready.filter((it) => it.account.id !== head.account.id).map((it) => it.account)];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function unavailableReason() {
|
|
186
|
+
const all = (await loadAccounts()).filter((a) => a.provider === 'minimax');
|
|
187
|
+
if (!all.length) return '没有 MiniMax 账号:请在 MiniMax Code 登录后在面板进行本机导入,或粘贴 Token 添加';
|
|
188
|
+
const now = Date.now();
|
|
189
|
+
const n = { dead: 0, cooling: 0, noToken: 0 };
|
|
190
|
+
for (const a of all) {
|
|
191
|
+
if (isDead(a)) n.dead++;
|
|
192
|
+
else if ((cooling.get(a.id) || 0) > now) n.cooling++;
|
|
193
|
+
else if (!a.token) n.noToken++;
|
|
194
|
+
}
|
|
195
|
+
const parts = [];
|
|
196
|
+
if (n.cooling) parts.push(`${n.cooling} 个临时冷却中(稍后自动恢复)`);
|
|
197
|
+
if (n.dead) parts.push(`${n.dead} 个凭据已失效(需在 MiniMax Code 重新登录后导入)`);
|
|
198
|
+
if (n.noToken) parts.push(`${n.noToken} 个缺少 Token`);
|
|
199
|
+
return `没有可用的 MiniMax 账号:${parts.join(';')}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const LOOPBACK_RE = /^(::1|::ffff:127\.0\.0\.1|127\.0\.0\.1)$/;
|
|
203
|
+
function isLoopbackRemote(remote) {
|
|
204
|
+
return !remote || LOOPBACK_RE.test(String(remote));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function remoteHost(remote) {
|
|
208
|
+
return String(remote || '').replace(/^::ffff:/i, '').trim();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function remoteAllowed(remote, allowList) {
|
|
212
|
+
const host = remoteHost(remote);
|
|
213
|
+
if (!host) return false;
|
|
214
|
+
return (Array.isArray(allowList) ? allowList : []).some((entry) => {
|
|
215
|
+
const e = String(entry || '').trim();
|
|
216
|
+
if (!e) return false;
|
|
217
|
+
if (e.endsWith('*')) {
|
|
218
|
+
// 通配要求带点段尾:防止 192.168.31.1* 这类把 .100-.199 整段一起放行的怪手。
|
|
219
|
+
// 要扩整段子网,把写法统一为 192.168.31.*(带尾句点)。
|
|
220
|
+
const prefix = e.slice(0, -1);
|
|
221
|
+
if (!prefix.endsWith('.')) return false;
|
|
222
|
+
return host.toLowerCase().startsWith(prefix.toLowerCase());
|
|
223
|
+
}
|
|
224
|
+
return host.toLowerCase() === e.toLowerCase();
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function readRawBody(req) {
|
|
229
|
+
return new Promise((resolve, reject) => {
|
|
230
|
+
const chunks = [];
|
|
231
|
+
let size = 0;
|
|
232
|
+
const limit = 20 * 1024 * 1024; // 20MB
|
|
233
|
+
req.on('data', (chunk) => {
|
|
234
|
+
size += chunk.length;
|
|
235
|
+
if (size > limit) {
|
|
236
|
+
// 只 pause 不 destroy:destroy 会把 socket 打死,随后写的 413 客户端收不到(只看到 ECONNRESET)
|
|
237
|
+
req.pause();
|
|
238
|
+
const err = new Error('请求体过大(上限 20MB)');
|
|
239
|
+
err.status = 413;
|
|
240
|
+
reject(err);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
chunks.push(chunk);
|
|
244
|
+
});
|
|
245
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
246
|
+
req.on('error', reject);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 处理 MiniMax 本地网关请求
|
|
252
|
+
*/
|
|
253
|
+
export async function handleGateway(req, res) {
|
|
254
|
+
if (req.method !== 'POST') {
|
|
255
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
256
|
+
return res.end(JSON.stringify({ error: 'POST /gateway/minimax/v1/messages(Anthropic /v1/messages 形态)' }));
|
|
257
|
+
}
|
|
258
|
+
const settings = await loadSettings();
|
|
259
|
+
if (settings.minimaxGateway !== true) {
|
|
260
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
261
|
+
return res.end(JSON.stringify({ error: 'MiniMax 本地网关未开启(面板 → MiniMax → 接口设置)' }));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 局域网访问控制
|
|
265
|
+
const remote = (req.socket && req.socket.remoteAddress) || '';
|
|
266
|
+
if (!isLoopbackRemote(remote)) {
|
|
267
|
+
if (settings.minimaxGatewayLan !== true) {
|
|
268
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
269
|
+
return res.end(JSON.stringify({ error: 'MiniMax 网关未允许局域网访问(面板 → MiniMax → 接口设置)' }));
|
|
270
|
+
}
|
|
271
|
+
if (!remoteAllowed(remote, settings.minimaxGatewayAllow)) {
|
|
272
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
273
|
+
return res.end(JSON.stringify({ error: `来源 ${remoteHost(remote) || '未知'} 不在 MiniMax 网关 IP 白名单内` }));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let rawBody;
|
|
278
|
+
try {
|
|
279
|
+
rawBody = await readRawBody(req);
|
|
280
|
+
} catch (e) {
|
|
281
|
+
res.writeHead(e.status || 400, { 'Content-Type': 'application/json' });
|
|
282
|
+
// 超限时请求体还没读完:先把 413 发出去,发完再断开
|
|
283
|
+
res.on('finish', () => req.destroy());
|
|
284
|
+
return res.end(JSON.stringify({ error: e.message }));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const queue = await rotationQueue();
|
|
288
|
+
if (!queue.length) {
|
|
289
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
290
|
+
return res.end(JSON.stringify({ error: await unavailableReason() }));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
stats.lastCallAt = new Date().toISOString();
|
|
294
|
+
stats.calls += 1;
|
|
295
|
+
|
|
296
|
+
const attempted = [];
|
|
297
|
+
const clientAbort = new AbortController();
|
|
298
|
+
req.once('close', () => { if (!res.writableEnded) clientAbort.abort(); });
|
|
299
|
+
|
|
300
|
+
for (let i = 0; i < Math.min(MAX_ATTEMPTS, queue.length); i++) {
|
|
301
|
+
const account = queue[i];
|
|
302
|
+
if (isDead(account) || (cooling.get(account.id) || 0) > Date.now()) continue;
|
|
303
|
+
const label = account.name || account.uid || account.id;
|
|
304
|
+
|
|
305
|
+
let upstream;
|
|
306
|
+
|
|
307
|
+
let activeToken = account.token;
|
|
308
|
+
const sendUpstream = async (tok) => {
|
|
309
|
+
const headers = {
|
|
310
|
+
'Content-Type': 'application/json',
|
|
311
|
+
Accept: 'application/json, text/event-stream',
|
|
312
|
+
Authorization: tok.startsWith('Bearer ') ? tok : `Bearer ${tok}`,
|
|
313
|
+
'anthropic-version': '2023-06-01',
|
|
314
|
+
};
|
|
315
|
+
return fetchJsonRace(MINIMAX_MESSAGES_URL, {
|
|
316
|
+
method: 'POST',
|
|
317
|
+
headers,
|
|
318
|
+
body: rawBody,
|
|
319
|
+
timeoutMs: UPSTREAM_TIMEOUT_MS,
|
|
320
|
+
connectMs: UPSTREAM_CONNECT_MS,
|
|
321
|
+
signal: clientAbort.signal,
|
|
322
|
+
});
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
upstream = await sendUpstream(activeToken);
|
|
327
|
+
// 如果 401 且账号有 refreshToken,尝试就地刷新一次并重试(走与 checkin/quota 统一的 refreshAccountToken,自带互斥防竞态与自动落盘)
|
|
328
|
+
if (upstream.status === 401 && account.refreshToken) {
|
|
329
|
+
logger.info('MINIMAX-GW', '{label} 收到 401,尝试刷新凭据...', { label });
|
|
330
|
+
try {
|
|
331
|
+
const creds = await refreshAccountToken(account, {
|
|
332
|
+
log: (m) => logger.info('MINIMAX-GW', '{label} {msg}', { label, msg: m }),
|
|
333
|
+
});
|
|
334
|
+
activeToken = creds.token;
|
|
335
|
+
// 刷新回写即凭据更新:解除该账号可能存在的拉黑/冷却(指纹已变化,防御性直清)
|
|
336
|
+
dead.delete(account.id);
|
|
337
|
+
cooling.delete(account.id);
|
|
338
|
+
resetCoolingStreak(account.id);
|
|
339
|
+
upstream = await sendUpstream(activeToken);
|
|
340
|
+
} catch (refErr) {
|
|
341
|
+
logger.warn('MINIMAX-GW', '{label} 凭据刷新失败: {err}', { label, err: refErr.message });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
} catch (e) {
|
|
345
|
+
if (clientAbort.signal.aborted) return;
|
|
346
|
+
attempted.push({ account: label, ok: false, error: e.message });
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (upstream.ok) {
|
|
351
|
+
stats.lastAccount = label;
|
|
352
|
+
resetCoolingStreak(account.id); // 成功一次即清零连续 429 计数
|
|
353
|
+
logger.info('MINIMAX-GW', '{label} 补全成功 ({status})', { label, status: upstream.status });
|
|
354
|
+
try {
|
|
355
|
+
const meta = JSON.parse(rawBody);
|
|
356
|
+
logger.debug('MINIMAX-GW', '请求详情:model={model} stream={stream} ua={ua}', { model: meta?.model || '-', stream: meta?.stream ? 'y' : 'n', ua: req.headers?.['user-agent'] || '-' });
|
|
357
|
+
} catch {}
|
|
358
|
+
|
|
359
|
+
// 透传头(SSE / JSON)
|
|
360
|
+
const forwardHeaders = {
|
|
361
|
+
'Content-Type': upstream.headers.get('content-type') || 'application/json',
|
|
362
|
+
'Cache-Control': 'no-cache',
|
|
363
|
+
};
|
|
364
|
+
res.writeHead(upstream.status, forwardHeaders);
|
|
365
|
+
|
|
366
|
+
if (upstream.body && typeof upstream.body.pipe === 'function') {
|
|
367
|
+
upstream.body.pipe(res);
|
|
368
|
+
} else if (upstream.body && typeof upstream.body.getReader === 'function') {
|
|
369
|
+
const reader = upstream.body.getReader();
|
|
370
|
+
try {
|
|
371
|
+
while (true) {
|
|
372
|
+
const { done, value } = await reader.read();
|
|
373
|
+
if (done) break;
|
|
374
|
+
if (!res.write(Buffer.from(value))) {
|
|
375
|
+
await new Promise((r) => res.once('drain', r));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
} catch (e) {
|
|
379
|
+
// 客户端主动断开(点停止/换一句)属正常收尾,不是故障
|
|
380
|
+
logger.debug('MINIMAX-GW', '流式转发中断:{err}', { err: e.message });
|
|
381
|
+
}
|
|
382
|
+
res.end();
|
|
383
|
+
} else {
|
|
384
|
+
const buf = await upstream.arrayBuffer().catch(() => null);
|
|
385
|
+
res.end(buf ? Buffer.from(buf) : '');
|
|
386
|
+
}
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// 失败处理
|
|
391
|
+
const errText = await upstream.text().catch(() => '');
|
|
392
|
+
if (upstream.status === 401) {
|
|
393
|
+
markDead(account);
|
|
394
|
+
attempted.push({ account: label, ok: false, error: 'Token 彻底失效 (401),拉黑' });
|
|
395
|
+
logger.info('MINIMAX-GW', '{label} 凭据失效,拉黑', { label });
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (upstream.status === 402 || /insufficient_balance|余额不足|额度不足/i.test(errText)) {
|
|
399
|
+
markCooling(account.id, 30 * 60_000);
|
|
400
|
+
attempted.push({ account: label, ok: false, error: '额度耗尽 (402),冷却 30 分钟' });
|
|
401
|
+
logger.warn('MINIMAX-GW', '{label} 额度耗尽 (402),冷却 30 分钟', { label });
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (upstream.status === 429) {
|
|
405
|
+
const ms = markRateLimited(account.id);
|
|
406
|
+
const dur = formatDurationMs(ms);
|
|
407
|
+
const streak = coolingStreak.get(account.id);
|
|
408
|
+
attempted.push({ account: label, ok: false, error: `429 限流,冷却 ${dur}` });
|
|
409
|
+
logger.info('MINIMAX-GW', '{label} 429 限流,冷却 {dur}(连续第 {streak} 次)', { label, dur, streak });
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
attempted.push({ account: label, ok: false, error: `上游错误 HTTP ${upstream.status}: ${errText.slice(0, 100)}` });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// 全部重试失败
|
|
417
|
+
const failedDetail = summarizeAttempts(attempted);
|
|
418
|
+
logger.warn('MINIMAX-GW', '全部 {n} 次尝试失败{detail}(下一请求自动重试)', { n: attempted.length, detail: failedDetail ? ':' + failedDetail : '' });
|
|
419
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
420
|
+
res.end(JSON.stringify({
|
|
421
|
+
error: '所有 MiniMax 账号均调用失败',
|
|
422
|
+
attempts: attempted,
|
|
423
|
+
}));
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export async function gatewayStatus() {
|
|
428
|
+
const enabled = await gatewayEnabled();
|
|
429
|
+
const all = (await loadAccounts()).filter((a) => a.provider === 'minimax');
|
|
430
|
+
const now = Date.now();
|
|
431
|
+
const settings = await loadSettings();
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
enabled,
|
|
435
|
+
lan: settings.minimaxGatewayLan === true,
|
|
436
|
+
allow: Array.isArray(settings.minimaxGatewayAllow) ? settings.minimaxGatewayAllow : [],
|
|
437
|
+
suggestAllow: gatewayHostSuggestion(),
|
|
438
|
+
stats: {
|
|
439
|
+
lastCallAt: stats.lastCallAt,
|
|
440
|
+
calls: stats.calls,
|
|
441
|
+
lastAccount: stats.lastAccount,
|
|
442
|
+
},
|
|
443
|
+
accounts: all.length,
|
|
444
|
+
// cooling 保持字符串数组(历史形状);冷却细节走 coolingDetail(与 zcodeGateway 同款)
|
|
445
|
+
cooling: all.filter((a) => !isDead(a) && (cooling.get(a.id) || 0) > now).map((a) => a.name || a.uid || a.id),
|
|
446
|
+
coolingDetail: all.filter((a) => !isDead(a) && (cooling.get(a.id) || 0) > now)
|
|
447
|
+
.map((a) => {
|
|
448
|
+
const remainMs = (cooling.get(a.id) || 0) - now;
|
|
449
|
+
return {
|
|
450
|
+
id: a.id, name: a.name || a.uid || a.id,
|
|
451
|
+
remainMs, remainSeconds: Math.max(0, Math.ceil(remainMs / 1000)),
|
|
452
|
+
remainText: formatDurationMs(remainMs),
|
|
453
|
+
streak: coolingStreak.get(a.id) || 0,
|
|
454
|
+
};
|
|
455
|
+
}),
|
|
456
|
+
dead: all.filter((a) => isDead(a)).map((a) => a.name || a.uid || a.id),
|
|
457
|
+
endpoint: '/gateway/minimax/v1/messages',
|
|
458
|
+
};
|
|
459
|
+
}
|