mslxdff 0.1.64 → 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 +47 -10
- package/package.json +1 -1
- package/src/chat/prompt.js +7 -2
- package/src/chat/repl.js +8 -4
- package/src/chat/upstream.js +271 -36
- 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
package/bin/mslxdff.js
CHANGED
|
@@ -1313,7 +1313,7 @@ if (args.includes("-provider") || args.includes("--provider")) {
|
|
|
1313
1313
|
console.error(` mslxdff -provider ${id} allowAny on|off (empty allowlist = block or allow all)`);
|
|
1314
1314
|
process.exit(1);
|
|
1315
1315
|
}
|
|
1316
|
-
if (sub === "models" || sub === "list-models" || sub === "ls") {
|
|
1316
|
+
if (sub === "models" || sub === "show-models" || sub === "list-models" || sub === "ls") {
|
|
1317
1317
|
const wantsJson = args.includes("--json") || args.includes("-json");
|
|
1318
1318
|
const cfg = loadProviderConfig(id);
|
|
1319
1319
|
// opencode: use aggregated cache + provider-specific? For opencode, show bare ids from cache
|
|
@@ -1354,20 +1354,57 @@ if (args.includes("-provider") || args.includes("--provider")) {
|
|
|
1354
1354
|
provider = createGenericProvider({ id, baseUrl, apiKeys: keys, file: defaultStateFile() });
|
|
1355
1355
|
}
|
|
1356
1356
|
const all = await provider.listModels();
|
|
1357
|
-
//
|
|
1358
|
-
const
|
|
1359
|
-
const raw = String(
|
|
1360
|
-
|
|
1361
|
-
const checkRaw = m.id.startsWith(`${id}/`) ? m.id.slice(id.length + 1) : raw;
|
|
1357
|
+
// show 全部上游模型,仅标注是否被 allowlist 放行(不拦截展示)
|
|
1358
|
+
const markAllowed = (mid) => {
|
|
1359
|
+
const raw = String(mid || "").includes("/") ? String(mid).split("/").slice(1).join("/") : String(mid);
|
|
1360
|
+
const checkRaw = mid.startsWith(`${id}/`) ? mid.slice(id.length + 1) : raw;
|
|
1362
1361
|
return isModelAllowed(id, checkRaw);
|
|
1363
|
-
}
|
|
1362
|
+
};
|
|
1364
1363
|
if (wantsJson) {
|
|
1364
|
+
// --json 仍按 allowlist 过滤(给脚本消费可用模型)
|
|
1365
|
+
const filtered = all.filter((m) => markAllowed(m.id));
|
|
1365
1366
|
console.log(JSON.stringify({ object: "list", data: filtered }, null, 2));
|
|
1366
1367
|
} else {
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1368
|
+
const allowedCount = all.filter((m) => markAllowed(m.id)).length;
|
|
1369
|
+
console.log(`${id} models (${all.length} total, ${allowedCount} ✓ allowed${allowedCount !== all.length ? `, ${all.length - allowedCount} x blocked by allowlist` : ""}):`);
|
|
1370
|
+
const fmtPrice = (m) => {
|
|
1371
|
+
const c = String(m.credits || "").trim();
|
|
1372
|
+
if (c) {
|
|
1373
|
+
// "x0.00 credits" / "x0.00" → 统一成 "x0.00"
|
|
1374
|
+
const m0 = c.match(/x\s*([\d.]+)/i);
|
|
1375
|
+
if (m0) return `x${m0[1]}`;
|
|
1376
|
+
return c.replace(/\s*credits\s*/gi, "").trim().replace(/\s+/g, " ");
|
|
1377
|
+
}
|
|
1378
|
+
if (m.pricing && typeof m.pricing === "object") {
|
|
1379
|
+
const p = m.pricing.prompt ?? m.pricing.input ?? m.pricing.completion ?? "";
|
|
1380
|
+
if (p) return String(p);
|
|
1381
|
+
}
|
|
1382
|
+
if (m.price != null && String(m.price).trim()) return String(m.price).trim();
|
|
1383
|
+
if (String(m.id).endsWith("/auto")) return "浮动";
|
|
1384
|
+
return "—";
|
|
1385
|
+
};
|
|
1386
|
+
const fmtBadge = (m) => {
|
|
1387
|
+
const tags = Array.isArray(m.tags) ? m.tags : [];
|
|
1388
|
+
const b = tags.find((t) => String(t).includes("限时免费") || String(t).toLowerCase().includes("free"));
|
|
1389
|
+
if (!b) return "";
|
|
1390
|
+
const part = String(b).split(":")[1];
|
|
1391
|
+
return part ? ` [${part}]` : ` [${b}]`;
|
|
1392
|
+
};
|
|
1393
|
+
// 按 credits 升序已在 provider 排好序,展示时对齐价格列便于分辨
|
|
1394
|
+
const idW = Math.max(22, ...all.map((m) => String(m.id).length)) + 2;
|
|
1395
|
+
const priceW = Math.max(6, ...all.map((m) => fmtPrice(m).length)) + 2;
|
|
1396
|
+
for (const m of all) {
|
|
1397
|
+
const ok = markAllowed(m.id);
|
|
1398
|
+
const price = fmtPrice(m);
|
|
1399
|
+
const badge = fmtBadge(m);
|
|
1400
|
+
const name = m.name ? ` ${m.name}` : "";
|
|
1401
|
+
const blocked = ok ? "" : " [blocked — allowlist]";
|
|
1402
|
+
const line = ` ${ok ? "✓" : "x"} ${String(m.id).padEnd(idW)}${String(price).padEnd(priceW)}${name}${badge}${blocked}`;
|
|
1403
|
+
console.log(line);
|
|
1404
|
+
}
|
|
1370
1405
|
if (!all.length) console.log(` (no models — check baseUrl/keys or try: curl ${baseUrl}/models)`);
|
|
1406
|
+
else if (allowedCount === 0) console.log(` tip: all blocked — mslxdff -provider ${id} allowAny on 或 allowlist set <model...>`);
|
|
1407
|
+
else if (allowedCount !== all.length) console.log(` tip: blocked 仅影响 /v1/chat 调用,展示已全量列出`);
|
|
1371
1408
|
}
|
|
1372
1409
|
try { await provider.close?.(); } catch {}
|
|
1373
1410
|
} catch (e) {
|
package/package.json
CHANGED
package/src/chat/prompt.js
CHANGED
|
@@ -47,6 +47,11 @@ export function buildSystemPrompt({ modelsOverride } = {}) {
|
|
|
47
47
|
|
|
48
48
|
${mini}
|
|
49
49
|
|
|
50
|
+
语言(最高优先级):
|
|
51
|
+
- 全部面向用户的自然语言回复**必须使用简体中文**(无论用户用英文/日文/拼音提问,都用中文回答)。
|
|
52
|
+
- 仅代码、命令、模型 id、路径、JSON 等技术标识保持原文,不做翻译。
|
|
53
|
+
- 禁止输出英文长段解释;中英文混排时中文为主。
|
|
54
|
+
|
|
50
55
|
规则:
|
|
51
56
|
- 用户说简称你必须自行查“可用模型”找到全称,例如 hy3→hy3-free,mimo→mimo-v2.5-free,bigpickle→big-pickle。
|
|
52
57
|
- 永远输出精确的命令与模型 id,大小写敏感。
|
|
@@ -56,8 +61,8 @@ ${mini}
|
|
|
56
61
|
- 严禁幻觉命令:mslxdff "hi" --model X / mslxdff --model X "hi" / mslxdff -chat --model X 都不存在,输出只会是 status 页。探活任意模型(含 clinebot/*、workbuddy/*、bai/*)必须用 curl POST http://localhost:8989/v1/chat/completions,body 为 {"model":"<前缀/模型>","messages":[{"role":"user","content":"hi"}],"stream":false},成功 200 + x-mslxdff-via:local 即通;401 代表本机 token 陈旧需提示 mslxdff -stop && mslxdff;403 + x-mslxdff-allowlist:1 代表白名单未放行需 allowlist add。
|
|
57
62
|
- **禁止重复调用(最高优先级)**:同一 run_command/curl/read_file 在本轮只执行一次,重复会被工具侧 SKIPPED_DUP 拦截;查询类(-showtoken/-status/-provider list/-providers list/-model list/-group list/-log 等)**调用一次即答案**,拿到 OK 结果后必须**立即用中文直接回答用户**,禁止再发起任何工具调用。收到 SKIPPED_DUP 或“请直接回答/禁止再调用”提示时,必须 0 工具直接回答。
|
|
58
63
|
- 禁止调用 -uninstall,包含即拒绝;-showtoken 仅在用户明确要求查看/调试本机 token 时才用,查模型/查供应商严禁调用。
|
|
59
|
-
-
|
|
60
|
-
-
|
|
64
|
+
- 回复风格:简洁友好,执行前后用中文说明你在做什么。
|
|
65
|
+
- 若用户只是闲聊/提问且可用模型列表已能回答,不调工具,直接用中文回答。`;
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
export function getModelsForPrompt() {
|
package/src/chat/repl.js
CHANGED
|
@@ -59,6 +59,7 @@ async function runAgentTurn(userText, messages) {
|
|
|
59
59
|
messages.push({ role: "user", content: userText });
|
|
60
60
|
let loops = 0;
|
|
61
61
|
let lastModel = null;
|
|
62
|
+
let lastProvider = null;
|
|
62
63
|
let lastUsage = null;
|
|
63
64
|
let lastFallback = false;
|
|
64
65
|
let lastFallbackGateway = false;
|
|
@@ -105,6 +106,7 @@ async function runAgentTurn(userText, messages) {
|
|
|
105
106
|
return { text: err, model: null, latency: lastLatency, usage: null, fallback: false, ok: false };
|
|
106
107
|
}
|
|
107
108
|
lastModel = res.model;
|
|
109
|
+
lastProvider = res.provider || null;
|
|
108
110
|
lastUsage = res.usage || null;
|
|
109
111
|
lastFallback = !!res.fallback;
|
|
110
112
|
lastFallbackGateway = !!res.fallbackGateway || !!res.viaGateway;
|
|
@@ -123,7 +125,7 @@ async function runAgentTurn(userText, messages) {
|
|
|
123
125
|
if (lastFallbackGateway) note = "\n\x1b[90m[注:mimo/big-pickle 均不可用,已自动切本地网关 auto(:8989)]\x1b[0m";
|
|
124
126
|
else if (lastFallback) note = "\n\x1b[90m[注:mimo 不可用,已用 big-pickle]\x1b[0m";
|
|
125
127
|
trace(`[turn] 完成 总计 ${totalMs}ms · LLM ${lastLatency}ms · 0 工具${lastFallbackGateway ? " · gateway-fallback" : ""}`);
|
|
126
|
-
return { text: text + note, model: lastModel, latency: lastLatency, usage: lastUsage, fallback: lastFallback, fallbackGateway: lastFallbackGateway, ok: true, totalMs };
|
|
128
|
+
return { text: text + note, model: lastModel, provider: lastProvider, latency: lastLatency, usage: lastUsage, fallback: lastFallback, fallbackGateway: lastFallbackGateway, ok: true, totalMs };
|
|
127
129
|
}
|
|
128
130
|
const calls = toolCalls.length ? toolCalls : [{ id: "fallback-1", function: { name: "run_command", arguments: JSON.stringify({ command: fallbackCmd }) } }];
|
|
129
131
|
messages.push({ role: "assistant", content: msg.content || "", tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: c.function })) });
|
|
@@ -217,7 +219,7 @@ async function runAgentTurn(userText, messages) {
|
|
|
217
219
|
messages.push({ role: "assistant", content: synth });
|
|
218
220
|
const totalMs = Math.round(performance.now() - t0);
|
|
219
221
|
trace(`[turn] 提前结束(重复阈值) 总计 ${totalMs}ms`);
|
|
220
|
-
return { text: synth, model: lastModel || "local", latency: lastLatency, usage: lastUsage, fallback: lastFallback, ok: true, totalMs };
|
|
222
|
+
return { text: synth, model: lastModel || "local", provider: lastProvider, latency: lastLatency, usage: lastUsage, fallback: lastFallback, ok: true, totalMs };
|
|
221
223
|
}
|
|
222
224
|
}
|
|
223
225
|
const toolsMs = Math.round(performance.now() - tTools);
|
|
@@ -228,12 +230,14 @@ async function runAgentTurn(userText, messages) {
|
|
|
228
230
|
return { text: "(工具调用次数已达上限,已停止)", model: lastModel, latency: lastLatency, usage: lastUsage, fallback: lastFallback, fallbackGateway: lastFallbackGateway, ok: false };
|
|
229
231
|
}
|
|
230
232
|
|
|
231
|
-
function printFooter({ model, latency, usage, totalMs, fallback, fallbackGateway, viaGateway }) {
|
|
233
|
+
function printFooter({ model, provider, latency, usage, totalMs, fallback, fallbackGateway, viaGateway }) {
|
|
232
234
|
const dim = "\x1b[90m";
|
|
233
235
|
const rst = "\x1b[0m";
|
|
234
236
|
let gw = null;
|
|
235
237
|
try { gw = collectStats(); } catch {}
|
|
236
|
-
const
|
|
238
|
+
const prov = provider && provider !== "opencode" ? `${provider}/` : "";
|
|
239
|
+
const baseLabel = model ? `${prov}${model}` : "—";
|
|
240
|
+
const modelLabel = model ? (fallbackGateway || viaGateway ? `${baseLabel} (gateway auto)` : baseLabel) : "—";
|
|
237
241
|
const latLabel = latency ? `${latency}ms` : "—";
|
|
238
242
|
const totalLabel = totalMs ? ` · 总耗时 ${totalMs}ms` : "";
|
|
239
243
|
const tokLabel = usage ? ` · tokens ${usage.prompt_tokens ?? "?"}→${usage.completion_tokens ?? "?"}` : "";
|
package/src/chat/upstream.js
CHANGED
|
@@ -8,6 +8,10 @@ function modelForAttempt(attempt) {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export async function chatOnce({ messages, tools, model }) {
|
|
11
|
+
// 直连 mimo/pickle 不走 anon 3s 额外探测,避免 800ms 对冲被拖慢
|
|
12
|
+
const prevAnon = process.env.MSLXDFF_FREE_ANON;
|
|
13
|
+
const needDisableAnon = model === CHAT_PREFERRED || model === CHAT_FALLBACK;
|
|
14
|
+
if (needDisableAnon) process.env.MSLXDFF_FREE_ANON = "0";
|
|
11
15
|
const client = createUpstreamClient({ connectTimeoutMs: CHAT_TIMEOUT_MS, keepAlive: false, fetchImpl: globalThis.fetch });
|
|
12
16
|
const body = {
|
|
13
17
|
model: model || CHAT_PREFERRED,
|
|
@@ -39,10 +43,17 @@ export async function chatOnce({ messages, tools, model }) {
|
|
|
39
43
|
return { ok: true, message: choice.message, usage: j.usage, raw: j, status: res.status };
|
|
40
44
|
} finally {
|
|
41
45
|
try { await client.close(); } catch {}
|
|
46
|
+
if (needDisableAnon) {
|
|
47
|
+
if (prevAnon === undefined) delete process.env.MSLXDFF_FREE_ANON;
|
|
48
|
+
else process.env.MSLXDFF_FREE_ANON = prevAnon;
|
|
49
|
+
}
|
|
42
50
|
}
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
async function chatOnceNoTools({ messages, model }) {
|
|
54
|
+
const prevAnon2 = process.env.MSLXDFF_FREE_ANON;
|
|
55
|
+
const needDisable2 = model === CHAT_PREFERRED || model === CHAT_FALLBACK;
|
|
56
|
+
if (needDisable2) process.env.MSLXDFF_FREE_ANON = "0";
|
|
46
57
|
const client = createUpstreamClient({ connectTimeoutMs: CHAT_TIMEOUT_MS, keepAlive: false, fetchImpl: globalThis.fetch });
|
|
47
58
|
const body = { model: model || CHAT_PREFERRED, messages, stream: false };
|
|
48
59
|
try {
|
|
@@ -54,7 +65,13 @@ async function chatOnceNoTools({ messages, model }) {
|
|
|
54
65
|
const choice = j.choices?.[0];
|
|
55
66
|
if (!choice) return { ok: false, error: "no choice", status: res.status };
|
|
56
67
|
return { ok: true, message: choice.message, usage: j.usage, raw: j, status: res.status };
|
|
57
|
-
} finally {
|
|
68
|
+
} finally {
|
|
69
|
+
try { await client.close(); } catch {}
|
|
70
|
+
if (needDisable2) {
|
|
71
|
+
if (prevAnon2 === undefined) delete process.env.MSLXDFF_FREE_ANON;
|
|
72
|
+
else process.env.MSLXDFF_FREE_ANON = prevAnon2;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
58
75
|
}
|
|
59
76
|
|
|
60
77
|
// 带自动降级:mimo-v2.5-free → big-pickle → 本地 8989 auto(网关 auto,含多供应商择优/hedge/peer)
|
|
@@ -108,8 +125,10 @@ async function chatViaGateway({ messages, tools }) {
|
|
|
108
125
|
try {
|
|
109
126
|
const lines = txt.split(/\r?\n/);
|
|
110
127
|
let content = "";
|
|
128
|
+
let toolCallsMap = new Map();
|
|
111
129
|
let model = "auto";
|
|
112
130
|
let usage = null;
|
|
131
|
+
let finishReason = "stop";
|
|
113
132
|
let sseOk = false;
|
|
114
133
|
for (const line of lines) {
|
|
115
134
|
const t = String(line).trim();
|
|
@@ -120,24 +139,50 @@ async function chatViaGateway({ messages, tools }) {
|
|
|
120
139
|
const obj = JSON.parse(payload);
|
|
121
140
|
sseOk = true;
|
|
122
141
|
const ch = obj.choices?.[0];
|
|
123
|
-
|
|
142
|
+
if (ch?.finish_reason) finishReason = ch.finish_reason;
|
|
143
|
+
// content / reasoning_content
|
|
124
144
|
if (ch?.delta?.content) content += ch.delta.content;
|
|
125
145
|
else if (ch?.delta?.reasoning_content) content += ch.delta.reasoning_content;
|
|
126
146
|
else if (ch?.message?.content) content += ch.message.content;
|
|
127
147
|
else if (ch?.message?.reasoning_content) content += ch.message.reasoning_content;
|
|
128
148
|
else if (typeof ch?.text === "string") content += ch.text;
|
|
129
149
|
else if (typeof obj.content === "string") content += obj.content;
|
|
150
|
+
if (ch?.delta?.tool_calls) {
|
|
151
|
+
for (const tc of ch.delta.tool_calls) {
|
|
152
|
+
const idx = tc.index ?? 0;
|
|
153
|
+
const cur = toolCallsMap.get(idx) || { id: tc.id || `chatcmpl-tool-${idx}`, type: tc.type || "function", function: { name: "", arguments: "" } };
|
|
154
|
+
if (tc.id) cur.id = tc.id;
|
|
155
|
+
if (tc.type) cur.type = tc.type;
|
|
156
|
+
if (tc.function?.name) cur.function.name = tc.function.name;
|
|
157
|
+
if (typeof tc.function?.arguments === "string") cur.function.arguments += tc.function.arguments;
|
|
158
|
+
toolCallsMap.set(idx, cur);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (ch?.message?.tool_calls) {
|
|
162
|
+
for (const tc of ch.message.tool_calls) {
|
|
163
|
+
const idx = tc.index ?? toolCallsMap.size;
|
|
164
|
+
toolCallsMap.set(idx, tc);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
130
167
|
if (obj.model) model = obj.model;
|
|
131
168
|
if (obj.usage) usage = obj.usage;
|
|
132
|
-
// 有些 SSE 直接是完整 chat.completion
|
|
133
169
|
if (obj.choices?.[0]?.message?.content && !content) content = obj.choices[0].message.content;
|
|
134
170
|
if (obj.choices?.[0]?.message?.reasoning_content && !content) content = obj.choices[0].message.reasoning_content;
|
|
171
|
+
if (obj.choices?.[0]?.message?.tool_calls && toolCallsMap.size === 0) {
|
|
172
|
+
for (const tc of obj.choices[0].message.tool_calls) toolCallsMap.set(tc.index ?? 0, tc);
|
|
173
|
+
}
|
|
135
174
|
} catch {}
|
|
136
175
|
}
|
|
137
|
-
|
|
138
|
-
|
|
176
|
+
const hasToolCalls = toolCallsMap.size > 0;
|
|
177
|
+
const hasContent = !!content;
|
|
178
|
+
if (sseOk && (hasContent || hasToolCalls)) {
|
|
179
|
+
const tool_calls = hasToolCalls ? [...toolCallsMap.values()].sort((a,b)=>(a.index??0)-(b.index??0)) : undefined;
|
|
180
|
+
const msg = { role: "assistant", content: content || "" };
|
|
181
|
+
if (tool_calls) msg.tool_calls = tool_calls;
|
|
182
|
+
// 若 finish_reason 为 tool_calls 但 content 为空,仍视为有效 tool_calls
|
|
183
|
+
if (hasToolCalls && !hasContent) finishReason = "tool_calls";
|
|
184
|
+
j = { id: `sse-${Date.now()}`, object: "chat.completion", model, choices: [{ index: 0, finish_reason: finishReason, message: msg }], usage };
|
|
139
185
|
} else if (sseOk) {
|
|
140
|
-
// SSE 但无 content,按失败处理
|
|
141
186
|
return { ok: false, error: `gateway SSE no content: ${txt.slice(0, 800)}`, status: res.status };
|
|
142
187
|
} else {
|
|
143
188
|
return { ok: false, error: `gateway non-json: ${txt.slice(0, 800)}`, status: res.status };
|
|
@@ -164,13 +209,39 @@ async function chatViaGateway({ messages, tools }) {
|
|
|
164
209
|
if (TRACE) console.log(`\x1b[90m· [gateway debug] no choice, txt=${txt.slice(0, 800)} · j=${JSON.stringify(j).slice(0, 800)}\x1b[0m`);
|
|
165
210
|
return { ok: false, error: `gateway no choice: ${txt.slice(0, 800)}`, status: res.status };
|
|
166
211
|
}
|
|
212
|
+
// 推断供应商:读 models.json 前缀表,匹配 raw → provider
|
|
213
|
+
let provider = "opencode";
|
|
214
|
+
const rawModel = effectiveJ.model || j.model || choice.message?.model || "auto";
|
|
215
|
+
try {
|
|
216
|
+
const { readFileSync: rfs, existsSync: es } = await import("node:fs");
|
|
217
|
+
const { join: jn } = await import("node:path");
|
|
218
|
+
const { homedir: hd } = await import("node:os");
|
|
219
|
+
const cache = jn(hd(), ".config", "mslxdff", "models.json");
|
|
220
|
+
if (es(cache)) {
|
|
221
|
+
const c = JSON.parse(rfs(cache, "utf8"));
|
|
222
|
+
const ids = (c.data || []).map((x) => x.id).filter(Boolean);
|
|
223
|
+
for (const pid of ids) {
|
|
224
|
+
const slash = pid.indexOf("/");
|
|
225
|
+
const prov = slash > 0 ? pid.slice(0, slash) : "opencode";
|
|
226
|
+
const raw = slash > 0 ? pid.slice(slash + 1) : pid;
|
|
227
|
+
if (pid === rawModel || raw === rawModel || pid.endsWith("/" + rawModel)) {
|
|
228
|
+
provider = prov;
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (provider === "opencode" && rawModel.includes("/")) {
|
|
233
|
+
const maybe = rawModel.split("/")[0];
|
|
234
|
+
if (["workbuddy", "clinebot", "sensenova", "openrouter", "generic"].includes(maybe)) provider = maybe;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} catch {}
|
|
167
238
|
if (TRACE) {
|
|
168
239
|
const dt = Math.round(performance.now() - t0);
|
|
169
|
-
const m =
|
|
170
|
-
console.log(`\x1b[90m· [LLM] gateway auto OK · ${dt}ms · 模型 ${m} · 总 ${Math.round(performance.now() - t0)}ms (gateway-fallback)\x1b[0m`);
|
|
240
|
+
const m = rawModel;
|
|
241
|
+
console.log(`\x1b[90m· [LLM] gateway auto OK · ${dt}ms · 模型 ${provider !== "opencode" ? provider + "/" : ""}${m} · 总 ${Math.round(performance.now() - t0)}ms (gateway-fallback)\x1b[0m`);
|
|
171
242
|
}
|
|
172
243
|
// 透传 usage/raw,并标记 gateway(兼容 data 包装)
|
|
173
|
-
return { ok: true, message: choice.message, usage: effectiveJ.usage || j.usage, raw: j, status: res.status, model:
|
|
244
|
+
return { ok: true, message: choice.message, usage: effectiveJ.usage || j.usage, raw: j, status: res.status, model: rawModel, provider, viaGateway: true };
|
|
174
245
|
} catch (err) {
|
|
175
246
|
const msg = String(err?.message || err).slice(0, 800);
|
|
176
247
|
if (TRACE) {
|
|
@@ -193,6 +264,9 @@ async function safeChatOnce(opts, model) {
|
|
|
193
264
|
}
|
|
194
265
|
}
|
|
195
266
|
|
|
267
|
+
const CHAT_COOLDOWN_MS = 10 * 60 * 1000; // mimo 429 后至少 10min 不再试直连
|
|
268
|
+
const CHAT_SLOW_COOLDOWN_MS = 10 * 60 * 1000;
|
|
269
|
+
|
|
196
270
|
async function isCoolingAsync(id) {
|
|
197
271
|
try {
|
|
198
272
|
const state = await import("../state.js");
|
|
@@ -202,57 +276,218 @@ async function isCoolingAsync(id) {
|
|
|
202
276
|
const at = Number(e.at || 0);
|
|
203
277
|
if (!at) return false;
|
|
204
278
|
const isSlow = !!e.slow;
|
|
205
|
-
const cd = isSlow ?
|
|
279
|
+
const cd = isSlow ? CHAT_SLOW_COOLDOWN_MS : CHAT_COOLDOWN_MS;
|
|
206
280
|
return Date.now() - at < cd && (e.status === "limit" || e.status === "error");
|
|
207
281
|
} catch { return false; }
|
|
208
282
|
}
|
|
209
283
|
|
|
284
|
+
async function recordChatError(id, status, { slow = false, latencyMs = 0 } = {}) {
|
|
285
|
+
try {
|
|
286
|
+
const state = await import("../state.js");
|
|
287
|
+
const errors = state.loadModelErrors();
|
|
288
|
+
const isLimit = Number(status) === 429 || String(status).includes("429");
|
|
289
|
+
// 复用 auto 的分类:429/limit → limit,否则 error
|
|
290
|
+
const entryStatus = isLimit ? "limit" : "error";
|
|
291
|
+
errors[id] = { status: entryStatus, at: Date.now(), code: Number.isInteger(Number(status)) ? Number(status) : null, slow: !!slow };
|
|
292
|
+
state.saveModelErrors(errors);
|
|
293
|
+
try { state.flushStateSync(); } catch {}
|
|
294
|
+
if (slow && Number.isFinite(latencyMs) && latencyMs > 0) {
|
|
295
|
+
try {
|
|
296
|
+
const lat = state.loadModelLatencies();
|
|
297
|
+
const prev = lat[id]?.emaMs;
|
|
298
|
+
const ema = prev ? Math.round(prev * 0.7 + latencyMs * 0.3) : Math.round(latencyMs);
|
|
299
|
+
lat[id] = { emaMs: ema, lastMs: Math.round(latencyMs), at: Date.now(), count: (lat[id]?.count ?? 0) + 1 };
|
|
300
|
+
state.saveModelLatencies(lat);
|
|
301
|
+
try { state.flushStateSync(); } catch {}
|
|
302
|
+
} catch {}
|
|
303
|
+
}
|
|
304
|
+
} catch {}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function recordChatOk(id, latencyMs) {
|
|
308
|
+
try {
|
|
309
|
+
const state = await import("../state.js");
|
|
310
|
+
const errors = state.loadModelErrors();
|
|
311
|
+
errors[id] = { status: "normal", at: Date.now(), code: 200, slow: false };
|
|
312
|
+
state.saveModelErrors(errors);
|
|
313
|
+
try { state.flushStateSync(); } catch {}
|
|
314
|
+
if (Number.isFinite(latencyMs) && latencyMs > 0) {
|
|
315
|
+
const lat = state.loadModelLatencies();
|
|
316
|
+
const prev = lat[id]?.emaMs;
|
|
317
|
+
const ema = prev ? Math.round(prev * 0.7 + latencyMs * 0.3) : Math.round(latencyMs);
|
|
318
|
+
lat[id] = { emaMs: ema, lastMs: Math.round(latencyMs), at: Date.now(), count: (lat[id]?.count ?? 0) + 1 };
|
|
319
|
+
state.saveModelLatencies(lat);
|
|
320
|
+
try { state.flushStateSync(); } catch {}
|
|
321
|
+
}
|
|
322
|
+
} catch {}
|
|
323
|
+
}
|
|
324
|
+
|
|
210
325
|
export async function chatWithFallback(opts) {
|
|
211
326
|
const TRACE = process.env.MSLXDFF_CHAT_TRACE !== "0";
|
|
327
|
+
const HEDGE_MS = (() => {
|
|
328
|
+
const v = Number(process.env.MSLXDFF_HEDGE_DELAY_MS);
|
|
329
|
+
return Number.isInteger(v) && v >= 0 ? v : 800;
|
|
330
|
+
})();
|
|
212
331
|
const t0 = TRACE ? performance.now() : 0;
|
|
213
332
|
// 若上次已确认冷却(429/limit),直接跳过,避免 7+7 秒白等,第二次直接走“上次成功”的网关
|
|
214
333
|
const firstCooling = await isCoolingAsync(CHAT_PREFERRED);
|
|
215
334
|
let first;
|
|
335
|
+
let firstMs = 0;
|
|
216
336
|
if (firstCooling) {
|
|
217
337
|
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 跳过(冷却中)· 直接试 ${CHAT_FALLBACK}\x1b[0m`);
|
|
218
338
|
first = { ok: false, error: "skip cooling", status: 429 };
|
|
219
339
|
} else {
|
|
340
|
+
const t = performance.now();
|
|
220
341
|
first = await safeChatOnce(opts, CHAT_PREFERRED);
|
|
342
|
+
firstMs = Math.round(performance.now() - t);
|
|
343
|
+
if (TRACE) {
|
|
344
|
+
const dt = Math.round(performance.now() - t0);
|
|
345
|
+
console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} ${first.ok ? "OK" : "FAIL"} · ${dt}ms${first.ok ? "" : ` · ${String(first.error).slice(0, 80)}`}\x1b[0m`);
|
|
346
|
+
}
|
|
347
|
+
if (first.ok) {
|
|
348
|
+
await recordChatOk(CHAT_PREFERRED, firstMs);
|
|
349
|
+
return { ...first, model: CHAT_PREFERRED };
|
|
350
|
+
} else {
|
|
351
|
+
const slow = firstMs > 20000;
|
|
352
|
+
await recordChatError(CHAT_PREFERRED, first.status, { slow, latencyMs: firstMs });
|
|
353
|
+
}
|
|
221
354
|
}
|
|
222
|
-
if (
|
|
223
|
-
|
|
224
|
-
|
|
355
|
+
if (firstCooling) {
|
|
356
|
+
// 已跳过,无需重复日志
|
|
357
|
+
} else if (TRACE) {
|
|
358
|
+
// fail 日志已在上面
|
|
225
359
|
}
|
|
226
|
-
if (first.ok) return { ...first, model: CHAT_PREFERRED };
|
|
227
360
|
const t1 = TRACE ? performance.now() : 0;
|
|
361
|
+
// 按用户要求:mimo 一旦 429,10min 内第二次直接走 gateway,跳过 big-pickle
|
|
362
|
+
if (firstCooling) {
|
|
363
|
+
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 冷却中(${CHAT_COOLDOWN_MS/60000}min)· 直接走网关 auto,跳过 ${CHAT_FALLBACK}\x1b[0m`);
|
|
364
|
+
const t2 = TRACE ? performance.now() : 0;
|
|
365
|
+
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 冷却,直接走网关 auto(:8989)\x1b[0m`);
|
|
366
|
+
const third = await chatViaGateway(opts);
|
|
367
|
+
if (TRACE) {
|
|
368
|
+
const dt = Math.round(performance.now() - t2);
|
|
369
|
+
const total = Math.round(performance.now() - t0);
|
|
370
|
+
console.log(`\x1b[90m· [LLM] gateway auto ${third.ok ? "OK" : "FAIL"} · ${dt}ms · 总 ${total}ms (gateway-fallback)\x1b[0m`);
|
|
371
|
+
}
|
|
372
|
+
if (third.ok) return { ...third, model: third.model || "auto", fallbackGateway: true, fallback: true, firstError: first.error, secondError: "skip big-pickle (mimo cooling)", viaGateway: true };
|
|
373
|
+
return { ok: false, error: `${CHAT_PREFERRED} cooling: ${first.error}; gateway auto failed: ${third.error}`, status: third.status || 429 };
|
|
374
|
+
}
|
|
228
375
|
const secondCooling = await isCoolingAsync(CHAT_FALLBACK);
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
376
|
+
// 800ms 对冲:big-pickle 与 gateway 并发,谁快谁赢
|
|
377
|
+
// 若 big-pickle 冷却则只走 gateway;否则两者并行 800ms staggered
|
|
378
|
+
const doGateway = () => chatViaGateway(opts);
|
|
379
|
+
const doSecond = async () => {
|
|
380
|
+
const t = performance.now();
|
|
381
|
+
const r = await safeChatOnce(opts, CHAT_FALLBACK);
|
|
382
|
+
const ms = Math.round(performance.now() - t);
|
|
383
|
+
if (r.ok) await recordChatOk(CHAT_FALLBACK, ms);
|
|
384
|
+
else await recordChatError(CHAT_FALLBACK, r.status, { slow: ms > 20000, latencyMs: ms });
|
|
385
|
+
return { res: r, ms };
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
if (secondCooling) {
|
|
234
389
|
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_FALLBACK} 跳过(冷却中)· 直接走网关 auto\x1b[0m`);
|
|
235
|
-
|
|
390
|
+
const t2 = performance.now();
|
|
391
|
+
const third = await doGateway();
|
|
392
|
+
if (TRACE) {
|
|
393
|
+
const dt = Math.round(performance.now() - t2);
|
|
394
|
+
const total = Math.round(performance.now() - t0);
|
|
395
|
+
console.log(`\x1b[90m· [LLM] gateway auto ${third.ok ? "OK" : "FAIL"} · ${dt}ms · 总 ${total}ms (gateway-fallback)\x1b[0m`);
|
|
396
|
+
}
|
|
397
|
+
if (third.ok) return { ...third, model: third.model || "auto", fallbackGateway: true, fallback: true, firstError: first.error, secondError: "skip cooling", viaGateway: true };
|
|
398
|
+
return { ok: false, error: `${CHAT_PREFERRED} failed: ${first.error}; ${CHAT_FALLBACK} failed: skip cooling; gateway auto failed: ${third.error}`, status: third.status || first.status };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// 两者皆需尝试:并行对冲(800ms staggered),首个 OK 即胜
|
|
402
|
+
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 失败,${CHAT_FALLBACK} + gateway 对冲中(${HEDGE_MS}ms)\x1b[0m`);
|
|
403
|
+
const hedgeStart = performance.now();
|
|
404
|
+
let secondRes = null;
|
|
405
|
+
let secondMs = 0;
|
|
406
|
+
let gatewayRes = null;
|
|
407
|
+
|
|
408
|
+
const secondPromise = doSecond().then(({ res, ms }) => {
|
|
409
|
+
secondRes = res; secondMs = ms;
|
|
410
|
+
if (TRACE) {
|
|
411
|
+
const dt = Math.round(performance.now() - t1);
|
|
412
|
+
console.log(`\x1b[90m· [LLM] ${CHAT_FALLBACK} ${res.ok ? "OK" : "FAIL"} · ${dt}ms · 总 ${Math.round(performance.now() - t0)}ms (hedge)\x1b[0m`);
|
|
413
|
+
}
|
|
414
|
+
return res;
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// gateway 800ms 后启动,对冲慢链路
|
|
418
|
+
let gatewayPromise = null;
|
|
419
|
+
const gatewayDelay = HEDGE_MS > 0 ? HEDGE_MS : 0;
|
|
420
|
+
if (gatewayDelay > 0) {
|
|
421
|
+
gatewayPromise = new Promise((resolve) => {
|
|
422
|
+
setTimeout(async () => {
|
|
423
|
+
const r = await doGateway();
|
|
424
|
+
gatewayRes = r;
|
|
425
|
+
resolve(r);
|
|
426
|
+
}, gatewayDelay);
|
|
427
|
+
});
|
|
428
|
+
// 同时也准备一个立即启动的 fallback 若 second 极快失败,则不等待 delay(用 raceFirstOk 覆盖)
|
|
429
|
+
// 为保证“谁快用谁”,实际让 gateway 立即也准备好,若 second 800ms 内未回,gateway 已在路上
|
|
236
430
|
} else {
|
|
237
|
-
|
|
431
|
+
gatewayPromise = doGateway().then((r) => { gatewayRes = r; return r; });
|
|
432
|
+
}
|
|
433
|
+
// 为了不让 gatewayDelay 成为必经等待,采用并发 race 策略:
|
|
434
|
+
// 若 secondPromise 在 hedgeDelay 内成功,则直接返回;否则等待 gateway
|
|
435
|
+
const raceFirstOk = async () => {
|
|
436
|
+
// 等 second 或 timeout
|
|
437
|
+
const secondOrTimeout = await Promise.race([
|
|
438
|
+
secondPromise.then((r) => ({ kind: "second", r })),
|
|
439
|
+
new Promise((resolve) => setTimeout(() => resolve({ kind: "timeout" }), gatewayDelay)),
|
|
440
|
+
]);
|
|
441
|
+
if (secondOrTimeout.kind === "second" && secondOrTimeout.r?.ok) {
|
|
442
|
+
// second 成功,直接赢
|
|
443
|
+
// 取消后续 gateway(无需等待)
|
|
444
|
+
return { ok: true, res: secondOrTimeout.r, model: CHAT_FALLBACK, fallback: true, firstError: first.error };
|
|
445
|
+
}
|
|
446
|
+
// second 失败或超时 → 等 gateway
|
|
447
|
+
// 确保 gateway 已启动:若之前延迟启动,立即再启动一个即时 gateway 并 race
|
|
448
|
+
if (!gatewayPromise || secondOrTimeout.kind === "timeout") {
|
|
449
|
+
// 若 gateway 仍在延迟窗口,提前触发
|
|
450
|
+
const immediate = doGateway().then((r) => { gatewayRes = r; return r; });
|
|
451
|
+
if (gatewayPromise) {
|
|
452
|
+
// 有延迟版本,race 即时 vs 延迟
|
|
453
|
+
gatewayRes = await Promise.race([gatewayPromise, immediate]);
|
|
454
|
+
} else {
|
|
455
|
+
gatewayRes = await immediate;
|
|
456
|
+
}
|
|
457
|
+
} else {
|
|
458
|
+
// second 已失败但 gatewayDelay 已过,等待 gateway
|
|
459
|
+
gatewayRes = await gatewayPromise;
|
|
460
|
+
}
|
|
461
|
+
if (gatewayRes?.ok) return { ok: true, res: gatewayRes, model: gatewayRes.model || "auto", fallbackGateway: true, fallback: true, firstError: first.error, secondError: secondRes?.error, viaGateway: true };
|
|
462
|
+
// 两者皆败
|
|
463
|
+
if (secondRes?.ok) return { ok: true, res: secondRes, model: CHAT_FALLBACK, fallback: true, firstError: first.error };
|
|
464
|
+
return { ok: false, gatewayRes, secondRes };
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
// 若 gatewayDelay 0,则直接双并发
|
|
468
|
+
if (gatewayDelay === 0) {
|
|
469
|
+
const [sRes, gRes] = await Promise.all([secondPromise.catch((e) => ({ ok: false, error: String(e), status: 502 })), doGateway().catch((e) => ({ ok: false, error: String(e), status: 502 }))]);
|
|
470
|
+
secondRes = sRes; gatewayRes = gRes;
|
|
471
|
+
if (sRes?.ok) return { ...sRes, model: CHAT_FALLBACK, fallback: true, firstError: first.error };
|
|
472
|
+
if (gRes?.ok) return { ...gRes, model: gRes.model || "auto", fallbackGateway: true, fallback: true, firstError: first.error, secondError: sRes?.error, viaGateway: true };
|
|
473
|
+
return { ok: false, error: `${CHAT_PREFERRED} failed: ${first.error}; ${CHAT_FALLBACK} failed: ${sRes?.error}; gateway auto failed: ${gRes?.error}`, status: gRes?.status || sRes?.status || first.status };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const raced = await raceFirstOk();
|
|
477
|
+
if (raced.ok) {
|
|
478
|
+
const r = raced.res;
|
|
479
|
+
return { ...r, model: raced.model, fallback: raced.fallback, fallbackGateway: raced.fallbackGateway, firstError: raced.firstError, secondError: raced.secondError, viaGateway: raced.viaGateway };
|
|
238
480
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
console.log(`\x1b[90m· [LLM] ${CHAT_FALLBACK} ${second.ok ? "OK" : "FAIL"} · ${dt}ms · 总 ${total}ms (fallback)\x1b[0m`);
|
|
481
|
+
// 双失败兜底:若 race 未决出 OK,取最终结果
|
|
482
|
+
if (!secondRes) {
|
|
483
|
+
try { secondRes = await secondPromise; } catch (e) { secondRes = { ok: false, error: String(e), status: 502 }; }
|
|
243
484
|
}
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} + ${CHAT_FALLBACK} 均失败,尝试本地网关 auto(:8989)\x1b[0m`);
|
|
248
|
-
const third = await chatViaGateway(opts);
|
|
249
|
-
if (TRACE) {
|
|
250
|
-
const dt = Math.round(performance.now() - t2);
|
|
251
|
-
const total = Math.round(performance.now() - t0);
|
|
252
|
-
console.log(`\x1b[90m· [LLM] gateway auto ${third.ok ? "OK" : "FAIL"} · ${dt}ms · 总 ${total}ms (gateway-fallback)\x1b[0m`);
|
|
485
|
+
if (secondRes?.ok) return { ...secondRes, model: CHAT_FALLBACK, fallback: true, firstError: first.error };
|
|
486
|
+
if (!gatewayRes) {
|
|
487
|
+
try { gatewayRes = await (gatewayPromise || doGateway()); } catch (e) { gatewayRes = { ok: false, error: String(e), status: 502 }; }
|
|
253
488
|
}
|
|
254
|
-
if (
|
|
255
|
-
return { ok: false, error: `${CHAT_PREFERRED} failed: ${first.error}; ${CHAT_FALLBACK} failed: ${
|
|
489
|
+
if (gatewayRes?.ok) return { ...gatewayRes, model: gatewayRes.model || "auto", fallbackGateway: true, fallback: true, firstError: first.error, secondError: secondRes?.error, viaGateway: true };
|
|
490
|
+
return { ok: false, error: `${CHAT_PREFERRED} failed: ${first.error}; ${CHAT_FALLBACK} failed: ${secondRes?.error}; gateway auto failed: ${gatewayRes?.error}`, status: gatewayRes?.status || secondRes?.status || first.status };
|
|
256
491
|
}
|
|
257
492
|
|
|
258
493
|
// 压缩用:简短摘要请求(不带 tools),128k 上下文下仅 95% 触发,需完整摘要
|
package/src/models.js
CHANGED
|
@@ -30,8 +30,11 @@ export function createModelsService({ baseUrl, headers, ttlMs = CACHE_TTL_MS, re
|
|
|
30
30
|
let lastAllowlistKey = "";
|
|
31
31
|
async function currentAllowlistKey() {
|
|
32
32
|
try {
|
|
33
|
-
const { loadProviderAllowedModels } = await import("./state.js");
|
|
34
|
-
return providers.map((p) =>
|
|
33
|
+
const { loadProviderAllowedModels, loadProviderAllowAnyModels } = await import("./state.js");
|
|
34
|
+
return providers.map((p) => {
|
|
35
|
+
const allowAny = loadProviderAllowAnyModels(p.id) ? "any" : "block";
|
|
36
|
+
return `${p.id}:${allowAny}:${loadProviderAllowedModels(p.id).join(",")}`;
|
|
37
|
+
}).join("|");
|
|
35
38
|
} catch {
|
|
36
39
|
return "";
|
|
37
40
|
}
|
|
@@ -41,10 +44,15 @@ export function createModelsService({ baseUrl, headers, ttlMs = CACHE_TTL_MS, re
|
|
|
41
44
|
for (const p of providers) {
|
|
42
45
|
try {
|
|
43
46
|
let list = (await p.listModels?.()) ?? [];
|
|
44
|
-
//
|
|
47
|
+
// 白名单过滤:空名单且 allowAny=false → 该供应商不暴露任何模型(安全默认全拦,auto 直接跳过该供应商)
|
|
45
48
|
try {
|
|
46
|
-
const { loadProviderAllowedModels } = await import("./state.js");
|
|
49
|
+
const { loadProviderAllowedModels, loadProviderAllowAnyModels } = await import("./state.js");
|
|
47
50
|
const allowed = loadProviderAllowedModels(p.id);
|
|
51
|
+
const allowAny = loadProviderAllowAnyModels(p.id);
|
|
52
|
+
if (!allowed.length && !allowAny) {
|
|
53
|
+
// 该供应商被全拦,auto 直接跳过整个供应商
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
48
56
|
if (allowed.length) {
|
|
49
57
|
const allowedSet = new Set(allowed);
|
|
50
58
|
const { splitModelId } = await import("./providers/model-id.js");
|
|
@@ -81,12 +89,14 @@ export function createModelsService({ baseUrl, headers, ttlMs = CACHE_TTL_MS, re
|
|
|
81
89
|
} catch {}
|
|
82
90
|
// 否则尝试在缓存上二次过滤(处理 allowlist 从空变非空等未触发重载的场景)
|
|
83
91
|
try {
|
|
84
|
-
const { loadProviderAllowedModels } = await import("./state.js");
|
|
92
|
+
const { loadProviderAllowedModels, loadProviderAllowAnyModels } = await import("./state.js");
|
|
85
93
|
const { splitModelId } = await import("./providers/model-id.js");
|
|
86
94
|
const filteredData = aggregate.data.filter((m) => {
|
|
87
95
|
if (!m || !m.id) return false;
|
|
88
96
|
const { provider, raw } = splitModelId(m.id, providers.map((x) => x.id));
|
|
89
97
|
const allowed = loadProviderAllowedModels(provider);
|
|
98
|
+
const allowAny = loadProviderAllowAnyModels(provider);
|
|
99
|
+
if (!allowed.length && !allowAny) return false;
|
|
90
100
|
if (!allowed.length) return true;
|
|
91
101
|
return allowed.includes(String(raw || "").trim());
|
|
92
102
|
});
|
package/src/providers/keyring.js
CHANGED
|
@@ -30,9 +30,27 @@ export function createKeyRing(keys = [], { cooldownMs = DEFAULT_COOLDOWN_MS, now
|
|
|
30
30
|
if (list.includes(key)) errAt.set(key, now());
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
function replace(oldKey, newKey) {
|
|
34
|
+
const idx = list.indexOf(oldKey);
|
|
35
|
+
if (idx < 0) {
|
|
36
|
+
if (newKey && !list.includes(newKey)) list.push(newKey);
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
if (newKey && oldKey !== newKey) {
|
|
40
|
+
list[idx] = newKey;
|
|
41
|
+
// 迁移冷却状态:旧 key 的冷却移到新 key,避免新 token 立即被误判可用
|
|
42
|
+
if (errAt.has(oldKey)) {
|
|
43
|
+
const t = errAt.get(oldKey);
|
|
44
|
+
errAt.delete(oldKey);
|
|
45
|
+
// 新 token 刚刷新,不应继承旧冷却,直接清掉
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
33
51
|
function available() {
|
|
34
52
|
return list.filter((k) => !isCooling(k)).length;
|
|
35
53
|
}
|
|
36
54
|
|
|
37
|
-
return { next, onError, available, size: list.length, cooldownMs, keys: [...list] };
|
|
55
|
+
return { next, onError, replace, available, size: list.length, cooldownMs, keys: [...list] };
|
|
38
56
|
}
|
|
@@ -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 });
|