creditdaddy 1.2.0 → 1.3.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/LICENSE +21 -21
- package/README.md +40 -10
- package/bin/creditdaddy.js +131 -2
- package/package.json +29 -29
- package/src/accounts.js +18 -7
- package/src/authDevice.js +3 -0
- package/src/bgdaemon.js +332 -0
- package/src/catpawClient.js +103 -103
- package/src/catpawLocal.js +229 -229
- package/src/checkin.js +10 -4
- package/src/constants.js +4 -2
- package/src/daemon.js +137 -13
- package/src/localDetect.js +147 -147
- package/src/logger.js +104 -6
- package/src/minimaxAuth.js +127 -0
- package/src/minimaxClient.js +339 -0
- package/src/minimaxGateway.js +410 -0
- package/src/minimaxLocal.js +259 -0
- package/src/mirasimClient.js +189 -189
- package/src/panel.html +459 -36
- package/src/providers.js +26 -0
- package/src/qoderApp.js +3 -9
- package/src/qoderClient.js +237 -237
- package/src/qoderUmid.js +198 -198
- package/src/store.js +65 -7
- package/src/tenrouter.js +8 -0
- package/src/tenrouterAccounts.js +38 -3
- package/src/traeClient.js +6 -1
- package/src/traeGateway.js +679 -0
- package/src/transfer.js +24 -1
- package/src/usageSync.js +505 -505
- package/src/winProc.js +36 -0
- package/src/workbuddyLocal.js +133 -133
- package/src/zcodeAuth.js +131 -131
- package/src/zcodeAutoClaim.js +97 -97
- package/src/zcodeClient.js +93 -18
- package/src/zcodeGateway.js +104 -46
- package/src/zcodePlanShape.json +3 -3
- package/src/zcrypto.js +160 -160
|
@@ -0,0 +1,410 @@
|
|
|
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 限流自动冷却 5 分钟
|
|
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, fetchMiniMaxQuota, MINIMAX_MESSAGES_URL } from './minimaxClient.js';
|
|
18
|
+
import { refreshContext } from './accounts.js';
|
|
19
|
+
import { gatewayHostSuggestion } from './tenrouter.js';
|
|
20
|
+
import { logger, summarizeAttempts } from './logger.js';
|
|
21
|
+
|
|
22
|
+
const ACCOUNT_COOLING_MS = 5 * 60_000;
|
|
23
|
+
const MAX_ATTEMPTS = 5;
|
|
24
|
+
const UPSTREAM_TIMEOUT_MS = 600_000;
|
|
25
|
+
const UPSTREAM_CONNECT_MS = 15_000;
|
|
26
|
+
|
|
27
|
+
// ── 开关(持久化在 settings.minimaxGateway) ──
|
|
28
|
+
|
|
29
|
+
export async function gatewayEnabled() {
|
|
30
|
+
return (await loadSettings()).minimaxGateway === true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function setGatewayEnabled(v) {
|
|
34
|
+
const on = v === true;
|
|
35
|
+
await saveSettings({ minimaxGateway: on });
|
|
36
|
+
// 开启网关视为「重新开始」:清掉进程内的拉黑/冷却状态,
|
|
37
|
+
// 否则重新授权后旧黑名单仍会把账号挡在轮转队列外(曾导致开关无效)。
|
|
38
|
+
if (on) {
|
|
39
|
+
dead.clear();
|
|
40
|
+
cooling.clear();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── 账号轮换状态(进程内) ──
|
|
45
|
+
|
|
46
|
+
const cooling = new Map(); // accountId → 冷却截止(ms)
|
|
47
|
+
const dead = new Map(); // accountId → 拉黑时的凭据指纹(指纹变化 = 已重新授权/刷新,自动复活)
|
|
48
|
+
const weights = new Map(); // accountId → 上次查到的剩余积分(0 时按保底权重轮询,保持可用性)
|
|
49
|
+
|
|
50
|
+
export const stats = {
|
|
51
|
+
lastCallAt: null,
|
|
52
|
+
calls: 0,
|
|
53
|
+
lastAccount: null,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// 平滑加权轮询(SWRR):剩余积分多的账号分到更多请求,而不是雨露均沾地 round-robin。
|
|
57
|
+
// 查不到额度或为 0 时给保底权重,保证账号仍会被轮询(0 也可能只是查询失败/尚未刷新)。
|
|
58
|
+
const MIN_WEIGHT = 1;
|
|
59
|
+
|
|
60
|
+
function credFingerprint(a) {
|
|
61
|
+
return `${a.token || ''}|${a.refreshToken || ''}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function markCooling(id, ms = ACCOUNT_COOLING_MS) {
|
|
65
|
+
cooling.set(id, Date.now() + ms);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function markDead(account) {
|
|
69
|
+
dead.set(account.id, credFingerprint(account));
|
|
70
|
+
cooling.set(account.id, Number.MAX_SAFE_INTEGER);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 账号是否仍处于拉黑状态。拉黑记的是「当时那份凭据」的指纹:
|
|
75
|
+
* 重新登录/重新导入/刷新回写换了 token 后指纹不匹配 → 自动复活(清黑名单与冷却)。
|
|
76
|
+
*/
|
|
77
|
+
function isDead(account) {
|
|
78
|
+
const fp = dead.get(account.id);
|
|
79
|
+
if (fp === undefined) return false;
|
|
80
|
+
if (fp === credFingerprint(account)) return true;
|
|
81
|
+
dead.delete(account.id);
|
|
82
|
+
cooling.delete(account.id);
|
|
83
|
+
logger.info('MINIMAX-GW', `${account.name || account.uid || account.id} 凭据已更新,解除拉黑`);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 测试专用:清空进程内状态 */
|
|
88
|
+
export function __resetForTests() {
|
|
89
|
+
cooling.clear();
|
|
90
|
+
dead.clear();
|
|
91
|
+
weights.clear();
|
|
92
|
+
swrr.clear();
|
|
93
|
+
stats.lastCallAt = null;
|
|
94
|
+
stats.calls = 0;
|
|
95
|
+
stats.lastAccount = null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 账号权重:剩余积分(上次快照,避免每个请求都打额度接口)。查不到用保底权重。 */
|
|
99
|
+
async function accountWeight(a) {
|
|
100
|
+
if (!weights.has(a.id)) {
|
|
101
|
+
try {
|
|
102
|
+
const q = await fetchMiniMaxQuota(a, refreshContext(a, (m) => logger.info('MINIMAX-GW', `${a.name || a.uid || a.id} ${m}`)));
|
|
103
|
+
const remaining = Number(q?.remaining) || 0;
|
|
104
|
+
weights.set(a.id, Math.max(MIN_WEIGHT, remaining));
|
|
105
|
+
} catch {
|
|
106
|
+
weights.set(a.id, MIN_WEIGHT);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return weights.get(a.id);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// SWRR 状态:accountId → 当前有效权重(每次调度累加,被选中的减去总权重)
|
|
113
|
+
const swrr = new Map();
|
|
114
|
+
function ensureSwrr(id) { if (!swrr.has(id)) swrr.set(id, 0); }
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 可参与轮转的 MiniMax 账号队列,按剩余积分加权排序(SWRR)。
|
|
118
|
+
* 返回 [账号, ...]:首个为本次该分到的账号;MAX_ATTEMPTS 只取队列头部用于重试,
|
|
119
|
+
* 所以这里直接把权重最高的放最前,其余按有效权重降序兜底。
|
|
120
|
+
*/
|
|
121
|
+
async function rotationQueue() {
|
|
122
|
+
const all = (await loadAccounts()).filter((a) => a.provider === 'minimax');
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
const ready = [];
|
|
125
|
+
for (const a of all) {
|
|
126
|
+
if (isDead(a)) continue;
|
|
127
|
+
if ((cooling.get(a.id) || 0) > now) continue;
|
|
128
|
+
if (!a.token) continue;
|
|
129
|
+
ensureSwrr(a.id);
|
|
130
|
+
const w = await accountWeight(a);
|
|
131
|
+
swrr.set(a.id, (swrr.get(a.id) || 0) + w);
|
|
132
|
+
ready.push({ account: a, weight: w });
|
|
133
|
+
}
|
|
134
|
+
if (!ready.length) return [];
|
|
135
|
+
|
|
136
|
+
// 有效权重最大者中签(SWRR 的「选出 current 最大」步)
|
|
137
|
+
let pick = ready[0];
|
|
138
|
+
for (const it of ready) if ((swrr.get(it.account.id) || 0) > (swrr.get(pick.account.id) || 0)) pick = it;
|
|
139
|
+
const total = ready.reduce((s, it) => s + it.weight, 0);
|
|
140
|
+
swrr.set(pick.account.id, (swrr.get(pick.account.id) || 0) - total);
|
|
141
|
+
|
|
142
|
+
// 队首 = 本次中签账号;其余按剩余积分降序(重试兜底顺序)
|
|
143
|
+
const rest = ready.filter((it) => it.account.id !== pick.account.id)
|
|
144
|
+
.sort((x, y) => y.weight - x.weight)
|
|
145
|
+
.map((it) => it.account);
|
|
146
|
+
return [pick.account, ...rest];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function unavailableReason() {
|
|
150
|
+
const all = (await loadAccounts()).filter((a) => a.provider === 'minimax');
|
|
151
|
+
if (!all.length) return '没有 MiniMax 账号:请在 MiniMax Code 登录后在面板进行本机导入,或粘贴 Token 添加';
|
|
152
|
+
const now = Date.now();
|
|
153
|
+
const n = { dead: 0, cooling: 0, noToken: 0 };
|
|
154
|
+
for (const a of all) {
|
|
155
|
+
if (isDead(a)) n.dead++;
|
|
156
|
+
else if ((cooling.get(a.id) || 0) > now) n.cooling++;
|
|
157
|
+
else if (!a.token) n.noToken++;
|
|
158
|
+
}
|
|
159
|
+
const parts = [];
|
|
160
|
+
if (n.cooling) parts.push(`${n.cooling} 个临时冷却中(稍后自动恢复)`);
|
|
161
|
+
if (n.dead) parts.push(`${n.dead} 个凭据已失效(需在 MiniMax Code 重新登录后导入)`);
|
|
162
|
+
if (n.noToken) parts.push(`${n.noToken} 个缺少 Token`);
|
|
163
|
+
return `没有可用的 MiniMax 账号:${parts.join(';')}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const LOOPBACK_RE = /^(::1|::ffff:127\.0\.0\.1|127\.0\.0\.1)$/;
|
|
167
|
+
function isLoopbackRemote(remote) {
|
|
168
|
+
return !remote || LOOPBACK_RE.test(String(remote));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function remoteHost(remote) {
|
|
172
|
+
return String(remote || '').replace(/^::ffff:/i, '').trim();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function remoteAllowed(remote, allowList) {
|
|
176
|
+
const host = remoteHost(remote);
|
|
177
|
+
if (!host) return false;
|
|
178
|
+
return (Array.isArray(allowList) ? allowList : []).some((entry) => {
|
|
179
|
+
const e = String(entry || '').trim();
|
|
180
|
+
if (!e) return false;
|
|
181
|
+
if (e.endsWith('*')) return host.toLowerCase().startsWith(e.slice(0, -1).toLowerCase());
|
|
182
|
+
return host.toLowerCase() === e.toLowerCase();
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function readRawBody(req) {
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
const chunks = [];
|
|
189
|
+
let size = 0;
|
|
190
|
+
const limit = 20 * 1024 * 1024; // 20MB
|
|
191
|
+
req.on('data', (chunk) => {
|
|
192
|
+
size += chunk.length;
|
|
193
|
+
if (size > limit) {
|
|
194
|
+
// 只 pause 不 destroy:destroy 会把 socket 打死,随后写的 413 客户端收不到(只看到 ECONNRESET)
|
|
195
|
+
req.pause();
|
|
196
|
+
const err = new Error('请求体过大(上限 20MB)');
|
|
197
|
+
err.status = 413;
|
|
198
|
+
reject(err);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
chunks.push(chunk);
|
|
202
|
+
});
|
|
203
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
204
|
+
req.on('error', reject);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 处理 MiniMax 本地网关请求
|
|
210
|
+
*/
|
|
211
|
+
export async function handleGateway(req, res) {
|
|
212
|
+
if (req.method !== 'POST') {
|
|
213
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
214
|
+
return res.end(JSON.stringify({ error: 'POST /gateway/minimax/v1/messages(Anthropic /v1/messages 形态)' }));
|
|
215
|
+
}
|
|
216
|
+
const settings = await loadSettings();
|
|
217
|
+
if (settings.minimaxGateway !== true) {
|
|
218
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
219
|
+
return res.end(JSON.stringify({ error: 'MiniMax 本地网关未开启(面板 → MiniMax → 接口设置)' }));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 局域网访问控制
|
|
223
|
+
const remote = (req.socket && req.socket.remoteAddress) || '';
|
|
224
|
+
if (!isLoopbackRemote(remote)) {
|
|
225
|
+
if (settings.minimaxGatewayLan !== true) {
|
|
226
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
227
|
+
return res.end(JSON.stringify({ error: 'MiniMax 网关未允许局域网访问(面板 → MiniMax → 接口设置)' }));
|
|
228
|
+
}
|
|
229
|
+
if (!remoteAllowed(remote, settings.minimaxGatewayAllow)) {
|
|
230
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
231
|
+
return res.end(JSON.stringify({ error: `来源 ${remoteHost(remote) || '未知'} 不在 MiniMax 网关 IP 白名单内` }));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let rawBody;
|
|
236
|
+
try {
|
|
237
|
+
rawBody = await readRawBody(req);
|
|
238
|
+
} catch (e) {
|
|
239
|
+
res.writeHead(e.status || 400, { 'Content-Type': 'application/json' });
|
|
240
|
+
// 超限时请求体还没读完:先把 413 发出去,发完再断开
|
|
241
|
+
res.on('finish', () => req.destroy());
|
|
242
|
+
return res.end(JSON.stringify({ error: e.message }));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const queue = await rotationQueue();
|
|
246
|
+
if (!queue.length) {
|
|
247
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
248
|
+
return res.end(JSON.stringify({ error: await unavailableReason() }));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
stats.lastCallAt = new Date().toISOString();
|
|
252
|
+
stats.calls += 1;
|
|
253
|
+
|
|
254
|
+
const attempted = [];
|
|
255
|
+
const clientAbort = new AbortController();
|
|
256
|
+
req.once('close', () => { if (!res.writableEnded) clientAbort.abort(); });
|
|
257
|
+
|
|
258
|
+
for (let i = 0; i < Math.min(MAX_ATTEMPTS, queue.length); i++) {
|
|
259
|
+
const account = queue[i];
|
|
260
|
+
if (isDead(account) || (cooling.get(account.id) || 0) > Date.now()) continue;
|
|
261
|
+
const label = account.name || account.uid || account.id;
|
|
262
|
+
|
|
263
|
+
let upstream;
|
|
264
|
+
|
|
265
|
+
let activeToken = account.token;
|
|
266
|
+
const sendUpstream = async (tok) => {
|
|
267
|
+
const headers = {
|
|
268
|
+
'Content-Type': 'application/json',
|
|
269
|
+
Accept: 'application/json, text/event-stream',
|
|
270
|
+
Authorization: tok.startsWith('Bearer ') ? tok : `Bearer ${tok}`,
|
|
271
|
+
'anthropic-version': '2023-06-01',
|
|
272
|
+
};
|
|
273
|
+
return fetchJsonRace(MINIMAX_MESSAGES_URL, {
|
|
274
|
+
method: 'POST',
|
|
275
|
+
headers,
|
|
276
|
+
body: rawBody,
|
|
277
|
+
timeoutMs: UPSTREAM_TIMEOUT_MS,
|
|
278
|
+
connectMs: UPSTREAM_CONNECT_MS,
|
|
279
|
+
signal: clientAbort.signal,
|
|
280
|
+
});
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
upstream = await sendUpstream(activeToken);
|
|
285
|
+
// 如果 401 且账号有 refreshToken,尝试就地刷新一次并重试
|
|
286
|
+
if (upstream.status === 401 && account.refreshToken) {
|
|
287
|
+
logger.info('MINIMAX-GW', `${label} 收到 401,尝试刷新凭据…`);
|
|
288
|
+
try {
|
|
289
|
+
// 本机导入账号:刷新前对齐 ~/.minimax auth.json 里客户端最新的 refreshToken,避免 invalid_grant
|
|
290
|
+
try {
|
|
291
|
+
const { alignMiniMaxFromLocal } = await import('./minimaxLocal.js');
|
|
292
|
+
if (alignMiniMaxFromLocal(account, (m) => logger.info('MINIMAX-GW', `${label} ${m}`))) {
|
|
293
|
+
activeToken = account.token;
|
|
294
|
+
}
|
|
295
|
+
} catch {}
|
|
296
|
+
const refreshed = await refreshMiniMaxToken(account.refreshToken);
|
|
297
|
+
const expiresAt = new Date(Date.now() + refreshed.expiresIn * 1000).toISOString();
|
|
298
|
+
account.token = refreshed.accessToken;
|
|
299
|
+
account.refreshToken = refreshed.refreshToken;
|
|
300
|
+
account.expiresAt = expiresAt;
|
|
301
|
+
const { withAccounts } = await import('./store.js');
|
|
302
|
+
await withAccounts((list) => {
|
|
303
|
+
const cur = list.find((a) => a.id === account.id);
|
|
304
|
+
if (cur) {
|
|
305
|
+
cur.token = refreshed.accessToken;
|
|
306
|
+
cur.refreshToken = refreshed.refreshToken;
|
|
307
|
+
cur.expiresAt = expiresAt;
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
activeToken = refreshed.accessToken;
|
|
311
|
+
// 刷新回写即凭据更新:解除该账号可能存在的拉黑/冷却(指纹已变化,防御性直清)
|
|
312
|
+
dead.delete(account.id);
|
|
313
|
+
cooling.delete(account.id);
|
|
314
|
+
upstream = await sendUpstream(activeToken);
|
|
315
|
+
} catch (refErr) {
|
|
316
|
+
logger.warn('MINIMAX-GW', `${label} 凭据刷新失败: ${refErr.message}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
} catch (e) {
|
|
320
|
+
if (clientAbort.signal.aborted) return;
|
|
321
|
+
attempted.push({ account: label, ok: false, error: e.message });
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (upstream.ok) {
|
|
326
|
+
stats.lastAccount = label;
|
|
327
|
+
logger.info('MINIMAX-GW', `${label} 补全成功 (${upstream.status})`);
|
|
328
|
+
|
|
329
|
+
// 透传头(SSE / JSON)
|
|
330
|
+
const forwardHeaders = {
|
|
331
|
+
'Content-Type': upstream.headers.get('content-type') || 'application/json',
|
|
332
|
+
'Cache-Control': 'no-cache',
|
|
333
|
+
};
|
|
334
|
+
res.writeHead(upstream.status, forwardHeaders);
|
|
335
|
+
|
|
336
|
+
if (upstream.body && typeof upstream.body.pipe === 'function') {
|
|
337
|
+
upstream.body.pipe(res);
|
|
338
|
+
} else if (upstream.body && typeof upstream.body.getReader === 'function') {
|
|
339
|
+
const reader = upstream.body.getReader();
|
|
340
|
+
try {
|
|
341
|
+
while (true) {
|
|
342
|
+
const { done, value } = await reader.read();
|
|
343
|
+
if (done) break;
|
|
344
|
+
if (!res.write(Buffer.from(value))) {
|
|
345
|
+
await new Promise((r) => res.once('drain', r));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
} catch (e) {
|
|
349
|
+
// 客户端主动断开(点停止/换一句)属正常收尾,不是故障
|
|
350
|
+
logger.debug('MINIMAX-GW', `流式转发中断:${e.message}`);
|
|
351
|
+
}
|
|
352
|
+
res.end();
|
|
353
|
+
} else {
|
|
354
|
+
const buf = await upstream.arrayBuffer().catch(() => null);
|
|
355
|
+
res.end(buf ? Buffer.from(buf) : '');
|
|
356
|
+
}
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// 失败处理
|
|
361
|
+
const errText = await upstream.text().catch(() => '');
|
|
362
|
+
if (upstream.status === 401) {
|
|
363
|
+
markDead(account);
|
|
364
|
+
attempted.push({ account: label, ok: false, error: 'Token 彻底失效 (401),拉黑' });
|
|
365
|
+
logger.info('MINIMAX-GW', `${label} 凭据失效,拉黑`);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (upstream.status === 429) {
|
|
369
|
+
markCooling(account.id, ACCOUNT_COOLING_MS);
|
|
370
|
+
attempted.push({ account: label, ok: false, error: '429 限流,冷却 5 分钟' });
|
|
371
|
+
logger.info('MINIMAX-GW', `${label} 429 限流,冷却 5 分钟`);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
attempted.push({ account: label, ok: false, error: `上游错误 HTTP ${upstream.status}: ${errText.slice(0, 100)}` });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// 全部重试失败
|
|
379
|
+
const failedDetail = summarizeAttempts(attempted);
|
|
380
|
+
logger.warn('MINIMAX-GW', `全部 ${attempted.length} 次尝试失败${failedDetail ? ':' + failedDetail : ''}(下一请求自动重试)`);
|
|
381
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
382
|
+
res.end(JSON.stringify({
|
|
383
|
+
error: '所有 MiniMax 账号均调用失败',
|
|
384
|
+
attempts: attempted,
|
|
385
|
+
}));
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export async function gatewayStatus() {
|
|
390
|
+
const enabled = await gatewayEnabled();
|
|
391
|
+
const all = (await loadAccounts()).filter((a) => a.provider === 'minimax');
|
|
392
|
+
const now = Date.now();
|
|
393
|
+
const settings = await loadSettings();
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
enabled,
|
|
397
|
+
lan: settings.minimaxGatewayLan === true,
|
|
398
|
+
allow: Array.isArray(settings.minimaxGatewayAllow) ? settings.minimaxGatewayAllow : [],
|
|
399
|
+
suggestAllow: gatewayHostSuggestion(),
|
|
400
|
+
stats: {
|
|
401
|
+
lastCallAt: stats.lastCallAt,
|
|
402
|
+
calls: stats.calls,
|
|
403
|
+
lastAccount: stats.lastAccount,
|
|
404
|
+
},
|
|
405
|
+
accounts: all.length,
|
|
406
|
+
cooling: all.filter((a) => !isDead(a) && (cooling.get(a.id) || 0) > now).map((a) => a.name || a.uid || a.id),
|
|
407
|
+
dead: all.filter((a) => isDead(a)).map((a) => a.name || a.uid || a.id),
|
|
408
|
+
endpoint: '/gateway/minimax/v1/messages',
|
|
409
|
+
};
|
|
410
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MiniMax Code 本机客户端探测与凭据自动读取。
|
|
3
|
+
*
|
|
4
|
+
* MiniMax Code 客户端文件路径:
|
|
5
|
+
* - 配置与模型: ~/.minimax/config.yaml
|
|
6
|
+
* - 凭据存储: ~/.minimax/auth/prod/cn/mcode-public/auth.json
|
|
7
|
+
* - 凭据状态: ~/.minimax/auth/prod/cn/mcode-public/auth-state.json
|
|
8
|
+
* - 可执行程序(Windows): %LOCALAPPDATA%\Programs\MiniMax Code\MiniMax Code.exe
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { execSync } from 'node:child_process';
|
|
15
|
+
import { fetchMiniMaxProfile } from './minimaxClient.js';
|
|
16
|
+
|
|
17
|
+
export function minimaxHome() {
|
|
18
|
+
return process.env.MINIMAX_HOME || path.join(os.homedir(), '.minimax');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function minimaxAuthPath() {
|
|
22
|
+
return path.join(minimaxHome(), 'auth', 'prod', 'cn', 'mcode-public', 'auth.json');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function minimaxAuthStatePath() {
|
|
26
|
+
return path.join(minimaxHome(), 'auth', 'prod', 'cn', 'mcode-public', 'auth-state.json');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function minimaxConfigPath() {
|
|
30
|
+
return path.join(minimaxHome(), 'config.yaml');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function minimaxUidCachePath() {
|
|
34
|
+
const authDir = path.dirname(minimaxAuthPath());
|
|
35
|
+
return path.join(authDir, 'uid-cache.json');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readJson(file) {
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function writeJson(file, data) {
|
|
47
|
+
const dir = path.dirname(file);
|
|
48
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
49
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 寻找 MiniMax Code.exe 安装路径
|
|
54
|
+
*/
|
|
55
|
+
export function minimaxExePath() {
|
|
56
|
+
if (process.platform !== 'win32') return null;
|
|
57
|
+
const local = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
58
|
+
const cand = path.join(local, 'Programs', 'MiniMax Code', 'MiniMax Code.exe');
|
|
59
|
+
return fs.existsSync(cand) ? cand : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const WIN_TASKLIST = () => {
|
|
63
|
+
try {
|
|
64
|
+
return execSync('tasklist /FI "IMAGENAME eq MiniMax Code.exe" /NH', { windowsHide: true, encoding: 'utf8' });
|
|
65
|
+
} catch {
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* MiniMax Code 客户端是否正在运行
|
|
72
|
+
*/
|
|
73
|
+
export function minimaxRunning() {
|
|
74
|
+
if (process.platform !== 'win32') return false;
|
|
75
|
+
return /minimax code\.exe/i.test(WIN_TASKLIST());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 检测本机 MiniMax 客户端安装与登录状态
|
|
80
|
+
*/
|
|
81
|
+
export function detectMiniMax() {
|
|
82
|
+
const exe = minimaxExePath();
|
|
83
|
+
const authFile = minimaxAuthPath();
|
|
84
|
+
const exists = Boolean(exe || fs.existsSync(authFile));
|
|
85
|
+
const authData = readJson(authFile);
|
|
86
|
+
const records = authData?.records || {};
|
|
87
|
+
const hasRecord = Object.values(records).some((r) => r?.accessToken);
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
installed: exists,
|
|
91
|
+
exePath: exe,
|
|
92
|
+
authPath: authFile,
|
|
93
|
+
signedIn: hasRecord,
|
|
94
|
+
running: minimaxRunning(),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 从本机 ~/.minimax 读取当前登录凭据,组装为账号记录
|
|
100
|
+
*/
|
|
101
|
+
export async function liveToAccount() {
|
|
102
|
+
const authFile = minimaxAuthPath();
|
|
103
|
+
const authData = readJson(authFile);
|
|
104
|
+
if (!authData?.records) return null;
|
|
105
|
+
|
|
106
|
+
const recordKey = Object.keys(authData.records).find((k) => authData.records[k]?.accessToken);
|
|
107
|
+
if (!recordKey) return null;
|
|
108
|
+
const record = authData.records[recordKey];
|
|
109
|
+
if (!record || !record.accessToken) return null;
|
|
110
|
+
|
|
111
|
+
let profile = null;
|
|
112
|
+
try {
|
|
113
|
+
profile = await fetchMiniMaxProfile(record.accessToken);
|
|
114
|
+
} catch {
|
|
115
|
+
// profile 失败不影响返回基础账号信息
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// auth.json 记录没有稳定 uid 字段,token 又高频轮换:
|
|
119
|
+
// 把 profile 解析出的 uid 按 authRecordKey 缓存,供 currentMiniMaxUid 离线比对。
|
|
120
|
+
const cached = readJson(minimaxUidCachePath()) || {};
|
|
121
|
+
const uid = profile?.userId || record.subject || record.accountId || cached[recordKey] || null;
|
|
122
|
+
if (profile?.userId && cached[recordKey] !== profile.userId) {
|
|
123
|
+
cached[recordKey] = profile.userId;
|
|
124
|
+
try {
|
|
125
|
+
writeJson(minimaxUidCachePath(), cached);
|
|
126
|
+
} catch {
|
|
127
|
+
// 缓存写失败不影响本次返回
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const name = profile?.name || (uid ? `MiniMax_${String(uid).slice(-6)}` : 'MiniMax Code');
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
provider: 'minimax',
|
|
134
|
+
token: record.accessToken,
|
|
135
|
+
refreshToken: record.refreshToken || null,
|
|
136
|
+
expiresAt: record.expiresAtMs ? new Date(record.expiresAtMs).toISOString() : null,
|
|
137
|
+
uid: uid ? String(uid) : null,
|
|
138
|
+
name,
|
|
139
|
+
email: profile?.email || null,
|
|
140
|
+
source: 'local-app',
|
|
141
|
+
meta: {
|
|
142
|
+
authRecordKey: recordKey,
|
|
143
|
+
clientId: record.clientId,
|
|
144
|
+
scopes: record.scopes,
|
|
145
|
+
capturedAt: new Date().toISOString(),
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 获取当前登录的 UID。
|
|
152
|
+
* 优先用 record 自带字段;没有则按 authRecordKey 命中 uid 缓存(token 会轮换,uid 不会)。
|
|
153
|
+
*/
|
|
154
|
+
export function currentMiniMaxUid() {
|
|
155
|
+
const authFile = minimaxAuthPath();
|
|
156
|
+
const authData = readJson(authFile);
|
|
157
|
+
if (!authData?.records) return null;
|
|
158
|
+
const recordKey = Object.keys(authData.records).find((k) => authData.records[k]?.accessToken);
|
|
159
|
+
if (!recordKey) return null;
|
|
160
|
+
const record = authData.records[recordKey];
|
|
161
|
+
const own = record?.subject || record?.accountId;
|
|
162
|
+
if (own) return String(own);
|
|
163
|
+
const cached = readJson(minimaxUidCachePath());
|
|
164
|
+
return cached?.[recordKey] || null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 获取当前登录记录的稳定 key(token 轮换时保持不变,可跨账号库匹配)
|
|
169
|
+
*/
|
|
170
|
+
export function currentMiniMaxRecordKey() {
|
|
171
|
+
const authFile = minimaxAuthPath();
|
|
172
|
+
const authData = readJson(authFile);
|
|
173
|
+
if (!authData?.records) return null;
|
|
174
|
+
return Object.keys(authData.records).find((k) => authData.records[k]?.accessToken) || null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 获取当前登录的 Token(用于比对当前登录账号)
|
|
179
|
+
*/
|
|
180
|
+
export function currentMiniMaxToken() {
|
|
181
|
+
const authFile = minimaxAuthPath();
|
|
182
|
+
const authData = readJson(authFile);
|
|
183
|
+
if (!authData?.records) return null;
|
|
184
|
+
const record = Object.values(authData.records).find((r) => r?.accessToken);
|
|
185
|
+
return record?.accessToken || null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 读取指定 record(刷新前对齐本机客户端最新 refreshToken 用)。
|
|
190
|
+
* recordKey 见 liveToAccount 写入的 meta.authRecordKey。
|
|
191
|
+
*/
|
|
192
|
+
export function readMiniMaxRecord(recordKey) {
|
|
193
|
+
if (!recordKey) return null;
|
|
194
|
+
const authData = readJson(minimaxAuthPath());
|
|
195
|
+
return authData?.records?.[recordKey] || null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 刷新前对齐:本机导入账号(带 meta.authRecordKey)从 ~/.minimax auth.json 重读该 record 的
|
|
200
|
+
* 最新凭据,原地更新 account.{token,refreshToken,expiresAt}。
|
|
201
|
+
*
|
|
202
|
+
* 背景:MiniMax 的 refreshToken 是轮换式一次性的,本机客户端会独立刷新(auth.json 的 generation
|
|
203
|
+
* 随之累加)。账号库里的快照可能早被服务端作废,直接用会触发 invalid_grant
|
|
204
|
+
* ("this refresh token can no longer be used")。device 登录账号无 authRecordKey,跳过(自持独立链)。
|
|
205
|
+
*
|
|
206
|
+
* @returns {boolean} 是否发生了对齐更新
|
|
207
|
+
*/
|
|
208
|
+
export function alignMiniMaxFromLocal(account, log) {
|
|
209
|
+
const key = account?.meta?.authRecordKey;
|
|
210
|
+
if (!key) return false;
|
|
211
|
+
try {
|
|
212
|
+
const rec = readMiniMaxRecord(key);
|
|
213
|
+
if (!rec?.refreshToken || rec.refreshToken === account.refreshToken) return false;
|
|
214
|
+
account.refreshToken = rec.refreshToken;
|
|
215
|
+
if (rec.accessToken) account.token = rec.accessToken;
|
|
216
|
+
if (rec.expiresAtMs) account.expiresAt = new Date(rec.expiresAtMs).toISOString();
|
|
217
|
+
log?.('检测到本机客户端已轮换凭据,改用最新 refreshToken 刷新…');
|
|
218
|
+
return true;
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 安全回写刷新后的凭据到 ~/.minimax auth.json。返回是否实际写入。
|
|
226
|
+
*
|
|
227
|
+
* 仅对「本机导入」账号(带 meta.authRecordKey)且**客户端未运行**时回写:
|
|
228
|
+
* - device 登录账号(minimaxAuth.js)持有独立 loginEpoch,绝不回写,避免与客户端刷新链互斥;
|
|
229
|
+
* - 客户端运行中不回写——它内存里的 refreshToken 不受文件控制,回写新 token 会让客户端
|
|
230
|
+
* 下次刷新命中已被我们消费的旧 token(invalid_grant),反而弄坏客户端。
|
|
231
|
+
* 回写失败静默返回 false:账号库已由 refreshContext 更新,下次刷新前会再重读文件对齐。
|
|
232
|
+
*/
|
|
233
|
+
export function writeMiniMaxAuth(account) {
|
|
234
|
+
const key = account?.meta?.authRecordKey;
|
|
235
|
+
if (!key) return false;
|
|
236
|
+
if (minimaxRunning()) return false;
|
|
237
|
+
const authFile = minimaxAuthPath();
|
|
238
|
+
const authData = readJson(authFile);
|
|
239
|
+
const rec = authData?.records?.[key];
|
|
240
|
+
if (!rec) return false;
|
|
241
|
+
|
|
242
|
+
rec.accessToken = account.token;
|
|
243
|
+
if (account.refreshToken) rec.refreshToken = account.refreshToken;
|
|
244
|
+
if (account.expiresAt) {
|
|
245
|
+
const ms = new Date(account.expiresAt).getTime();
|
|
246
|
+
if (Number.isFinite(ms)) rec.expiresAtMs = ms;
|
|
247
|
+
}
|
|
248
|
+
rec.generation = (Number(rec.generation) || 0) + 1;
|
|
249
|
+
|
|
250
|
+
const tmp = authFile + '.tmp';
|
|
251
|
+
try {
|
|
252
|
+
fs.writeFileSync(tmp, JSON.stringify(authData, null, 2), { mode: 0o600, encoding: 'utf8' });
|
|
253
|
+
fs.renameSync(tmp, authFile);
|
|
254
|
+
return true;
|
|
255
|
+
} catch {
|
|
256
|
+
try { fs.rmSync(tmp, { force: true }); } catch {}
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|