mslxdff 0.1.64 → 0.1.66

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.
Files changed (47) hide show
  1. package/bin/mslxdff.js +3 -3122
  2. package/package.json +1 -1
  3. package/src/chat/prompt.js +7 -2
  4. package/src/chat/repl.js +8 -4
  5. package/src/chat/upstream.js +271 -36
  6. package/src/cli/bootstrap.js +408 -0
  7. package/src/cli/commands/daemon.js +151 -0
  8. package/src/cli/commands/group.js +272 -0
  9. package/src/cli/commands/model.js +405 -0
  10. package/src/cli/commands/provider/add.js +106 -0
  11. package/src/cli/commands/provider/allowlist.js +99 -0
  12. package/src/cli/commands/provider/config.js +82 -0
  13. package/src/cli/commands/provider/index.js +193 -0
  14. package/src/cli/commands/provider/keys.js +135 -0
  15. package/src/cli/commands/provider/models.js +99 -0
  16. package/src/cli/commands/provider.js +1 -0
  17. package/src/cli/commands/sync.js +143 -0
  18. package/src/cli/commands/system.js +236 -0
  19. package/src/cli/commands/workbuddy.js +91 -0
  20. package/src/cli/format.js +119 -0
  21. package/src/cli/group-helpers.js +69 -0
  22. package/src/cli/help.js +66 -0
  23. package/src/cli/index.js +59 -0
  24. package/src/cli/interactive.js +84 -0
  25. package/src/cli/policy.js +119 -0
  26. package/src/cli/status.js +332 -0
  27. package/src/cli/util.js +24 -0
  28. package/src/models.js +15 -5
  29. package/src/providers/base.js +159 -0
  30. package/src/providers/generic.js +26 -166
  31. package/src/providers/keyring.js +19 -1
  32. package/src/providers/openrouter.js +32 -162
  33. package/src/providers/workbuddy.js +200 -131
  34. package/src/routes/chat/gateway.js +301 -0
  35. package/src/routes/chat/index.js +1 -239
  36. package/src/state/facade.js +57 -0
  37. package/src/state/memory.js +180 -0
  38. package/src/state/persist.js +42 -0
  39. package/src/state/schemas/allowlist.js +87 -0
  40. package/src/state/schemas/group.js +31 -0
  41. package/src/state/schemas/model.js +53 -0
  42. package/src/state/schemas/peer.js +31 -0
  43. package/src/state/schemas/port.js +10 -0
  44. package/src/state/schemas/provider.js +254 -0
  45. package/src/state/schemas/token.js +43 -0
  46. package/src/state/store.js +202 -0
  47. package/src/state.js +1 -711
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.64",
3
+ "version": "0.1.66",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 modelLabel = model ? (fallbackGateway || viaGateway ? `${model} (gateway auto)` : model) : "";
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 ?? "?"}` : "";
@@ -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 { try { await client.close(); } catch {} }
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
- // 兼容 thinking 模型的 reasoning_content(delta 阶段 content 为空,实际在 reasoning_content)
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
- if (sseOk && content) {
138
- j = { id: `sse-${Date.now()}`, object: "chat.completion", model, choices: [{ index: 0, finish_reason: "stop", message: { role: "assistant", content } }], usage };
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 = effectiveJ.model || j.model || choice.message?.model || "auto";
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: effectiveJ.model || j.model || "auto", viaGateway: true };
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 ? 5 * 60 * 1000 : 60 * 1000;
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 (TRACE) {
223
- const dt = Math.round(performance.now() - t0);
224
- console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} ${first.ok ? "OK" : "FAIL"} · ${dt}ms${first.ok ? "" : ` · ${String(first.error).slice(0, 80)}`}\x1b[0m`);
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
- let second;
230
- if (secondCooling && firstCooling) {
231
- if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_FALLBACK} 跳过(冷却中)· 直接走网关 auto\x1b[0m`);
232
- second = { ok: false, error: "skip cooling", status: 429 };
233
- } else if (secondCooling) {
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
- second = { ok: false, error: "skip cooling", status: 429 };
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
- second = await safeChatOnce(opts, CHAT_FALLBACK);
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
- if (TRACE) {
240
- const dt = Math.round(performance.now() - t1);
241
- const total = Math.round(performance.now() - t0);
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 (second.ok) return { ...second, model: CHAT_FALLBACK, fallback: true, firstError: first.error };
245
- // 两者皆失败 → 兜底到本地网关 auto(会走 auto 择优、hedge、peer 等完整链路)
246
- const t2 = TRACE ? performance.now() : 0;
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 (third.ok) return { ...third, model: third.model || "auto", fallbackGateway: true, fallback: true, firstError: first.error, secondError: second.error, viaGateway: true };
255
- return { ok: false, error: `${CHAT_PREFERRED} failed: ${first.error}; ${CHAT_FALLBACK} failed: ${second.error}; gateway auto failed: ${third.error}`, status: third.status || second.status || first.status };
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% 触发,需完整摘要