mslxdff 0.1.63 → 0.1.65
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/bin/mslxdff.js +281 -45
- package/package.json +1 -1
- package/src/auto.js +13 -0
- package/src/chat/config.js +2 -1
- package/src/chat/prompt.js +7 -2
- package/src/chat/repl.js +17 -50
- package/src/chat/stats.js +1 -1
- package/src/chat/upstream.js +429 -16
- package/src/models.js +15 -5
- package/src/providers/keyring.js +19 -1
- package/src/providers/workbuddy.js +193 -99
- package/src/routes/chat/index.js +147 -2
|
@@ -71,6 +71,30 @@ function isInsufficientStatus(status, bodyText, cached) {
|
|
|
71
71
|
return false;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// 401/403 或 body 含 token 失效关键词 → 视为需刷新(workbuddy 返回 200+JSON 或 SSE 错误时也能命中)
|
|
75
|
+
function isAuthError(status, bodyText) {
|
|
76
|
+
if (status === 401 || status === 403) return true;
|
|
77
|
+
const t = String(bodyText || "").toLowerCase();
|
|
78
|
+
// 常见 workbuddy 鉴权失败文案
|
|
79
|
+
if (t.includes("unauthorized") || t.includes("authenticate") || t.includes("invalid token") || t.includes("token expired") || t.includes("token invalid") || t.includes("access token") || t.includes("login expired") || t.includes("need login") || t.includes("session expired")) return true;
|
|
80
|
+
if (t.includes("code") && (t.includes("401") || t.includes("403")) && t.includes("token")) return true;
|
|
81
|
+
// JWT 失效的 400 也可能带 token
|
|
82
|
+
if (status === 400 && t.includes("token")) return true;
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function decodeJwtExp(token) {
|
|
87
|
+
try {
|
|
88
|
+
const payload = String(token || "").split(".")[1];
|
|
89
|
+
if (!payload) return 0;
|
|
90
|
+
const json = JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
|
|
91
|
+
return Number(json.exp || 0);
|
|
92
|
+
} catch { return 0; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 并发去重:同一 uid 同时只刷一次
|
|
96
|
+
const inflightRefresh = new Map();
|
|
97
|
+
|
|
74
98
|
function withUidHeader(res, uid) {
|
|
75
99
|
try {
|
|
76
100
|
// try mutable set first
|
|
@@ -184,7 +208,7 @@ export function createWorkbuddyProvider({
|
|
|
184
208
|
} catch {}
|
|
185
209
|
}
|
|
186
210
|
|
|
187
|
-
|
|
211
|
+
let ring = createKeyRing(keys, { cooldownMs });
|
|
188
212
|
|
|
189
213
|
let dispatcher = null;
|
|
190
214
|
let agent = null;
|
|
@@ -212,51 +236,76 @@ export function createWorkbuddyProvider({
|
|
|
212
236
|
const rt = auth?.refreshToken;
|
|
213
237
|
const uid = auth?.uid;
|
|
214
238
|
if (!rt || !uid) return null;
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
"
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
if (
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
239
|
+
const dedupKey = String(uid);
|
|
240
|
+
if (inflightRefresh.has(dedupKey)) {
|
|
241
|
+
try { return await inflightRefresh.get(dedupKey); } catch { return null; }
|
|
242
|
+
}
|
|
243
|
+
const p = (async () => {
|
|
244
|
+
const url = joinUrl(resolvedBase, "/v2/plugin/auth/token/refresh");
|
|
245
|
+
const headers = {
|
|
246
|
+
"Content-Type": "application/json",
|
|
247
|
+
Authorization: `Bearer ${key}`,
|
|
248
|
+
"X-Refresh-Token": rt,
|
|
249
|
+
"X-User-Id": uid,
|
|
250
|
+
"X-Domain": auth.domain || "www.codebuddy.cn",
|
|
251
|
+
"User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
|
|
252
|
+
Origin: "https://www.codebuddy.cn",
|
|
253
|
+
Referer: "https://www.codebuddy.cn/",
|
|
254
|
+
};
|
|
255
|
+
try {
|
|
256
|
+
const opts = { method: "POST", headers, body: "{}" };
|
|
257
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
258
|
+
const res = await fetchImpl(url, opts);
|
|
259
|
+
const text = await res.text();
|
|
260
|
+
const j = JSON.parse(text);
|
|
261
|
+
if (j.code === 0 && j.data?.accessToken) {
|
|
262
|
+
const newAt = j.data.accessToken;
|
|
263
|
+
const newRt = j.data.refreshToken || rt;
|
|
264
|
+
const idx = keys.indexOf(key);
|
|
265
|
+
if (idx >= 0) {
|
|
266
|
+
keys[idx] = newAt;
|
|
267
|
+
authList[idx] = { ...auth, refreshToken: newRt };
|
|
268
|
+
// 同步 ring,避免下一轮仍取旧 token
|
|
269
|
+
try { ring.replace(key, newAt); } catch {}
|
|
270
|
+
try { saveProviderConfig(id, { baseUrl: resolvedBase, keys: [...keys], auths: [...authList] }, file ? { file } : {}); } catch {}
|
|
271
|
+
try {
|
|
272
|
+
const authDir = process.env.WORKBUDDY_AUTH_DIR || (isTestEnv() ? join(tmpdir(), "mslxdff-test-auths") : (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths")));
|
|
273
|
+
mkdirSync(authDir, { recursive: true });
|
|
274
|
+
const expAt = (() => { try { return JSON.parse(Buffer.from(newAt.split(".")[1], "base64").toString()).exp; } catch { return Math.floor(Date.now()/1000)+5184000; } })();
|
|
275
|
+
const doc = { account: { uid, enterpriseId: auth.enterpriseId || "", nickname: "" }, auth: { accessToken: newAt, refreshToken: newRt, expiresAt: expAt, domain: auth.domain || "www.codebuddy.cn" } };
|
|
276
|
+
const fp = join(authDir, `workbuddy-${uid}.json`);
|
|
277
|
+
const tmp = fp + ".tmp";
|
|
278
|
+
writeFileSync(tmp, JSON.stringify(doc, null, 2), { mode: 0o600 });
|
|
279
|
+
try { if (existsSync(fp)) { const { unlinkSync, renameSync } = await import("node:fs"); unlinkSync(fp); renameSync(tmp, fp); } else { const { renameSync } = await import("node:fs"); renameSync(tmp, fp); } } catch { writeFileSync(fp, JSON.stringify(doc, null, 2), { mode: 0o600 }); }
|
|
280
|
+
} catch {}
|
|
281
|
+
} else {
|
|
282
|
+
// 未在 keys 里的 key(如临时 ring),也尝试追加
|
|
283
|
+
if (!keys.includes(newAt)) {
|
|
284
|
+
keys.push(newAt);
|
|
285
|
+
authList.push({ ...auth, refreshToken: newRt });
|
|
286
|
+
try { ring.replace(key, newAt); } catch {}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return newAt;
|
|
255
290
|
}
|
|
256
|
-
|
|
291
|
+
} catch {}
|
|
292
|
+
return null;
|
|
293
|
+
})();
|
|
294
|
+
inflightRefresh.set(dedupKey, p);
|
|
295
|
+
try { const r = await p; return r; } finally { inflightRefresh.delete(dedupKey); }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 主动续期:JWT 5 分钟内过期则后台刷新一次(不阻塞当前请求)
|
|
299
|
+
function maybeProactiveRefresh(auth, key) {
|
|
300
|
+
try {
|
|
301
|
+
const exp = decodeJwtExp(key);
|
|
302
|
+
if (!exp) return;
|
|
303
|
+
const remain = exp * 1000 - Date.now();
|
|
304
|
+
if (remain < 5 * 60 * 1000 && remain > -60 * 60 * 1000) {
|
|
305
|
+
// 剩余 <5min 且未过期太久才刷,避免每次都刷
|
|
306
|
+
void refreshTokenFor(key, auth).catch(() => {});
|
|
257
307
|
}
|
|
258
308
|
} catch {}
|
|
259
|
-
return null;
|
|
260
309
|
}
|
|
261
310
|
|
|
262
311
|
async function attemptOnce(url, body, key) {
|
|
@@ -294,6 +343,7 @@ export function createWorkbuddyProvider({
|
|
|
294
343
|
}
|
|
295
344
|
const key = keys[idx];
|
|
296
345
|
const auth = authList[idx];
|
|
346
|
+
maybeProactiveRefresh(auth, key);
|
|
297
347
|
const cached = getCachedBalance(auth.uid);
|
|
298
348
|
if (cached && Number(cached.total) === 0) {
|
|
299
349
|
const errBody = JSON.stringify({ error: `workbuddy uid in cooldown (balance 0): ${auth.uid}` });
|
|
@@ -301,9 +351,6 @@ export function createWorkbuddyProvider({
|
|
|
301
351
|
res._t = { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0) };
|
|
302
352
|
return res;
|
|
303
353
|
}
|
|
304
|
-
// check ring cooldown by peeking if key is cooling (ring doesn't expose, so try next and see)
|
|
305
|
-
// we enforce by attempting; if ring would skip, we still allow manual but mark cooling
|
|
306
|
-
// do single attempt with this key
|
|
307
354
|
const attempts = [];
|
|
308
355
|
let waitMs = 0;
|
|
309
356
|
for (let attempt = 0; ; attempt++) {
|
|
@@ -318,24 +365,33 @@ export function createWorkbuddyProvider({
|
|
|
318
365
|
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
319
366
|
throw result;
|
|
320
367
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
368
|
+
// 401/403 或 body 含 token 失效 → 自动续期(覆盖 workbuddy 200+JSON 错误体)
|
|
369
|
+
if (attempt === 0) {
|
|
370
|
+
let bodyText = "";
|
|
371
|
+
try { bodyText = await result.clone().text(); } catch {}
|
|
372
|
+
if (isAuthError(result.status, bodyText)) {
|
|
373
|
+
const newKey = await refreshTokenFor(key, auth);
|
|
374
|
+
if (newKey) {
|
|
375
|
+
const auth2 = authForKey(newKey);
|
|
376
|
+
const headers2 = buildAuthHeaders(newKey, auth2);
|
|
377
|
+
const finalBody = { ...body, stream: true };
|
|
378
|
+
const controller2 = new AbortController();
|
|
379
|
+
const timer2 = setTimeout(() => controller2.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
|
|
380
|
+
try {
|
|
381
|
+
const opts2 = { method: "POST", headers: headers2, body: JSON.stringify(finalBody), signal: controller2.signal };
|
|
382
|
+
if (dispatcher) opts2.dispatcher = dispatcher;
|
|
383
|
+
let res2 = await fetchImpl(url, opts2);
|
|
384
|
+
// 刷新后若仍 401/403,视为刷新未生效,标记新 key 冷却并返回错误
|
|
385
|
+
let res2Body = "";
|
|
386
|
+
try { res2Body = await res2.clone().text(); } catch {}
|
|
387
|
+
const stillAuth = isAuthError(res2.status, res2Body);
|
|
388
|
+
res2._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0), refreshed: true };
|
|
389
|
+
res2 = withUidHeader(res2, auth2.uid || auth.uid);
|
|
390
|
+
if (stillAuth || res2.status === 429 || res2.status >= 500) activeRing.onError(newKey);
|
|
391
|
+
appendRotationLog({ uid: auth2.uid || auth.uid, model: modelForLog, totalMs: Math.round(performance.now() - t0), balanceHit: false, error: stillAuth ? `still auth ${res2.status}` : undefined });
|
|
392
|
+
return res2;
|
|
393
|
+
} catch (e2) { e2._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) }; throw e2; } finally { clearTimeout(timer2); }
|
|
394
|
+
}
|
|
339
395
|
}
|
|
340
396
|
}
|
|
341
397
|
const entry = retry?.[result.status];
|
|
@@ -381,6 +437,8 @@ export function createWorkbuddyProvider({
|
|
|
381
437
|
err._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
382
438
|
throw err;
|
|
383
439
|
}
|
|
440
|
+
// 主动续期(JWT 5min 内过期)
|
|
441
|
+
maybeProactiveRefresh(auth, key);
|
|
384
442
|
// single attempt with retry for network/429
|
|
385
443
|
let result = null;
|
|
386
444
|
let attempt = 0;
|
|
@@ -397,24 +455,45 @@ export function createWorkbuddyProvider({
|
|
|
397
455
|
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
398
456
|
break;
|
|
399
457
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
458
|
+
// 401/403 或 body 含 token 失效 → 自动续期后重试一次;若仍失败则切下一账号
|
|
459
|
+
if (attempt === 0) {
|
|
460
|
+
let bodyText = "";
|
|
461
|
+
try { bodyText = await result.clone().text(); } catch {}
|
|
462
|
+
const needRefresh = isAuthError(result.status, bodyText);
|
|
463
|
+
if (needRefresh) {
|
|
464
|
+
const newKey = await refreshTokenFor(key, auth);
|
|
465
|
+
if (newKey) {
|
|
466
|
+
const auth2 = authForKey(newKey);
|
|
467
|
+
const headers2 = buildAuthHeaders(newKey, auth2);
|
|
468
|
+
const finalBody = { ...body, stream: true };
|
|
469
|
+
const controller2 = new AbortController();
|
|
470
|
+
const timer2 = setTimeout(() => controller2.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
|
|
471
|
+
try {
|
|
472
|
+
const opts2 = { method: "POST", headers: headers2, body: JSON.stringify(finalBody), signal: controller2.signal };
|
|
473
|
+
if (dispatcher) opts2.dispatcher = dispatcher;
|
|
474
|
+
let res2 = await fetchImpl(url, opts2);
|
|
475
|
+
let res2Body = "";
|
|
476
|
+
try { res2Body = await res2.clone().text(); } catch {}
|
|
477
|
+
const stillAuth = isAuthError(res2.status, res2Body);
|
|
478
|
+
res2._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0), refreshed: true };
|
|
479
|
+
res2 = withUidHeader(res2, auth2.uid || uid);
|
|
480
|
+
if (stillAuth || res2.status === 429 || res2.status >= 500) activeRing.onError(newKey);
|
|
481
|
+
appendRotationLog({ uid: auth2.uid || uid, model: modelForLog, totalMs: Math.round(performance.now() - t0), balanceHit: false, error: stillAuth ? `still auth ${res2.status}` : undefined });
|
|
482
|
+
if (stillAuth) {
|
|
483
|
+
lastErr = new Error(`workbuddy auth still failing after refresh for ${uid}: ${res2Body.slice(0,120)}`);
|
|
484
|
+
lastErr._t = res2._t;
|
|
485
|
+
activeRing.onError(newKey);
|
|
486
|
+
break; // 切下一账号
|
|
487
|
+
}
|
|
488
|
+
return res2;
|
|
489
|
+
} catch (e2) { e2._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) }; lastErr = e2; break; } finally { clearTimeout(timer2); }
|
|
490
|
+
} else {
|
|
491
|
+
// 刷新失败也切下一账号
|
|
492
|
+
lastErr = new Error(`workbuddy refresh failed for ${uid}: ${bodyText.slice(0,120)}`);
|
|
493
|
+
lastErr._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
494
|
+
activeRing.onError(key);
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
418
497
|
}
|
|
419
498
|
}
|
|
420
499
|
const entry = retry?.[result.status];
|
|
@@ -500,24 +579,41 @@ export function createWorkbuddyProvider({
|
|
|
500
579
|
const now = Date.now();
|
|
501
580
|
if (cache && now - fetchedAt < CACHE_TTL_MS) return cache;
|
|
502
581
|
const url = joinUrl(resolvedBase, resolvedModelsPath);
|
|
503
|
-
const
|
|
504
|
-
|
|
582
|
+
const execList = async (useKey, useAuth) => {
|
|
583
|
+
const controller2 = new AbortController();
|
|
584
|
+
const timer2 = setTimeout(() => controller2.abort(new Error(`${id} models timed out`)), 15_000);
|
|
585
|
+
try {
|
|
586
|
+
const headers = {
|
|
587
|
+
Accept: "application/json",
|
|
588
|
+
"X-User-Id": useAuth?.uid || "",
|
|
589
|
+
"X-Domain": useAuth?.domain || "www.codebuddy.cn",
|
|
590
|
+
"X-Product": "SaaS",
|
|
591
|
+
"User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
|
|
592
|
+
Origin: "https://www.codebuddy.cn",
|
|
593
|
+
Referer: "https://www.codebuddy.cn/",
|
|
594
|
+
};
|
|
595
|
+
if (useKey) headers["Authorization"] = `Bearer ${useKey}`;
|
|
596
|
+
const opts = { headers, signal: controller2.signal };
|
|
597
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
598
|
+
return await fetchImpl(url, opts);
|
|
599
|
+
} finally { clearTimeout(timer2); }
|
|
600
|
+
};
|
|
505
601
|
try {
|
|
506
602
|
const key = ring.next() || keys[0] || "";
|
|
507
603
|
const auth = authForKey(key);
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
604
|
+
maybeProactiveRefresh(auth, key);
|
|
605
|
+
let res = await execList(key, auth);
|
|
606
|
+
if (!res.ok) {
|
|
607
|
+
let t = "";
|
|
608
|
+
try { t = await res.clone().text(); } catch {}
|
|
609
|
+
if (isAuthError(res.status, t)) {
|
|
610
|
+
const newKey = await refreshTokenFor(key, auth);
|
|
611
|
+
if (newKey) {
|
|
612
|
+
const auth2 = authForKey(newKey);
|
|
613
|
+
res = await execList(newKey, auth2);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
521
617
|
if (!res.ok) return [];
|
|
522
618
|
const json = await res.json().catch(() => ({}));
|
|
523
619
|
const models = json?.data?.models;
|
|
@@ -528,8 +624,6 @@ export function createWorkbuddyProvider({
|
|
|
528
624
|
return cache;
|
|
529
625
|
} catch {
|
|
530
626
|
return [];
|
|
531
|
-
} finally {
|
|
532
|
-
clearTimeout(timer);
|
|
533
627
|
}
|
|
534
628
|
}
|
|
535
629
|
|
package/src/routes/chat/index.js
CHANGED
|
@@ -127,6 +127,138 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
|
|
|
127
127
|
|
|
128
128
|
const handlerCtx = { reqId, model: null, body, hops, peers, plugins, evt, logError, logCall, logs };
|
|
129
129
|
|
|
130
|
+
// 首次 auto 并发测速:勾选的供应商并发比谁快,谁快下次优先(跳过明知故障的模型)
|
|
131
|
+
// 胜者会同时写入 preferredModel + auto 成功,下次 plugin 会将其排首位
|
|
132
|
+
if (useAuto && order.length > 1 && auto && !lockModel) {
|
|
133
|
+
const statuses = auto.statuses?.() ?? {};
|
|
134
|
+
const hasPriorSuccess = Object.values(statuses).some((e) => e && typeof e === "object" && e.status === "normal");
|
|
135
|
+
const nonCoolingOrder = order.filter((m) => {
|
|
136
|
+
try { return !auto.isCooling(m); } catch { return true; }
|
|
137
|
+
});
|
|
138
|
+
// 首次(无 normal)且至少 2 个非冷却候选 → 并发赛跑
|
|
139
|
+
if (!hasPriorSuccess && nonCoolingOrder.length > 1) {
|
|
140
|
+
const concLimit = (() => {
|
|
141
|
+
const v = Number(process.env.MSLXDFF_AUTO_CONCURRENT);
|
|
142
|
+
if (Number.isInteger(v) && v > 0) return Math.min(v, nonCoolingOrder.length);
|
|
143
|
+
return Math.min(nonCoolingOrder.length, 5);
|
|
144
|
+
})();
|
|
145
|
+
const raceModels = nonCoolingOrder.slice(0, concLimit);
|
|
146
|
+
evt("auto-concurrent-race", { reqId, models: raceModels, skippedFaulty: order.length - nonCoolingOrder.length, limit: concLimit });
|
|
147
|
+
// 并发发起 upstream.chat,取首个 200 成功者
|
|
148
|
+
const raceStart = performance.now();
|
|
149
|
+
const attempts = raceModels.map(async (m) => {
|
|
150
|
+
const fwd = { ...injectReasoningContent(m, body), model: m };
|
|
151
|
+
let r = null;
|
|
152
|
+
try {
|
|
153
|
+
const chatOpts = {};
|
|
154
|
+
if (Object.keys(shareKeys).length) chatOpts.shareKeys = shareKeys;
|
|
155
|
+
if (workbuddyUid) chatOpts.workbuddyUid = workbuddyUid;
|
|
156
|
+
r = await upstream.chat(fwd, Object.keys(chatOpts).length ? chatOpts : undefined);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
return { model: m, ok: false, error: errMsg(err), status: 502, timing: err?._t ?? null };
|
|
159
|
+
}
|
|
160
|
+
if (r && r.status >= 400) {
|
|
161
|
+
const isAllow = r.status === 403 && r.headers?.get?.("x-mslxdff-allowlist") === "1";
|
|
162
|
+
if (isAllow) return { model: m, ok: false, error: "allowlist", status: 403, allowlist: true };
|
|
163
|
+
return { model: m, ok: false, error: `upstream ${r.status}`, status: r.status, res: r, timing: r._t ?? null };
|
|
164
|
+
}
|
|
165
|
+
if (r instanceof Error) return { model: m, ok: false, error: errMsg(r), status: 502 };
|
|
166
|
+
return { model: m, ok: true, res: r, status: r.status, timing: r._t ?? null };
|
|
167
|
+
});
|
|
168
|
+
// 首个成功优先:轮询 settled,首个 ok 即胜;若全 fail 则走原串行兜底
|
|
169
|
+
let winner = null;
|
|
170
|
+
let winnerIdx = -1;
|
|
171
|
+
const pending = new Set(attempts.map((p, i) => ({ p, i })));
|
|
172
|
+
// 用 allSettled + 最快成功挑选(最小 timing 或最先 settled 的 ok)
|
|
173
|
+
const results = await Promise.allSettled(attempts);
|
|
174
|
+
// 按实际成功且 timing 最小排序(首包/总耗时最小者胜)
|
|
175
|
+
const okList = results.map((r, i) => ({ r, i, model: raceModels[i] }))
|
|
176
|
+
.filter(({ r }) => r.status === "fulfilled" && r.value?.ok)
|
|
177
|
+
.map(({ r, i, model }) => ({ model, idx: i, val: r.value, t: r.value.timing?.totalMs ?? r.value.timing?.ms ?? Number.MAX_SAFE_INTEGER }));
|
|
178
|
+
if (okList.length) {
|
|
179
|
+
okList.sort((a, b) => a.t - b.t);
|
|
180
|
+
const best = okList[0];
|
|
181
|
+
winner = best.val.res;
|
|
182
|
+
winnerIdx = best.idx;
|
|
183
|
+
const winModel = best.model;
|
|
184
|
+
evt("auto-concurrent-win", { reqId, model: winModel, timing: best.val.timing, totalMs: Math.round(performance.now() - raceStart), tried: raceModels.length });
|
|
185
|
+
// 记录优胜者为 normal + 设为 preferred(下次 plugin 排首位),其余失败者计 error 但不影响优先
|
|
186
|
+
for (const { r, i } of results.map((r, i) => ({ r, i }))) {
|
|
187
|
+
const m = raceModels[i];
|
|
188
|
+
if (r.status === "fulfilled" && r.value?.ok) {
|
|
189
|
+
if (m === winModel) {
|
|
190
|
+
const latencyMs = r.value.timing?.totalMs ?? Math.round(performance.now() - raceStart);
|
|
191
|
+
await auto.recordOk(m, { latencyMs });
|
|
192
|
+
try {
|
|
193
|
+
const { savePreferredModel } = await import("../../state.js");
|
|
194
|
+
savePreferredModel(m);
|
|
195
|
+
evt("auto-concurrent-preferred", { reqId, model: m });
|
|
196
|
+
} catch {}
|
|
197
|
+
}
|
|
198
|
+
} else if (r.status === "fulfilled" && !r.value?.ok && !r.value?.allowlist) {
|
|
199
|
+
// 非 allowlist 的失败才计 error(跳过的 allowlist 不计)
|
|
200
|
+
await auto.recordError(m, { status: r.value.status || 502 });
|
|
201
|
+
} else if (r.status === "rejected") {
|
|
202
|
+
await auto.recordError(m, { status: 502 });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// 直接中继优胜者
|
|
206
|
+
handlerCtx.model = winModel;
|
|
207
|
+
const isStream = Boolean(body.stream);
|
|
208
|
+
// 复用本地中继逻辑(不走 hedge,直接 relay)
|
|
209
|
+
const { handleLocalRelay: _relay } = await import("./local-handler.js");
|
|
210
|
+
const lr = await _relay({
|
|
211
|
+
upRes: winner,
|
|
212
|
+
model: winModel,
|
|
213
|
+
body,
|
|
214
|
+
order: raceModels,
|
|
215
|
+
idx: winnerIdx,
|
|
216
|
+
lastErr: null,
|
|
217
|
+
requested,
|
|
218
|
+
useAuto,
|
|
219
|
+
lockModel,
|
|
220
|
+
auto,
|
|
221
|
+
handlerCtx,
|
|
222
|
+
evt,
|
|
223
|
+
logCall,
|
|
224
|
+
logError,
|
|
225
|
+
mark,
|
|
226
|
+
perf0,
|
|
227
|
+
stages,
|
|
228
|
+
startedAt,
|
|
229
|
+
plugins,
|
|
230
|
+
res,
|
|
231
|
+
});
|
|
232
|
+
if (lr.handled) return;
|
|
233
|
+
// 若中继未 handled(如 interrupted),按原逻辑继续
|
|
234
|
+
if (lr.lastErr) {
|
|
235
|
+
// 优胜者中继失败,降级为串行兜底(剩余 order 中未测的继续)
|
|
236
|
+
} else {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
evt("auto-concurrent-all-fail", { reqId, tried: raceModels.length, totalMs: Math.round(performance.now() - raceStart) });
|
|
241
|
+
// 全失败:记录失败并继续走原串行(会按 order 逐个重试,含未并发的尾部)
|
|
242
|
+
for (const { r, i } of results.map((r, i) => ({ r, i }))) {
|
|
243
|
+
const m = raceModels[i];
|
|
244
|
+
if (r.status === "fulfilled" && !r.value?.ok && !r.value?.allowlist) await auto.recordError(m, { status: r.value.status || 502 });
|
|
245
|
+
else if (r.status === "rejected") await auto.recordError(m, { status: 502 });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// 并发未决出胜者或中继失败,回落到原串行循环(会跳过已试的 raceModels,继续 trial 剩余 order)
|
|
249
|
+
// 为避免重复试已失败的 raceModels,过滤 order
|
|
250
|
+
const triedSet = new Set(raceModels);
|
|
251
|
+
order = order.filter((m) => !triedSet.has(m));
|
|
252
|
+
if (!order.length) {
|
|
253
|
+
// 首次并发已试全部且全失败 → 按“所有勾选都不通”直接失败
|
|
254
|
+
const last = { model: raceModels[0] || requested, status: 502, message: "all concurrent candidates failed" };
|
|
255
|
+
await handleExhaustedAll({ res, body, lastErr: last, order: raceModels, requested, handlerCtx: { ...handlerCtx, reqId, startedAt }, evt, logCall, mark, perf0, stages });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// 继续走下方串行 for 循环(新 order)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
130
262
|
let lastErr = null;
|
|
131
263
|
for (let idx = 0; idx < order.length; idx++) {
|
|
132
264
|
const model = order[idx];
|
|
@@ -175,15 +307,28 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
|
|
|
175
307
|
}
|
|
176
308
|
mark(`up-${model}`);
|
|
177
309
|
if (upRes && upRes.status >= 400) {
|
|
178
|
-
// 白名单 403 直通,不计冷却、不 fallback
|
|
179
310
|
const isAllowlistBlock = upRes.status === 403 && (upRes.headers?.get?.("x-mslxdff-allowlist") === "1");
|
|
180
311
|
if (isAllowlistBlock) {
|
|
181
312
|
let bodyText = null;
|
|
182
313
|
try { bodyText = await upRes.clone().text(); } catch {}
|
|
183
314
|
let errBody = { error: `model not allowed for provider` };
|
|
184
315
|
try { errBody = bodyText ? JSON.parse(bodyText) : errBody; } catch { errBody = { error: bodyText || "model not allowed" }; }
|
|
316
|
+
// 白名单 403:显式模型直通 403;auto 时跳过该候选继续往下走(不打断多供应商 auto)
|
|
317
|
+
if (useAuto) {
|
|
318
|
+
// auto:软错跳过,不计冷却,继续试下一个供应商/模型
|
|
319
|
+
logError(model, 403, errBody.error || "model not allowed");
|
|
320
|
+
evt("upstream-error", { reqId, model, status: 403, message: errBody.error, timing: upRes._t ?? null, allowlist: true, skipped: true });
|
|
321
|
+
lastErr = { model, upstream: upRes, status: 403, message: errBody.error || "model not allowed" };
|
|
322
|
+
if (canFallback && idx < order.length - 1) {
|
|
323
|
+
evt("fallback", { reqId, from: model, to: order[idx + 1] ?? null, reason: `allowlist skip ${errBody.error || "blocked"}` });
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
// auto 已到末尾仍全被拦 → 直通 403
|
|
327
|
+
return json(res, 403, errBody);
|
|
328
|
+
}
|
|
329
|
+
// 非 auto:显式指定被拦 → 硬 403,不 fallback(防绕过 allowlist)
|
|
185
330
|
logError(model, 403, errBody.error || "model not allowed");
|
|
186
|
-
evt("upstream-error", { reqId, model, status: 403, message: errBody.error, timing: upRes._t ?? null });
|
|
331
|
+
evt("upstream-error", { reqId, model, status: 403, message: errBody.error, timing: upRes._t ?? null, allowlist: true });
|
|
187
332
|
return json(res, 403, errBody);
|
|
188
333
|
}
|
|
189
334
|
if (auto) await auto.recordError(model, { status: upRes.status });
|