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
package/src/chat/upstream.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { performance } from "node:perf_hooks";
|
|
2
|
-
import { CHAT_PREFERRED, CHAT_FALLBACK, CHAT_TIMEOUT_MS } from "./config.js";
|
|
2
|
+
import { CHAT_PREFERRED, CHAT_FALLBACK, CHAT_TIMEOUT_MS, CHAT_GATEWAY_TIMEOUT_MS } from "./config.js";
|
|
3
3
|
import { createUpstreamClient } from "../upstream.js";
|
|
4
|
+
import { DEFAULT_PORT } from "../state.js";
|
|
4
5
|
|
|
5
6
|
function modelForAttempt(attempt) {
|
|
6
7
|
return attempt === 0 ? CHAT_PREFERRED : CHAT_FALLBACK;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
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";
|
|
10
15
|
const client = createUpstreamClient({ connectTimeoutMs: CHAT_TIMEOUT_MS, keepAlive: false, fetchImpl: globalThis.fetch });
|
|
11
16
|
const body = {
|
|
12
17
|
model: model || CHAT_PREFERRED,
|
|
@@ -38,10 +43,17 @@ export async function chatOnce({ messages, tools, model }) {
|
|
|
38
43
|
return { ok: true, message: choice.message, usage: j.usage, raw: j, status: res.status };
|
|
39
44
|
} finally {
|
|
40
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
|
+
}
|
|
41
50
|
}
|
|
42
51
|
}
|
|
43
52
|
|
|
44
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";
|
|
45
57
|
const client = createUpstreamClient({ connectTimeoutMs: CHAT_TIMEOUT_MS, keepAlive: false, fetchImpl: globalThis.fetch });
|
|
46
58
|
const body = { model: model || CHAT_PREFERRED, messages, stream: false };
|
|
47
59
|
try {
|
|
@@ -53,28 +65,429 @@ async function chatOnceNoTools({ messages, model }) {
|
|
|
53
65
|
const choice = j.choices?.[0];
|
|
54
66
|
if (!choice) return { ok: false, error: "no choice", status: res.status };
|
|
55
67
|
return { ok: true, message: choice.message, usage: j.usage, raw: j, status: res.status };
|
|
56
|
-
} 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
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 带自动降级:mimo-v2.5-free → big-pickle → 本地 8989 auto(网关 auto,含多供应商择优/hedge/peer)
|
|
78
|
+
async function chatViaGateway({ messages, tools }) {
|
|
79
|
+
const TRACE = process.env.MSLXDFF_CHAT_TRACE !== "0";
|
|
80
|
+
const t0 = TRACE ? performance.now() : 0;
|
|
81
|
+
let port = DEFAULT_PORT;
|
|
82
|
+
let token = "";
|
|
83
|
+
try {
|
|
84
|
+
const state = await import("../state.js");
|
|
85
|
+
const loaded = await state.loadToken();
|
|
86
|
+
token = String(loaded?.token || "").trim();
|
|
87
|
+
const p = state.getPort();
|
|
88
|
+
if (Number.isInteger(p) && p > 0) port = p;
|
|
89
|
+
else if (Number.isInteger(Number(process.env.MSLXDFF_PORT)) && Number(process.env.MSLXDFF_PORT) > 0) port = Number(process.env.MSLXDFF_PORT);
|
|
90
|
+
} catch {}
|
|
91
|
+
// token 为空则尝试直接读 state 文件兜底(避免 loadToken 异常时无 token)
|
|
92
|
+
if (!token) {
|
|
93
|
+
try {
|
|
94
|
+
const { readFileSync, existsSync } = await import("node:fs");
|
|
95
|
+
const { join } = await import("node:path");
|
|
96
|
+
const { homedir } = await import("node:os");
|
|
97
|
+
const sf = process.env.MSLXDFF_STATE_FILE || join(homedir(), ".config", "mslxdff", "state.json");
|
|
98
|
+
if (existsSync(sf)) {
|
|
99
|
+
const j = JSON.parse(readFileSync(sf, "utf8"));
|
|
100
|
+
if (typeof j.token === "string" && j.token.trim()) token = j.token.trim();
|
|
101
|
+
}
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
const url = `http://127.0.0.1:${port}/v1/chat/completions`;
|
|
105
|
+
const body = { model: "auto", messages, stream: false };
|
|
106
|
+
if (tools?.length) {
|
|
107
|
+
body.tools = tools;
|
|
108
|
+
body.tool_choice = "auto";
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const controller = new AbortController();
|
|
112
|
+
const timer = setTimeout(() => controller.abort(), CHAT_GATEWAY_TIMEOUT_MS);
|
|
113
|
+
const res = await fetch(url, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
116
|
+
body: JSON.stringify(body),
|
|
117
|
+
signal: controller.signal,
|
|
118
|
+
});
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
const txt = await res.text();
|
|
121
|
+
let j;
|
|
122
|
+
try { j = JSON.parse(txt); } catch {
|
|
123
|
+
// 网关可能因 workbuddy 强制 stream:true 而返回 SSE(text/event-stream),需兼容
|
|
124
|
+
if (txt.includes("data:")) {
|
|
125
|
+
try {
|
|
126
|
+
const lines = txt.split(/\r?\n/);
|
|
127
|
+
let content = "";
|
|
128
|
+
let toolCallsMap = new Map();
|
|
129
|
+
let model = "auto";
|
|
130
|
+
let usage = null;
|
|
131
|
+
let finishReason = "stop";
|
|
132
|
+
let sseOk = false;
|
|
133
|
+
for (const line of lines) {
|
|
134
|
+
const t = String(line).trim();
|
|
135
|
+
if (!t.startsWith("data:")) continue;
|
|
136
|
+
const payload = t.slice(5).trim();
|
|
137
|
+
if (!payload || payload === "[DONE]") continue;
|
|
138
|
+
try {
|
|
139
|
+
const obj = JSON.parse(payload);
|
|
140
|
+
sseOk = true;
|
|
141
|
+
const ch = obj.choices?.[0];
|
|
142
|
+
if (ch?.finish_reason) finishReason = ch.finish_reason;
|
|
143
|
+
// content / reasoning_content
|
|
144
|
+
if (ch?.delta?.content) content += ch.delta.content;
|
|
145
|
+
else if (ch?.delta?.reasoning_content) content += ch.delta.reasoning_content;
|
|
146
|
+
else if (ch?.message?.content) content += ch.message.content;
|
|
147
|
+
else if (ch?.message?.reasoning_content) content += ch.message.reasoning_content;
|
|
148
|
+
else if (typeof ch?.text === "string") content += ch.text;
|
|
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
|
+
}
|
|
167
|
+
if (obj.model) model = obj.model;
|
|
168
|
+
if (obj.usage) usage = obj.usage;
|
|
169
|
+
if (obj.choices?.[0]?.message?.content && !content) content = obj.choices[0].message.content;
|
|
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
|
+
}
|
|
174
|
+
} catch {}
|
|
175
|
+
}
|
|
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 };
|
|
185
|
+
} else if (sseOk) {
|
|
186
|
+
return { ok: false, error: `gateway SSE no content: ${txt.slice(0, 800)}`, status: res.status };
|
|
187
|
+
} else {
|
|
188
|
+
return { ok: false, error: `gateway non-json: ${txt.slice(0, 800)}`, status: res.status };
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
return { ok: false, error: `gateway non-json: ${txt.slice(0, 800)}`, status: res.status };
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
return { ok: false, error: `gateway non-json: ${txt.slice(0, 800)}`, status: res.status };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (!res.ok) {
|
|
198
|
+
const msg = j?.error?.message || j?.error || j?.data?.error?.message || j?.data?.error || txt.slice(0, 800);
|
|
199
|
+
if (TRACE) {
|
|
200
|
+
const dt = Math.round(performance.now() - t0);
|
|
201
|
+
console.log(`\x1b[90m· [LLM] gateway auto FAIL · ${dt}ms · HTTP ${res.status} ${String(msg).slice(0, 80)}\x1b[0m`);
|
|
202
|
+
}
|
|
203
|
+
return { ok: false, error: msg, status: res.status };
|
|
204
|
+
}
|
|
205
|
+
// 兼容网关返回的两种形状:标准 {"choices":...} 与 workbuddy 聚合后的 {"data":{"choices":...}}
|
|
206
|
+
const choice = j.choices?.[0] || j.data?.choices?.[0];
|
|
207
|
+
const effectiveJ = j.choices ? j : (j.data?.choices ? j.data : j);
|
|
208
|
+
if (!choice) {
|
|
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`);
|
|
210
|
+
return { ok: false, error: `gateway no choice: ${txt.slice(0, 800)}`, status: res.status };
|
|
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 {}
|
|
238
|
+
if (TRACE) {
|
|
239
|
+
const dt = Math.round(performance.now() - t0);
|
|
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`);
|
|
242
|
+
}
|
|
243
|
+
// 透传 usage/raw,并标记 gateway(兼容 data 包装)
|
|
244
|
+
return { ok: true, message: choice.message, usage: effectiveJ.usage || j.usage, raw: j, status: res.status, model: rawModel, provider, viaGateway: true };
|
|
245
|
+
} catch (err) {
|
|
246
|
+
const msg = String(err?.message || err).slice(0, 800);
|
|
247
|
+
if (TRACE) {
|
|
248
|
+
const dt = Math.round(performance.now() - t0);
|
|
249
|
+
console.log(`\x1b[90m· [LLM] gateway auto FAIL · ${dt}ms · ${msg.slice(0, 80)}\x1b[0m`);
|
|
250
|
+
}
|
|
251
|
+
return { ok: false, error: `gateway ${msg}`, status: 502 };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 带自动降级:mimo-v2.5-free → big-pickle → 本地 8989 auto(网关 auto,含多供应商择优/hedge/peer)
|
|
256
|
+
async function safeChatOnce(opts, model) {
|
|
257
|
+
try {
|
|
258
|
+
const r = await chatOnce({ ...opts, model });
|
|
259
|
+
return r;
|
|
260
|
+
} catch (err) {
|
|
261
|
+
const msg = String(err?.message || err).slice(0, 800);
|
|
262
|
+
const status = err?._t ? 502 : (err?.cause?.code ? 502 : 502);
|
|
263
|
+
return { ok: false, error: msg, status, _thrown: err };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const CHAT_COOLDOWN_MS = 10 * 60 * 1000; // mimo 429 后至少 10min 不再试直连
|
|
268
|
+
const CHAT_SLOW_COOLDOWN_MS = 10 * 60 * 1000;
|
|
269
|
+
|
|
270
|
+
async function isCoolingAsync(id) {
|
|
271
|
+
try {
|
|
272
|
+
const state = await import("../state.js");
|
|
273
|
+
const errors = state.loadModelErrors();
|
|
274
|
+
const e = errors[id];
|
|
275
|
+
if (!e || typeof e !== "object") return false;
|
|
276
|
+
const at = Number(e.at || 0);
|
|
277
|
+
if (!at) return false;
|
|
278
|
+
const isSlow = !!e.slow;
|
|
279
|
+
const cd = isSlow ? CHAT_SLOW_COOLDOWN_MS : CHAT_COOLDOWN_MS;
|
|
280
|
+
return Date.now() - at < cd && (e.status === "limit" || e.status === "error");
|
|
281
|
+
} catch { return false; }
|
|
282
|
+
}
|
|
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 {}
|
|
57
323
|
}
|
|
58
324
|
|
|
59
|
-
// 带自动降级:仅 mimo-v2.5-free → big-pickle,不引入其他模型
|
|
60
325
|
export async function chatWithFallback(opts) {
|
|
61
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
|
+
})();
|
|
62
331
|
const t0 = TRACE ? performance.now() : 0;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
332
|
+
// 若上次已确认冷却(429/limit),直接跳过,避免 7+7 秒白等,第二次直接走“上次成功”的网关
|
|
333
|
+
const firstCooling = await isCoolingAsync(CHAT_PREFERRED);
|
|
334
|
+
let first;
|
|
335
|
+
let firstMs = 0;
|
|
336
|
+
if (firstCooling) {
|
|
337
|
+
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 跳过(冷却中)· 直接试 ${CHAT_FALLBACK}\x1b[0m`);
|
|
338
|
+
first = { ok: false, error: "skip cooling", status: 429 };
|
|
339
|
+
} else {
|
|
340
|
+
const t = performance.now();
|
|
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
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (firstCooling) {
|
|
356
|
+
// 已跳过,无需重复日志
|
|
357
|
+
} else if (TRACE) {
|
|
358
|
+
// fail 日志已在上面
|
|
67
359
|
}
|
|
68
|
-
if (first.ok) return { ...first, model: CHAT_PREFERRED };
|
|
69
360
|
const t1 = TRACE ? performance.now() : 0;
|
|
70
|
-
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
console.log(`\x1b[90m· [LLM] ${
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
+
}
|
|
375
|
+
const secondCooling = await isCoolingAsync(CHAT_FALLBACK);
|
|
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) {
|
|
389
|
+
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_FALLBACK} 跳过(冷却中)· 直接走网关 auto\x1b[0m`);
|
|
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 已在路上
|
|
430
|
+
} else {
|
|
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 };
|
|
480
|
+
}
|
|
481
|
+
// 双失败兜底:若 race 未决出 OK,取最终结果
|
|
482
|
+
if (!secondRes) {
|
|
483
|
+
try { secondRes = await secondPromise; } catch (e) { secondRes = { ok: false, error: String(e), status: 502 }; }
|
|
484
|
+
}
|
|
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 }; }
|
|
488
|
+
}
|
|
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 };
|
|
78
491
|
}
|
|
79
492
|
|
|
80
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
|
}
|