creditdaddy 1.3.2 → 1.4.0
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/README.md +65 -13
- package/bin/creditdaddy.js +33 -12
- package/package.json +5 -2
- package/src/accounts.js +10 -0
- package/src/authDevice.js +5 -4
- package/src/bgdaemon.js +14 -5
- package/src/checkin.js +69 -39
- package/src/daemon.js +1266 -1059
- package/src/i18n/en.json +1490 -0
- package/src/i18n/ja.json +1490 -0
- package/src/i18n/ko.json +1490 -0
- package/src/i18n/zh-TW.json +1490 -0
- package/src/i18n.js +47 -0
- package/src/logger.js +114 -17
- package/src/minimaxClient.js +18 -21
- package/src/minimaxGateway.js +63 -45
- package/src/minimaxLocal.js +61 -16
- package/src/mirasimClient.js +6 -1
- package/src/panel.html +1139 -626
- package/src/qoderClient.js +24 -4
- package/src/qoderUmid.js +4 -3
- package/src/refreshGuard.js +69 -0
- package/src/store.js +11 -0
- package/src/tenrouter.js +14 -7
- package/src/traeGateway.js +43 -24
- package/src/transfer.js +44 -6
- package/src/workbuddyClient.js +14 -7
- package/src/zcodeAutoClaim.js +47 -7
- package/src/zcodeClient.js +8 -3
- package/src/zcodeGateway.js +22 -15
package/src/i18n.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** 轻量国际化:以中文原文为 key,缺翻译时回退中文。字典位于 src/i18n/<locale>.json。 */
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
export const LOCALES = ['zh-CN', 'zh-TW', 'en', 'ja', 'ko'];
|
|
8
|
+
const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'i18n');
|
|
9
|
+
const cache = new Map();
|
|
10
|
+
|
|
11
|
+
export function normalizeLocale(v) {
|
|
12
|
+
const s = String(v || '').trim();
|
|
13
|
+
return LOCALES.includes(s) ? s : 'zh-CN';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function dictFor(locale) {
|
|
17
|
+
if (locale === 'zh-CN') return {};
|
|
18
|
+
if (!cache.has(locale)) {
|
|
19
|
+
try {
|
|
20
|
+
cache.set(locale, JSON.parse(fs.readFileSync(path.join(DIR, `${locale}.json`), 'utf8')));
|
|
21
|
+
} catch {
|
|
22
|
+
cache.set(locale, {});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return cache.get(locale);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function loadDict(locale) {
|
|
29
|
+
return dictFor(normalizeLocale(locale));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let current = 'zh-CN';
|
|
33
|
+
|
|
34
|
+
export function setLocale(v) {
|
|
35
|
+
current = normalizeLocale(v);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getLocale() {
|
|
39
|
+
return current;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** t('已连接 {n} 个账号', { n: 2 }) → 按当前语言取译文,再替换 {占位符} */
|
|
43
|
+
export function t(text, vars) {
|
|
44
|
+
const s = dictFor(current)[text] ?? text;
|
|
45
|
+
if (!vars) return s;
|
|
46
|
+
return s.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m));
|
|
47
|
+
}
|
package/src/logger.js
CHANGED
|
@@ -8,6 +8,8 @@ import fsSync from 'node:fs';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import os from 'node:os';
|
|
10
10
|
import zlib from 'node:zlib';
|
|
11
|
+
import { t } from './i18n.js';
|
|
12
|
+
import { PROVIDER_LABEL } from './constants.js';
|
|
11
13
|
|
|
12
14
|
const ARCHIVE_RETENTION_DAYS = 7; // .log.gz 归档保留天数,更早的自动清理
|
|
13
15
|
|
|
@@ -21,6 +23,8 @@ try {
|
|
|
21
23
|
|
|
22
24
|
let archiveDay = null;
|
|
23
25
|
let archiveStream = null;
|
|
26
|
+
let jsonlStream = null;
|
|
27
|
+
let jsonlFile = null;
|
|
24
28
|
|
|
25
29
|
function archiveLine(line) {
|
|
26
30
|
if (!archiveDir) return;
|
|
@@ -36,10 +40,22 @@ function archiveLine(line) {
|
|
|
36
40
|
prev.on('close', () => gzipAndCleanup(prevFile));
|
|
37
41
|
prev.end();
|
|
38
42
|
}
|
|
43
|
+
if (jsonlStream) {
|
|
44
|
+
const prevj = jsonlStream;
|
|
45
|
+
const prevjFile = jsonlFile;
|
|
46
|
+
jsonlStream = null;
|
|
47
|
+
prevj.on('error', () => {});
|
|
48
|
+
prevj.on('close', () => gzipAndCleanup(prevjFile));
|
|
49
|
+
prevj.end();
|
|
50
|
+
}
|
|
39
51
|
archiveStream = fsSync.createWriteStream(path.join(archiveDir, `daemon-${day}.log`), { flags: 'a' });
|
|
52
|
+
jsonlFile = path.join(archiveDir, `daemon-${day}.jsonl`);
|
|
53
|
+
jsonlStream = fsSync.createWriteStream(jsonlFile, { flags: 'a' });
|
|
40
54
|
archiveDay = day;
|
|
41
55
|
}
|
|
42
56
|
archiveStream.write('[' + line.at + '] ' + line.level.toUpperCase() + ' [' + line.tag + '] ' + line.msg + '\n');
|
|
57
|
+
// 结构化副行(JSONL):key+args 原样落盘,读取侧按当前语言重渲染(历史也能切语言)
|
|
58
|
+
jsonlStream.write(JSON.stringify({ at: line.at, level: line.level, tag: line.tag, msg: line.msg, key: line.key, args: line.args }) + '\n');
|
|
43
59
|
} catch { // 落盘失败不拖累主流程
|
|
44
60
|
}
|
|
45
61
|
}
|
|
@@ -81,7 +97,7 @@ function cleanupArchive() {
|
|
|
81
97
|
cutoff.setDate(cutoff.getDate() - ARCHIVE_RETENTION_DAYS);
|
|
82
98
|
cutoff.setHours(0, 0, 0, 0);
|
|
83
99
|
for (const name of fsSync.readdirSync(archiveDir)) {
|
|
84
|
-
const m = /^daemon-(\d{4}-\d{2}-\d{2})\.log(\.gz)?$/.exec(name);
|
|
100
|
+
const m = /^daemon-(\d{4}-\d{2}-\d{2})\.(?:log|jsonl)(\.gz)?$/.exec(name);
|
|
85
101
|
if (!m) continue; // 只动本 daemon 的归档文件,不碰目录里其他文件
|
|
86
102
|
const f = path.join(archiveDir, name);
|
|
87
103
|
let fileDay;
|
|
@@ -93,8 +109,9 @@ function cleanupArchive() {
|
|
|
93
109
|
} catch {}
|
|
94
110
|
}
|
|
95
111
|
|
|
96
|
-
export function log(level, tag, msg) {
|
|
97
|
-
const
|
|
112
|
+
export function log(level, tag, msg, args) {
|
|
113
|
+
const rendered = t(String(msg), args);
|
|
114
|
+
const line = { at: ts(), level, tag, msg: rendered, key: args ? String(msg) : null, args: args || null };
|
|
98
115
|
ring.push(line);
|
|
99
116
|
if (ring.length > RING_SIZE) ring.shift();
|
|
100
117
|
archiveLine(line);
|
|
@@ -103,10 +120,10 @@ export function log(level, tag, msg) {
|
|
|
103
120
|
}
|
|
104
121
|
|
|
105
122
|
export const logger = {
|
|
106
|
-
info: (tag, msg) => log('info', tag, msg),
|
|
107
|
-
warn: (tag, msg) => log('warn', tag, msg),
|
|
108
|
-
error: (tag, msg) => log('error', tag, msg),
|
|
109
|
-
debug: (tag, msg) => log('debug', tag, msg),
|
|
123
|
+
info: (tag, msg, args) => log('info', tag, msg, args),
|
|
124
|
+
warn: (tag, msg, args) => log('warn', tag, msg, args),
|
|
125
|
+
error: (tag, msg, args) => log('error', tag, msg, args),
|
|
126
|
+
debug: (tag, msg, args) => log('debug', tag, msg, args),
|
|
110
127
|
};
|
|
111
128
|
|
|
112
129
|
/**
|
|
@@ -133,11 +150,86 @@ export function summarizeAttempts(attempted) {
|
|
|
133
150
|
return [...buckets.entries()].map(([k, n]) => `${k}×${n}`).join('、');
|
|
134
151
|
}
|
|
135
152
|
|
|
153
|
+
/**
|
|
154
|
+
* 运行期拼出的复合参数(如「[WorkBuddy 国内版] 账号名」)不可能整串进字典(账号名千变万化),
|
|
155
|
+
* 其中已知的产品 / 区域标签按当前语言做子串替换,其余字段原样保留。
|
|
156
|
+
*/
|
|
157
|
+
// 除产品线标签外,失败摘要的桶标签(summarizeAttempts 产出,记进 {detail} 参数,形如「账号拉黑×2」)
|
|
158
|
+
// 同样是运行期复合串,整串无法进词典,按子串替换。
|
|
159
|
+
const ATTEMPT_BUCKETS = ['验证码被拒', '账号拉黑', '额度耗尽', '额度瞬时拒绝', '429 限流', '网络错误', '上游错误'];
|
|
160
|
+
|
|
161
|
+
function localizeEmbedded(text) {
|
|
162
|
+
let out = text;
|
|
163
|
+
for (const zh of [...Object.values(PROVIDER_LABEL), ...ATTEMPT_BUCKETS]) {
|
|
164
|
+
if (!out.includes(zh)) continue;
|
|
165
|
+
const tv = t(zh);
|
|
166
|
+
if (tv !== zh) out = out.split(zh).join(tv);
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 带运行时数字的既有消息(连签 11 天 / 第 1 天 +400 积分):按模式还原成模板再按当前语言渲染 */
|
|
172
|
+
const ARG_PATTERNS = [
|
|
173
|
+
[/^今日已签(连签 (\d+) 天)$/, '今日已签(连签 {n} 天)', (m) => ({ n: m[1] })],
|
|
174
|
+
[/^今日第 (\d+) 天已签到(([\d.]+) \+ ([\d.]+) 积分)$/, '今日第 {d} 天已签到({a} + {b} 积分)', (m) => ({ d: m[1], a: m[2], b: m[3] })],
|
|
175
|
+
[/^签到成功第 (\d+) 天(\+(\d+) 积分)$/, '签到成功第 {d} 天(+{p} 积分)', (m) => ({ d: m[1], p: m[2] })],
|
|
176
|
+
[/^今日已领取(\+(\d+) 积分)$/, '今日已领取(+{p} 积分)', (m) => ({ p: m[1] })],
|
|
177
|
+
[/^本机今日国际版额度已由「(.+)」领取(Qoder 每台设备每天限领一次)$/, '本机今日国际版额度已由「{name}」领取(Qoder 每台设备每天限领一次)', (m) => ({ name: localizeEmbedded(m[1]) })],
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
/** 字符串参数的三级翻译:整串精确 → 模式还原模板 → 子串标签替换 */
|
|
181
|
+
function localizeArgString(v, ctx) {
|
|
182
|
+
const tv = t(v, ctx);
|
|
183
|
+
if (tv !== v) return tv;
|
|
184
|
+
for (const [re, tpl, vars] of ARG_PATTERNS) {
|
|
185
|
+
const m = re.exec(v);
|
|
186
|
+
if (m) return t(tpl, vars(m));
|
|
187
|
+
}
|
|
188
|
+
return localizeEmbedded(v);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function localizeArgs(args) {
|
|
192
|
+
if (!args) return args;
|
|
193
|
+
const loc = {};
|
|
194
|
+
for (const [k, v] of Object.entries(args)) loc[k] = typeof v === 'string' ? localizeArgString(v, args) : v;
|
|
195
|
+
const out = {};
|
|
196
|
+
for (const [k, v] of Object.entries(args)) out[k] = typeof v === 'string' ? localizeArgString(v, loc) : v;
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
136
200
|
export function getLogs(limit = 100, tag = null) {
|
|
137
201
|
// tag=null 是面板「运行日志」:网关调用日志(*-GW)刷屏快、口径不同,
|
|
138
202
|
// 只在自己的标签页里展示,不混进运行日志;显式传 tag 的查询不受影响。
|
|
139
203
|
const lines = tag ? ring.filter((l) => l.tag === tag) : ring.filter((l) => !(l.tag || '').endsWith('-GW'));
|
|
140
|
-
return lines.slice(-limit);
|
|
204
|
+
return lines.slice(-limit).map((l) => (l.key ? { ...l, msg: t(l.key, localizeArgs(l.args)) } : l));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 读取结构化归档(.jsonl;缺省读当日、最多 limit 条)并按当前语言重渲染。
|
|
210
|
+
* 旧版本没有 .jsonl 时返回 [],调用方静默回退到环形缓冲。
|
|
211
|
+
* 同时兼容已 gzip 的当日归档(.jsonl.gz)。
|
|
212
|
+
*/
|
|
213
|
+
export function readArchiveJsonl({ day = null, limit = 2000 } = {}) {
|
|
214
|
+
if (!archiveDir) return [];
|
|
215
|
+
const d = day || ts().slice(0, 10);
|
|
216
|
+
const file = path.join(archiveDir, `daemon-${d}.jsonl`);
|
|
217
|
+
const gz = file + '.gz';
|
|
218
|
+
let raw = '';
|
|
219
|
+
try {
|
|
220
|
+
raw = fsSync.existsSync(file) ? fsSync.readFileSync(file, 'utf8') : '';
|
|
221
|
+
if (!raw && fsSync.existsSync(gz)) raw = zlib.gunzipSync(fsSync.readFileSync(gz)).toString('utf8');
|
|
222
|
+
} catch { return []; }
|
|
223
|
+
if (!raw) return [];
|
|
224
|
+
const lines = raw.split('\n').filter(Boolean).slice(-Math.max(1, Number(limit) || 2000));
|
|
225
|
+
const out = [];
|
|
226
|
+
for (const ln of lines) {
|
|
227
|
+
try {
|
|
228
|
+
const e = JSON.parse(ln);
|
|
229
|
+
out.push({ at: e.at, level: e.level, tag: e.tag, msg: e.key ? t(e.key, localizeArgs(e.args)) : e.msg });
|
|
230
|
+
} catch {}
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
141
233
|
}
|
|
142
234
|
|
|
143
235
|
/**
|
|
@@ -148,19 +240,24 @@ export function getLogs(limit = 100, tag = null) {
|
|
|
148
240
|
* 调用方不 await(测试里就是)也完全兼容,只是没人等它。
|
|
149
241
|
*/
|
|
150
242
|
export function closeArchiveStream() {
|
|
151
|
-
if (!archiveStream) return Promise.resolve();
|
|
152
243
|
const prev = archiveStream;
|
|
153
|
-
const
|
|
244
|
+
const prevJ = jsonlStream;
|
|
245
|
+
const prevFile = archiveDay ? path.join(archiveDir, `daemon-${archiveDay}.log`) : null;
|
|
246
|
+
const prevJFile = jsonlFile;
|
|
154
247
|
archiveStream = null;
|
|
248
|
+
jsonlStream = null;
|
|
155
249
|
archiveDay = null;
|
|
156
|
-
|
|
250
|
+
const closeOne = (stream, file) => new Promise((resolve) => {
|
|
251
|
+
if (!stream) return resolve();
|
|
157
252
|
let settled = false;
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
|
|
253
|
+
const finish = (gzip) => { if (!settled) { settled = true; if (gzip) gzipAndCleanup(file); resolve(); } };
|
|
254
|
+
stream.on('error', () => finish(false));
|
|
255
|
+
// 只等 close 事件收尾(gzip 也在这里做):end 的回调在 finish 阶段就触发,
|
|
256
|
+
// 那时 close 还没来、.gz 还没生成——await 完断言会看到对象不齐(测试 #3 逮过)
|
|
257
|
+
stream.on('close', () => finish(true));
|
|
258
|
+
const timer = setTimeout(() => finish(false), 2000); // 兜底:close 不来不挂死
|
|
163
259
|
timer.unref?.();
|
|
164
|
-
|
|
260
|
+
stream.end();
|
|
165
261
|
});
|
|
262
|
+
return Promise.all([closeOne(prev, prevFile), closeOne(prevJ, prevJFile)]);
|
|
166
263
|
}
|
package/src/minimaxClient.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { fetchJsonRace } from './zcodeClient.js';
|
|
16
|
+
import { withRefreshDedupe, refreshFpDead } from './refreshGuard.js';
|
|
16
17
|
import { FETCH_TIMEOUT_MS } from './constants.js';
|
|
17
18
|
|
|
18
19
|
export const MINIMAX_AGENT_BASE = 'https://agent.minimax.cn';
|
|
@@ -85,8 +86,10 @@ export async function refreshMiniMaxToken(refreshToken) {
|
|
|
85
86
|
/**
|
|
86
87
|
* 带自动刷新 401 重试的高阶调用。
|
|
87
88
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
89
|
+
* 刷新前若该账号带有 meta.authRecordKey(绑定本机客户端凭据链,仅本机导入账号会有),
|
|
90
|
+
* 先从 ~/.minimax auth.json 重读该 record 的最新 refreshToken——本机客户端会独立轮换刷新,
|
|
91
|
+
* 账号库快照可能已被服务端作废(invalid_grant)。对齐时会校验 uid 归属,链不算本账号的则跳过
|
|
92
|
+
* (见 minimaxLocal.alignMiniMaxFromLocal)。
|
|
90
93
|
* 刷新成功后经 ctx.onRefresh 回写账号库,并尝试安全回写 auth.json(见 minimaxLocal.writeMiniMaxAuth)。
|
|
91
94
|
*/
|
|
92
95
|
export async function withMiniMaxAuth(account, fn, ctx = {}) {
|
|
@@ -102,44 +105,38 @@ export async function withMiniMaxAuth(account, fn, ctx = {}) {
|
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* 面板每 2 分钟刷一轮账号额度,很容易和 2 小时一次的签到轮撞上。
|
|
108
|
-
* 用 in-flight 表把同一账号的并发刷新合并成一次,后到者直接用刷新出来的新 token 重试。
|
|
108
|
+
* 并发互斥 + 失效链熔断已提升到 src/refreshGuard.js —— workbuddy / mirasim 复用同一守卫。
|
|
109
|
+
* MiniMax 特有的点:刷新前先 alignMiniMaxFromLocal 对齐本机客户端最新链(客户端会轮换)。
|
|
109
110
|
*/
|
|
110
|
-
const refreshInFlight = new Map();
|
|
111
|
-
|
|
112
111
|
function refreshAccountToken(account, ctx = {}) {
|
|
113
|
-
|
|
114
|
-
const inflight = refreshInFlight.get(key);
|
|
115
|
-
if (inflight) return inflight;
|
|
116
|
-
const p = (async () => {
|
|
117
|
-
// 本机导入账号:刷新前对齐客户端最新的 refreshToken,避免用陈旧快照刷新触发 invalid_grant
|
|
112
|
+
return withRefreshDedupe(account, async (a) => {
|
|
118
113
|
try {
|
|
119
114
|
const { alignMiniMaxFromLocal } = await import('./minimaxLocal.js');
|
|
120
|
-
alignMiniMaxFromLocal(
|
|
115
|
+
alignMiniMaxFromLocal(a, ctx.log);
|
|
121
116
|
} catch {}
|
|
122
117
|
ctx.log?.('MiniMax token 已过期,正在自动刷新…');
|
|
123
118
|
try {
|
|
124
|
-
const refreshed = await refreshMiniMaxToken(
|
|
119
|
+
const refreshed = await refreshMiniMaxToken(a.refreshToken);
|
|
125
120
|
const expiresAt = new Date(Date.now() + refreshed.expiresIn * 1000).toISOString();
|
|
126
121
|
const creds = {
|
|
127
122
|
token: refreshed.accessToken,
|
|
128
123
|
refreshToken: refreshed.refreshToken,
|
|
129
124
|
expiresAt,
|
|
130
125
|
};
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
126
|
+
a.token = creds.token;
|
|
127
|
+
a.refreshToken = creds.refreshToken;
|
|
128
|
+
a.expiresAt = expiresAt;
|
|
134
129
|
await ctx.onRefresh?.(creds);
|
|
135
130
|
return creds;
|
|
136
131
|
} catch (refErr) {
|
|
137
132
|
ctx.log?.(`MiniMax token 自动刷新失败: ${refErr.message}`);
|
|
138
133
|
throw refErr;
|
|
139
134
|
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
135
|
+
}, {
|
|
136
|
+
deadMessage: 'MiniMax 凭据链已被服务端作废,等待重新登录',
|
|
137
|
+
deadNotice: 'MiniMax 凭据链已失效,将不再自动重试(重新登录或重新导入该账号后自动恢复)',
|
|
138
|
+
log: ctx.log,
|
|
139
|
+
});
|
|
143
140
|
}
|
|
144
141
|
|
|
145
142
|
/**
|
package/src/minimaxGateway.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* (上游为标准 Anthropic Messages 协议:https://agent.minimax.cn/mavis/api/v1/llm/v1/messages)
|
|
6
6
|
*
|
|
7
7
|
* 架构特性:
|
|
8
|
-
* 1.
|
|
8
|
+
* 1. 多账号粘性主力(provider=minimax):按剩余积分选主力并保持,出错/限流/凭据失效自动切下一个
|
|
9
9
|
* 2. 自动刷新与重试:401 凭据失效时自动走 OAuth refresh 换取新 token 并重试,彻底失效才拉黑
|
|
10
10
|
* 3. 429 限流保护:429 限流自动冷却 5 分钟
|
|
11
11
|
* 4. 局域网访问控制:继承相同的白名单与回环检查机制
|
|
@@ -17,7 +17,8 @@ import { loadAccounts, loadSettings, saveSettings } from './store.js';
|
|
|
17
17
|
import { refreshMiniMaxToken, fetchMiniMaxQuota, MINIMAX_MESSAGES_URL } from './minimaxClient.js';
|
|
18
18
|
import { refreshContext } from './accounts.js';
|
|
19
19
|
import { gatewayHostSuggestion } from './tenrouter.js';
|
|
20
|
-
import { logger, summarizeAttempts } from './logger.js';
|
|
20
|
+
import { logger, summarizeAttempts } from './logger.js';
|
|
21
|
+
import { t } from './i18n.js';
|
|
21
22
|
|
|
22
23
|
const ACCOUNT_COOLING_MS = 5 * 60_000;
|
|
23
24
|
const MAX_ATTEMPTS = 5;
|
|
@@ -45,7 +46,8 @@ export async function setGatewayEnabled(v) {
|
|
|
45
46
|
|
|
46
47
|
const cooling = new Map(); // accountId → 冷却截止(ms)
|
|
47
48
|
const dead = new Map(); // accountId → 拉黑时的凭据指纹(指纹变化 = 已重新授权/刷新,自动复活)
|
|
48
|
-
const weights = new Map(); // accountId →
|
|
49
|
+
const weights = new Map(); // accountId → { w: 剩余积分权重, at: 查询时刻 }(查询失败时保留旧快照)
|
|
50
|
+
const WEIGHT_TTL_MS = 10 * 60_000; // 权重超过该时长重新查询额度,让用量变化能反映到分流上
|
|
49
51
|
|
|
50
52
|
export const stats = {
|
|
51
53
|
lastCallAt: null,
|
|
@@ -80,43 +82,50 @@ function isDead(account) {
|
|
|
80
82
|
if (fp === credFingerprint(account)) return true;
|
|
81
83
|
dead.delete(account.id);
|
|
82
84
|
cooling.delete(account.id);
|
|
83
|
-
logger.info('MINIMAX-GW',
|
|
85
|
+
logger.info('MINIMAX-GW', '{label} 凭据已更新,解除拉黑', { label: account.name || account.uid || account.id });
|
|
84
86
|
return false;
|
|
85
87
|
}
|
|
86
88
|
|
|
89
|
+
/** 测试专用:仅让权重快照过期(保留粘性/拉黑状态),用于模拟额度变化 */
|
|
90
|
+
export function __expireWeightsForTests() { weights.clear(); }
|
|
91
|
+
|
|
87
92
|
/** 测试专用:清空进程内状态 */
|
|
88
93
|
export function __resetForTests() {
|
|
89
94
|
cooling.clear();
|
|
90
95
|
dead.clear();
|
|
91
96
|
weights.clear();
|
|
92
|
-
|
|
97
|
+
stickyId = null;
|
|
93
98
|
stats.lastCallAt = null;
|
|
94
99
|
stats.calls = 0;
|
|
95
100
|
stats.lastAccount = null;
|
|
96
101
|
}
|
|
97
102
|
|
|
98
|
-
/**
|
|
103
|
+
/** 账号权重:剩余积分(带 TTL 的快照,避免每个请求都打额度接口)。查询失败时保留上次快照权重。 */
|
|
99
104
|
async function accountWeight(a) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
const hit = weights.get(a.id);
|
|
106
|
+
if (hit && Date.now() - hit.at < WEIGHT_TTL_MS) return hit.w;
|
|
107
|
+
let w = hit?.w ?? MIN_WEIGHT;
|
|
108
|
+
try {
|
|
109
|
+
const q = await fetchMiniMaxQuota(a, refreshContext(a, (m) => logger.info("MINIMAX-GW", t('{label} {msg}', { label: a.name || a.uid || a.id, msg: m }))));
|
|
110
|
+
w = Math.max(MIN_WEIGHT, Number(q?.remaining) || 0);
|
|
111
|
+
} catch {
|
|
112
|
+
// 额度查询失败时保留上次快照权重:一次网络抖动不该把主力账号踢成保底权重、引发主力来回切换
|
|
108
113
|
}
|
|
109
|
-
|
|
114
|
+
weights.set(a.id, { w, at: Date.now() });
|
|
115
|
+
return w;
|
|
110
116
|
}
|
|
111
117
|
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
118
|
+
// ── 粘性主力选型 ──
|
|
119
|
+
// 上次的主力账号仍健康时继续用它:两个账号积分接近时若逐请求轮换,会在两账号间
|
|
120
|
+
// 交替刷屏(「一主一备」困扰的来源,用户期望「出错/积分拉开差距才换」);
|
|
121
|
+
// 仅当另一账号剩余积分明显更多,或主力被拉黑/冷却,才主动换主力。
|
|
122
|
+
let stickyId = null;
|
|
123
|
+
const STICKY_MARGIN_RATIO = 0.1; // 积分领先超过 10% 才换
|
|
124
|
+
const STICKY_MARGIN_ABS = 50; // 且绝对差超过 50 积分
|
|
115
125
|
|
|
116
126
|
/**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* 所以这里直接把权重最高的放最前,其余按有效权重降序兜底。
|
|
127
|
+
* 可用账号队列:队首 = 本次主力账号,其余按剩余积分降序作重试兜底。
|
|
128
|
+
* 拉黑/冷却在挂载时剔除;扭转主力粘性见上方说明。
|
|
120
129
|
*/
|
|
121
130
|
async function rotationQueue() {
|
|
122
131
|
const all = (await loadAccounts()).filter((a) => a.provider === 'minimax');
|
|
@@ -126,24 +135,23 @@ async function rotationQueue() {
|
|
|
126
135
|
if (isDead(a)) continue;
|
|
127
136
|
if ((cooling.get(a.id) || 0) > now) continue;
|
|
128
137
|
if (!a.token) continue;
|
|
129
|
-
|
|
130
|
-
const w = await accountWeight(a);
|
|
131
|
-
swrr.set(a.id, (swrr.get(a.id) || 0) + w);
|
|
132
|
-
ready.push({ account: a, weight: w });
|
|
138
|
+
ready.push({ account: a, weight: await accountWeight(a) });
|
|
133
139
|
}
|
|
134
140
|
if (!ready.length) return [];
|
|
135
141
|
|
|
136
|
-
|
|
137
|
-
let
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
.
|
|
145
|
-
.
|
|
146
|
-
|
|
142
|
+
ready.sort((x, y) => y.weight - x.weight);
|
|
143
|
+
let head = ready[0];
|
|
144
|
+
const sticky = stickyId ? ready.find((it) => it.account.id === stickyId) : null;
|
|
145
|
+
if (sticky && sticky.account.id !== head.account.id) {
|
|
146
|
+
const margin = Math.max(STICKY_MARGIN_ABS, head.weight * STICKY_MARGIN_RATIO);
|
|
147
|
+
if (head.weight - sticky.weight < margin) head = sticky;
|
|
148
|
+
}
|
|
149
|
+
if (stickyId && stickyId !== head.account.id) {
|
|
150
|
+
const from = all.find((a) => a.id === stickyId);
|
|
151
|
+
logger.info('MINIMAX-GW', '主力账号 {from} → {to}(剩余积分领先)', { from: from?.name || from?.uid || stickyId, to: head.account.name || head.account.uid || head.account.id });
|
|
152
|
+
}
|
|
153
|
+
stickyId = head.account.id;
|
|
154
|
+
return [head.account, ...ready.filter((it) => it.account.id !== head.account.id).map((it) => it.account)];
|
|
147
155
|
}
|
|
148
156
|
|
|
149
157
|
async function unavailableReason() {
|
|
@@ -178,7 +186,13 @@ function remoteAllowed(remote, allowList) {
|
|
|
178
186
|
return (Array.isArray(allowList) ? allowList : []).some((entry) => {
|
|
179
187
|
const e = String(entry || '').trim();
|
|
180
188
|
if (!e) return false;
|
|
181
|
-
if (e.endsWith('*'))
|
|
189
|
+
if (e.endsWith('*')) {
|
|
190
|
+
// 通配要求带点段尾:防止 192.168.31.1* 这类把 .100-.199 整段一起放行的怪手。
|
|
191
|
+
// 要扩整段子网,把写法统一为 192.168.31.*(带尾句点)。
|
|
192
|
+
const prefix = e.slice(0, -1);
|
|
193
|
+
if (!prefix.endsWith('.')) return false;
|
|
194
|
+
return host.toLowerCase().startsWith(prefix.toLowerCase());
|
|
195
|
+
}
|
|
182
196
|
return host.toLowerCase() === e.toLowerCase();
|
|
183
197
|
});
|
|
184
198
|
}
|
|
@@ -284,12 +298,12 @@ export async function handleGateway(req, res) {
|
|
|
284
298
|
upstream = await sendUpstream(activeToken);
|
|
285
299
|
// 如果 401 且账号有 refreshToken,尝试就地刷新一次并重试
|
|
286
300
|
if (upstream.status === 401 && account.refreshToken) {
|
|
287
|
-
logger.info('MINIMAX-GW',
|
|
301
|
+
logger.info('MINIMAX-GW', '{label} 收到 401,尝试刷新凭据...', { label });
|
|
288
302
|
try {
|
|
289
303
|
// 本机导入账号:刷新前对齐 ~/.minimax auth.json 里客户端最新的 refreshToken,避免 invalid_grant
|
|
290
304
|
try {
|
|
291
305
|
const { alignMiniMaxFromLocal } = await import('./minimaxLocal.js');
|
|
292
|
-
if (alignMiniMaxFromLocal(account, (m) => logger.info('MINIMAX-GW',
|
|
306
|
+
if (alignMiniMaxFromLocal(account, (m) => logger.info('MINIMAX-GW', '{label} {msg}', { label, msg: m }))) {
|
|
293
307
|
activeToken = account.token;
|
|
294
308
|
}
|
|
295
309
|
} catch {}
|
|
@@ -313,7 +327,7 @@ export async function handleGateway(req, res) {
|
|
|
313
327
|
cooling.delete(account.id);
|
|
314
328
|
upstream = await sendUpstream(activeToken);
|
|
315
329
|
} catch (refErr) {
|
|
316
|
-
logger.warn('MINIMAX-GW',
|
|
330
|
+
logger.warn('MINIMAX-GW', '{label} 凭据刷新失败: {err}', { label, err: refErr.message });
|
|
317
331
|
}
|
|
318
332
|
}
|
|
319
333
|
} catch (e) {
|
|
@@ -324,7 +338,11 @@ export async function handleGateway(req, res) {
|
|
|
324
338
|
|
|
325
339
|
if (upstream.ok) {
|
|
326
340
|
stats.lastAccount = label;
|
|
327
|
-
logger.info('MINIMAX-GW',
|
|
341
|
+
logger.info('MINIMAX-GW', '{label} 补全成功 ({status})', { label, status: upstream.status });
|
|
342
|
+
try {
|
|
343
|
+
const meta = JSON.parse(rawBody);
|
|
344
|
+
logger.debug('MINIMAX-GW', '请求详情:model={model} stream={stream} ua={ua}', { model: meta?.model || '-', stream: meta?.stream ? 'y' : 'n', ua: req.headers?.['user-agent'] || '-' });
|
|
345
|
+
} catch {}
|
|
328
346
|
|
|
329
347
|
// 透传头(SSE / JSON)
|
|
330
348
|
const forwardHeaders = {
|
|
@@ -347,7 +365,7 @@ export async function handleGateway(req, res) {
|
|
|
347
365
|
}
|
|
348
366
|
} catch (e) {
|
|
349
367
|
// 客户端主动断开(点停止/换一句)属正常收尾,不是故障
|
|
350
|
-
logger.debug('MINIMAX-GW',
|
|
368
|
+
logger.debug('MINIMAX-GW', '流式转发中断:{err}', { err: e.message });
|
|
351
369
|
}
|
|
352
370
|
res.end();
|
|
353
371
|
} else {
|
|
@@ -362,13 +380,13 @@ export async function handleGateway(req, res) {
|
|
|
362
380
|
if (upstream.status === 401) {
|
|
363
381
|
markDead(account);
|
|
364
382
|
attempted.push({ account: label, ok: false, error: 'Token 彻底失效 (401),拉黑' });
|
|
365
|
-
logger.info('MINIMAX-GW',
|
|
383
|
+
logger.info('MINIMAX-GW', '{label} 凭据失效,拉黑', { label });
|
|
366
384
|
continue;
|
|
367
385
|
}
|
|
368
386
|
if (upstream.status === 429) {
|
|
369
387
|
markCooling(account.id, ACCOUNT_COOLING_MS);
|
|
370
388
|
attempted.push({ account: label, ok: false, error: '429 限流,冷却 5 分钟' });
|
|
371
|
-
logger.info('MINIMAX-GW',
|
|
389
|
+
logger.info('MINIMAX-GW', '{label} 429 限流,冷却 5 分钟', { label });
|
|
372
390
|
continue;
|
|
373
391
|
}
|
|
374
392
|
|
|
@@ -377,7 +395,7 @@ export async function handleGateway(req, res) {
|
|
|
377
395
|
|
|
378
396
|
// 全部重试失败
|
|
379
397
|
const failedDetail = summarizeAttempts(attempted);
|
|
380
|
-
logger.warn('MINIMAX-GW',
|
|
398
|
+
logger.warn('MINIMAX-GW', '全部 {n} 次尝试失败{detail}(下一请求自动重试)', { n: attempted.length, detail: failedDetail ? ':' + failedDetail : '' });
|
|
381
399
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
382
400
|
res.end(JSON.stringify({
|
|
383
401
|
error: '所有 MiniMax 账号均调用失败',
|