mslxdff 0.1.66 → 0.1.67

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.66",
3
+ "version": "0.1.67",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,99 @@
1
+ /**
2
+ * 冷却深模块:对 state 的 10min 冷却 + EMA 延迟做唯一封装
3
+ * 注入化便于单测(内存 Map + now)
4
+ */
5
+
6
+ export function createCooling({
7
+ loadModelErrors,
8
+ saveModelErrors,
9
+ loadModelLatencies,
10
+ saveModelLatencies,
11
+ flush,
12
+ now = () => Date.now(),
13
+ cooldownMs = 10 * 60 * 1000,
14
+ slowCooldownMs = 10 * 60 * 1000,
15
+ } = {}) {
16
+ const _loadErrors = loadModelErrors || (() => ({}));
17
+ const _saveErrors = saveModelErrors || (() => {});
18
+ const _loadLats = loadModelLatencies || (() => ({}));
19
+ const _saveLats = saveModelLatencies || (() => {});
20
+ const _flush = flush || (() => {});
21
+
22
+ async function isCooling(id) {
23
+ try {
24
+ const errors = _loadErrors() || {};
25
+ const e = errors[id];
26
+ if (!e || typeof e !== "object") return false;
27
+ const at = Number(e.at || 0);
28
+ if (!at) return false;
29
+ const isSlow = !!e.slow;
30
+ const cd = isSlow ? slowCooldownMs : cooldownMs;
31
+ return now() - at < cd && (e.status === "limit" || e.status === "error");
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ async function recordError(id, status, { slow = false, latencyMs = 0 } = {}) {
38
+ try {
39
+ const errors = _loadErrors() || {};
40
+ const isLimit = Number(status) === 429 || String(status).includes("429");
41
+ const entryStatus = isLimit ? "limit" : "error";
42
+ errors[id] = { status: entryStatus, at: now(), code: Number.isInteger(Number(status)) ? Number(status) : null, slow: !!slow };
43
+ _saveErrors(errors);
44
+ try { _flush(); } catch {}
45
+ if (slow && Number.isFinite(latencyMs) && latencyMs > 0) {
46
+ try {
47
+ const lat = _loadLats() || {};
48
+ const prev = lat[id]?.emaMs;
49
+ const ema = prev ? Math.round(prev * 0.7 + latencyMs * 0.3) : Math.round(latencyMs);
50
+ lat[id] = { emaMs: ema, lastMs: Math.round(latencyMs), at: now(), count: (lat[id]?.count ?? 0) + 1 };
51
+ _saveLats(lat);
52
+ try { _flush(); } catch {}
53
+ } catch {}
54
+ }
55
+ } catch {}
56
+ }
57
+
58
+ async function recordOk(id, latencyMs) {
59
+ try {
60
+ const errors = _loadErrors() || {};
61
+ errors[id] = { status: "normal", at: now(), code: 200, slow: false };
62
+ _saveErrors(errors);
63
+ try { _flush(); } catch {}
64
+ if (Number.isFinite(latencyMs) && latencyMs > 0) {
65
+ const lat = _loadLats() || {};
66
+ const prev = lat[id]?.emaMs;
67
+ const ema = prev ? Math.round(prev * 0.7 + latencyMs * 0.3) : Math.round(latencyMs);
68
+ lat[id] = { emaMs: ema, lastMs: Math.round(latencyMs), at: now(), count: (lat[id]?.count ?? 0) + 1 };
69
+ _saveLats(lat);
70
+ try { _flush(); } catch {}
71
+ }
72
+ } catch {}
73
+ }
74
+
75
+ // 兼容旧命名
76
+ const recordChatError = recordError;
77
+ const recordChatOk = recordOk;
78
+ const isCoolingAsync = isCooling;
79
+
80
+ return { isCooling, isCoolingAsync, recordError, recordChatError, recordOk, recordChatOk };
81
+ }
82
+
83
+ // 默认实例(对接真实 state.js)
84
+ let _default = null;
85
+ export function getDefaultCooling() {
86
+ if (_default) return _default;
87
+ // 懒加载 state,避免循环
88
+ _default = createCooling({
89
+ loadModelErrors: () => {
90
+ try { const s = require("../state.js"); return s.loadModelErrors(); } catch { return {}; }
91
+ },
92
+ saveModelErrors: (o) => { try { const s = require("../state.js"); s.saveModelErrors(o); } catch {} },
93
+ loadModelLatencies: () => { try { const s = require("../state.js"); return s.loadModelLatencies(); } catch { return {}; } },
94
+ saveModelLatencies: (o) => { try { const s = require("../state.js"); s.saveModelLatencies(o); } catch {} },
95
+ flush: () => { try { const s = require("../state.js"); s.flushStateSync(); } catch {} },
96
+ now: () => Date.now(),
97
+ });
98
+ return _default;
99
+ }
@@ -0,0 +1,57 @@
1
+ import { performance } from "node:perf_hooks";
2
+
3
+ function isInput400(status, msg, hasTools) {
4
+ return status === 400 && /prompt|messages/i.test(String(msg || "")) && hasTools;
5
+ }
6
+
7
+ /**
8
+ * 直连深模块:mimo/pickle 经 createUpstreamClient 的 stream:false 调用
9
+ * 注入化:便于用 fake client 触发 400→去 tools 重试
10
+ */
11
+ export function createDirectClient({ createUpstreamClient, chatTimeoutMs = 15000, env = process.env, fetchImpl = globalThis.fetch } = {}) {
12
+ const _create = createUpstreamClient || (() => { throw new Error("createUpstreamClient not injected"); });
13
+
14
+ async function doChat({ messages, tools, model }, withoutTools) {
15
+ const prevAnon = env.MSLXDFF_FREE_ANON;
16
+ const needDisable = model === "mimo-v2.5-free" || model === "big-pickle";
17
+ if (needDisable) env.MSLXDFF_FREE_ANON = "0";
18
+ const client = _create({ connectTimeoutMs: chatTimeoutMs, keepAlive: false, fetchImpl });
19
+ const body = { model: model || "mimo-v2.5-free", messages, stream: false };
20
+ if (!withoutTools && tools?.length) {
21
+ body.tools = tools;
22
+ body.tool_choice = "auto";
23
+ }
24
+ try {
25
+ const res = await client.chat(body);
26
+ const txt = await res.text();
27
+ let j;
28
+ try { j = JSON.parse(txt); } catch { return { ok: false, error: `non-json upstream: ${txt.slice(0, 800)}`, status: res.status }; }
29
+ if (!res.ok) {
30
+ const msg = j?.error?.message || txt.slice(0, 800);
31
+ if (!withoutTools && isInput400(res.status, msg, !!tools?.length)) {
32
+ try { await client.close(); } catch {}
33
+ // 重试去 tools
34
+ const retry = await doChat({ messages, model }, true);
35
+ if (retry.ok) return { ...retry, retriedWithoutTools: true };
36
+ return { ok: false, error: msg, status: res.status, retried: retry.error };
37
+ }
38
+ return { ok: false, error: msg, status: res.status };
39
+ }
40
+ const choice = j.choices?.[0];
41
+ if (!choice) return { ok: false, error: "no choice", status: res.status };
42
+ return { ok: true, message: choice.message, usage: j.usage, raw: j, status: res.status };
43
+ } finally {
44
+ try { await client.close(); } catch {}
45
+ if (needDisable) {
46
+ if (prevAnon === undefined) delete env.MSLXDFF_FREE_ANON;
47
+ else env.MSLXDFF_FREE_ANON = prevAnon;
48
+ }
49
+ }
50
+ }
51
+
52
+ async function chatOnce(opts) {
53
+ return doChat(opts, false);
54
+ }
55
+
56
+ return { chatOnce, _doChat: doChat };
57
+ }
@@ -0,0 +1,148 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { parseSse } from "./sse.js";
3
+ import { DEFAULT_PORT } from "../state.js";
4
+
5
+ /**
6
+ * 网关深模块:POST 127.0.0.1:port/v1/chat/completions model:auto
7
+ * 注入化:fetch/loadToken/getPort/readModelsJson 均可伪,便于单测
8
+ */
9
+ export function createGatewayClient({
10
+ fetchImpl = globalThis.fetch,
11
+ loadToken,
12
+ getPort,
13
+ defaultPort = DEFAULT_PORT,
14
+ readModelsJson,
15
+ gatewayTimeoutMs = 25000,
16
+ env = process.env,
17
+ } = {}) {
18
+ const _fetch = fetchImpl;
19
+ const _loadToken = loadToken || (async () => {
20
+ try {
21
+ const state = await import("../state.js");
22
+ const loaded = await state.loadToken();
23
+ return String(loaded?.token || "").trim();
24
+ } catch { return ""; }
25
+ });
26
+ const _getPort = getPort || (() => {
27
+ try { const s = require("../state.js"); const p = s.getPort(); if (Number.isInteger(p) && p > 0) return p; } catch {}
28
+ const v = Number(env.MSLXDFF_PORT);
29
+ if (Number.isInteger(v) && v > 0) return v;
30
+ return defaultPort;
31
+ });
32
+ const _readModels = readModelsJson || (async () => {
33
+ try {
34
+ const { readFileSync, existsSync } = await import("node:fs");
35
+ const { join } = await import("node:path");
36
+ const { homedir } = await import("node:os");
37
+ const cache = join(homedir(), ".config", "mslxdff", "models.json");
38
+ if (existsSync(cache)) return JSON.parse(readFileSync(cache, "utf8"));
39
+ } catch {}
40
+ return { data: [] };
41
+ });
42
+
43
+ async function chatViaGateway({ messages, tools }) {
44
+ const TRACE = env.MSLXDFF_CHAT_TRACE !== "0";
45
+ const t0 = TRACE ? performance.now() : 0;
46
+ let port = defaultPort;
47
+ let token = "";
48
+ try {
49
+ token = String((await _loadToken()) || "").trim();
50
+ const p = _getPort();
51
+ if (Number.isInteger(p) && p > 0) port = p;
52
+ } catch {}
53
+ if (!token) {
54
+ try {
55
+ const { readFileSync, existsSync } = await import("node:fs");
56
+ const { join } = await import("node:path");
57
+ const { homedir } = await import("node:os");
58
+ const sf = env.MSLXDFF_STATE_FILE || join(homedir(), ".config", "mslxdff", "state.json");
59
+ if (existsSync(sf)) {
60
+ const j = JSON.parse(readFileSync(sf, "utf8"));
61
+ if (typeof j.token === "string" && j.token.trim()) token = j.token.trim();
62
+ }
63
+ } catch {}
64
+ }
65
+ const url = `http://127.0.0.1:${port}/v1/chat/completions`;
66
+ const body = { model: "auto", messages, stream: false };
67
+ if (tools?.length) { body.tools = tools; body.tool_choice = "auto"; }
68
+
69
+ try {
70
+ const controller = new AbortController();
71
+ const timer = setTimeout(() => controller.abort(), gatewayTimeoutMs);
72
+ const res = await _fetch(url, {
73
+ method: "POST",
74
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
75
+ body: JSON.stringify(body),
76
+ signal: controller.signal,
77
+ });
78
+ clearTimeout(timer);
79
+ const txt = await res.text();
80
+ let j;
81
+ try { j = JSON.parse(txt); } catch {
82
+ if (txt.includes("data:")) {
83
+ try {
84
+ const parsed = parseSse(txt);
85
+ if (parsed.sseOk && (parsed.content || parsed.toolCalls.length)) {
86
+ const hasToolCalls = parsed.toolCalls.length > 0;
87
+ const hasContent = !!parsed.content;
88
+ let finishReason = parsed.finishReason;
89
+ if (hasToolCalls && !hasContent) finishReason = "tool_calls";
90
+ const tool_calls = hasToolCalls ? parsed.toolCalls : undefined;
91
+ const msg = { role: "assistant", content: parsed.content || "" };
92
+ if (tool_calls) msg.tool_calls = tool_calls;
93
+ j = { id: `sse-${Date.now()}`, object: "chat.completion", model: parsed.model, choices: [{ index: 0, finish_reason: finishReason, message: msg }], usage: parsed.usage };
94
+ } else if (parsed.sseOk) {
95
+ return { ok: false, error: `gateway SSE no content: ${txt.slice(0, 800)}`, status: res.status };
96
+ } else {
97
+ return { ok: false, error: `gateway non-json: ${txt.slice(0, 800)}`, status: res.status };
98
+ }
99
+ } catch {
100
+ return { ok: false, error: `gateway non-json: ${txt.slice(0, 800)}`, status: res.status };
101
+ }
102
+ } else {
103
+ return { ok: false, error: `gateway non-json: ${txt.slice(0, 800)}`, status: res.status };
104
+ }
105
+ }
106
+ if (!res.ok) {
107
+ const msg = j?.error?.message || j?.error || j?.data?.error?.message || j?.data?.error || txt.slice(0, 800);
108
+ if (TRACE) console.log(`\x1b[90m· [LLM] gateway auto FAIL · ${Math.round(performance.now() - t0)}ms · HTTP ${res.status} ${String(msg).slice(0, 80)}\x1b[0m`);
109
+ return { ok: false, error: msg, status: res.status };
110
+ }
111
+ const choice = j.choices?.[0] || j.data?.choices?.[0];
112
+ const effectiveJ = j.choices ? j : (j.data?.choices ? j.data : j);
113
+ if (!choice) {
114
+ if (TRACE) console.log(`\x1b[90m· [gateway debug] no choice, txt=${txt.slice(0, 800)} · j=${JSON.stringify(j).slice(0, 800)}\x1b[0m`);
115
+ return { ok: false, error: `gateway no choice: ${txt.slice(0, 800)}`, status: res.status };
116
+ }
117
+ let provider = "opencode";
118
+ const rawModel = effectiveJ.model || j.model || choice.message?.model || "auto";
119
+ try {
120
+ const c = await _readModels();
121
+ const ids = (c.data || []).map((x) => x.id).filter(Boolean);
122
+ for (const pid of ids) {
123
+ const slash = pid.indexOf("/");
124
+ const prov = slash > 0 ? pid.slice(0, slash) : "opencode";
125
+ const raw = slash > 0 ? pid.slice(slash + 1) : pid;
126
+ if (pid === rawModel || raw === rawModel || pid.endsWith("/" + rawModel)) { provider = prov; break; }
127
+ }
128
+ if (provider === "opencode" && rawModel.includes("/")) {
129
+ const maybe = rawModel.split("/")[0];
130
+ if (["workbuddy", "clinebot", "sensenova", "openrouter", "generic"].includes(maybe)) provider = maybe;
131
+ }
132
+ } catch {}
133
+ if (TRACE) {
134
+ const dt = Math.round(performance.now() - t0);
135
+ console.log(`\x1b[90m· [LLM] gateway auto OK · ${dt}ms · 模型 ${provider !== "opencode" ? provider + "/" : ""}${rawModel} · 总 ${dt}ms (gateway-fallback)\x1b[0m`);
136
+ }
137
+ return { ok: true, message: choice.message, usage: effectiveJ.usage || j.usage, raw: j, status: res.status, model: rawModel, provider, viaGateway: true };
138
+ } catch (err) {
139
+ const msg = String(err?.message || err).slice(0, 800);
140
+ if (env.MSLXDFF_CHAT_TRACE !== "0") {
141
+ const dt2 = performance.now() - (performance.now() - 0); // placeholder, TRACE off in tests
142
+ }
143
+ return { ok: false, error: `gateway ${msg}`, status: 502 };
144
+ }
145
+ }
146
+
147
+ return { chatViaGateway };
148
+ }
@@ -0,0 +1,218 @@
1
+ import { performance as nodePerf } from "node:perf_hooks";
2
+
3
+ const ORIG_PREFERRED = "mimo-v2.5-free";
4
+ const ORIG_FALLBACK = "big-pickle";
5
+
6
+ /**
7
+ * 编排深模块:mimo → pickle → gateway 三级降级 + 800ms 对冲
8
+ * 注入化:chatOnce / chatViaGateway / cooling / config / env / performance
9
+ */
10
+ export function createOrchestrator({
11
+ chatOnce,
12
+ chatViaGateway,
13
+ cooling,
14
+ config = {},
15
+ env = process.env,
16
+ performance: perf = nodePerf,
17
+ chatWithFallbackImpl,
18
+ } = {}) {
19
+ const CHAT_PREFERRED = config.CHAT_PREFERRED || ORIG_PREFERRED;
20
+ const CHAT_FALLBACK = config.CHAT_FALLBACK || ORIG_FALLBACK;
21
+ const CHAT_GATEWAY_TIMEOUT_MS = config.CHAT_GATEWAY_TIMEOUT_MS || 25000;
22
+ const CHAT_COOLDOWN_MS = 10 * 60 * 1000;
23
+ const CHAT_SLOW_COOLDOWN_MS = 10 * 60 * 1000;
24
+
25
+ const _chatOnce = chatOnce || (async () => ({ ok: false, error: "no chatOnce", status: 500 }));
26
+ const _gateway = chatViaGateway || (async () => ({ ok: false, error: "no gateway", status: 500 }));
27
+ const _cooling = cooling || {
28
+ isCooling: async () => false,
29
+ recordError: async () => {},
30
+ recordOk: async () => {},
31
+ };
32
+
33
+ async function isCoolingAsync(id) {
34
+ try { return await _cooling.isCooling(id); } catch { return false; }
35
+ }
36
+ async function recordChatError(id, status, opts) {
37
+ try { await _cooling.recordError(id, status, opts); } catch {}
38
+ }
39
+ async function recordChatOk(id, latencyMs) {
40
+ try { await _cooling.recordOk(id, latencyMs); } catch {}
41
+ }
42
+
43
+ async function safeChatOnce(opts, model) {
44
+ try {
45
+ const r = await _chatOnce({ ...opts, model });
46
+ return r;
47
+ } catch (err) {
48
+ const msg = String(err?.message || err).slice(0, 800);
49
+ const status = err?._t ? 502 : 502;
50
+ return { ok: false, error: msg, status, _thrown: err };
51
+ }
52
+ }
53
+
54
+ async function chatWithFallback(opts) {
55
+ if (chatWithFallbackImpl) return chatWithFallbackImpl(opts);
56
+ const TRACE = env.MSLXDFF_CHAT_TRACE !== "0";
57
+ const HEDGE_MS = (() => {
58
+ const v = Number(env.MSLXDFF_HEDGE_DELAY_MS);
59
+ return Number.isInteger(v) && v >= 0 ? v : 800;
60
+ })();
61
+ const t0 = TRACE ? perf.now() : 0;
62
+
63
+ const firstCooling = await isCoolingAsync(CHAT_PREFERRED);
64
+ let first;
65
+ let firstMs = 0;
66
+ if (firstCooling) {
67
+ if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 跳过(冷却中)· 直接试 ${CHAT_FALLBACK}\x1b[0m`);
68
+ first = { ok: false, error: "skip cooling", status: 429 };
69
+ } else {
70
+ const t = perf.now();
71
+ first = await safeChatOnce(opts, CHAT_PREFERRED);
72
+ firstMs = Math.round(perf.now() - t);
73
+ if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} ${first.ok ? "OK" : "FAIL"} · ${Math.round(perf.now() - t0)}ms${first.ok ? "" : ` · ${String(first.error).slice(0, 80)}`}\x1b[0m`);
74
+ if (first.ok) {
75
+ await recordChatOk(CHAT_PREFERRED, firstMs);
76
+ return { ...first, model: CHAT_PREFERRED };
77
+ } else {
78
+ const slow = firstMs > 20000;
79
+ await recordChatError(CHAT_PREFERRED, first.status, { slow, latencyMs: firstMs });
80
+ }
81
+ }
82
+
83
+ if (firstCooling) {
84
+ if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 冷却中(${CHAT_COOLDOWN_MS / 60000}min)· 直接走网关 auto,跳过 ${CHAT_FALLBACK}\x1b[0m`);
85
+ if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 冷却,直接走网关 auto(:8989)\x1b[0m`);
86
+ const t2 = TRACE ? perf.now() : 0;
87
+ const third = await _gateway(opts);
88
+ if (TRACE) console.log(`\x1b[90m· [LLM] gateway auto ${third.ok ? "OK" : "FAIL"} · ${Math.round(perf.now() - t2)}ms · 总 ${Math.round(perf.now() - t0)}ms (gateway-fallback)\x1b[0m`);
89
+ if (third.ok) return { ...third, model: third.model || "auto", fallbackGateway: true, fallback: true, firstError: first.error, secondError: "skip big-pickle (mimo cooling)", viaGateway: true };
90
+ return { ok: false, error: `${CHAT_PREFERRED} cooling: ${first.error}; gateway auto failed: ${third.error}`, status: third.status || 429 };
91
+ }
92
+
93
+ const secondCooling = await isCoolingAsync(CHAT_FALLBACK);
94
+ const doGateway = () => _gateway(opts);
95
+ const doSecond = async () => {
96
+ const t = perf.now();
97
+ const r = await safeChatOnce(opts, CHAT_FALLBACK);
98
+ const ms = Math.round(perf.now() - t);
99
+ if (r.ok) await recordChatOk(CHAT_FALLBACK, ms);
100
+ else await recordChatError(CHAT_FALLBACK, r.status, { slow: ms > 20000, latencyMs: ms });
101
+ return { res: r, ms };
102
+ };
103
+
104
+ if (secondCooling) {
105
+ if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_FALLBACK} 跳过(冷却中)· 直接走网关 auto\x1b[0m`);
106
+ const t2 = perf.now();
107
+ const third = await doGateway();
108
+ if (TRACE) console.log(`\x1b[90m· [LLM] gateway auto ${third.ok ? "OK" : "FAIL"} · ${Math.round(perf.now() - t2)}ms · 总 ${Math.round(perf.now() - t0)}ms (gateway-fallback)\x1b[0m`);
109
+ if (third.ok) return { ...third, model: third.model || "auto", fallbackGateway: true, fallback: true, firstError: first.error, secondError: "skip cooling", viaGateway: true };
110
+ return { ok: false, error: `${CHAT_PREFERRED} failed: ${first.error}; ${CHAT_FALLBACK} failed: skip cooling; gateway auto failed: ${third.error}`, status: third.status || first.status };
111
+ }
112
+
113
+ if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 失败,${CHAT_FALLBACK} + gateway 对冲中(${HEDGE_MS}ms)\x1b[0m`);
114
+ const t1 = TRACE ? perf.now() : 0;
115
+ let secondRes = null;
116
+ let secondMs = 0;
117
+ let gatewayRes = null;
118
+
119
+ const secondPromise = doSecond().then(({ res, ms }) => {
120
+ secondRes = res; secondMs = ms;
121
+ if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_FALLBACK} ${res.ok ? "OK" : "FAIL"} · ${Math.round(perf.now() - t1)}ms · 总 ${Math.round(perf.now() - t0)}ms (hedge)\x1b[0m`);
122
+ return res;
123
+ });
124
+
125
+ let gatewayPromise = null;
126
+ const gatewayDelay = HEDGE_MS > 0 ? HEDGE_MS : 0;
127
+ if (gatewayDelay > 0) {
128
+ gatewayPromise = new Promise((resolve) => {
129
+ setTimeout(async () => {
130
+ const r = await doGateway();
131
+ gatewayRes = r;
132
+ resolve(r);
133
+ }, gatewayDelay);
134
+ });
135
+ } else {
136
+ gatewayPromise = doGateway().then((r) => { gatewayRes = r; return r; });
137
+ }
138
+
139
+ const raceFirstOk = async () => {
140
+ const secondOrTimeout = await Promise.race([
141
+ secondPromise.then((r) => ({ kind: "second", r })),
142
+ new Promise((resolve) => setTimeout(() => resolve({ kind: "timeout" }), gatewayDelay)),
143
+ ]);
144
+ if (secondOrTimeout.kind === "second" && secondOrTimeout.r?.ok) {
145
+ return { ok: true, res: secondOrTimeout.r, model: CHAT_FALLBACK, fallback: true, firstError: first.error };
146
+ }
147
+ if (!gatewayPromise || secondOrTimeout.kind === "timeout") {
148
+ const immediate = doGateway().then((r) => { gatewayRes = r; return r; });
149
+ if (gatewayPromise) {
150
+ gatewayRes = await Promise.race([gatewayPromise, immediate]);
151
+ } else {
152
+ gatewayRes = await immediate;
153
+ }
154
+ } else {
155
+ gatewayRes = await gatewayPromise;
156
+ }
157
+ if (gatewayRes?.ok) return { ok: true, res: gatewayRes, model: gatewayRes.model || "auto", fallbackGateway: true, fallback: true, firstError: first.error, secondError: secondRes?.error, viaGateway: true };
158
+ if (secondRes?.ok) return { ok: true, res: secondRes, model: CHAT_FALLBACK, fallback: true, firstError: first.error };
159
+ return { ok: false, gatewayRes, secondRes };
160
+ };
161
+
162
+ if (gatewayDelay === 0) {
163
+ const [sRes, gRes] = await Promise.all([
164
+ secondPromise.catch((e) => ({ ok: false, error: String(e), status: 502 })),
165
+ doGateway().catch((e) => ({ ok: false, error: String(e), status: 502 })),
166
+ ]);
167
+ secondRes = sRes; gatewayRes = gRes;
168
+ if (sRes?.ok) return { ...sRes, model: CHAT_FALLBACK, fallback: true, firstError: first.error };
169
+ if (gRes?.ok) return { ...gRes, model: gRes.model || "auto", fallbackGateway: true, fallback: true, firstError: first.error, secondError: sRes?.error, viaGateway: true };
170
+ 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 };
171
+ }
172
+
173
+ const raced = await raceFirstOk();
174
+ if (raced.ok) {
175
+ const r = raced.res;
176
+ return { ...r, model: raced.model, fallback: raced.fallback, fallbackGateway: raced.fallbackGateway, firstError: raced.firstError, secondError: raced.secondError, viaGateway: raced.viaGateway };
177
+ }
178
+ if (!secondRes) {
179
+ try { secondRes = await secondPromise; } catch (e) { secondRes = { ok: false, error: String(e), status: 502 }; }
180
+ }
181
+ if (secondRes?.ok) return { ...secondRes, model: CHAT_FALLBACK, fallback: true, firstError: first.error };
182
+ if (!gatewayRes) {
183
+ try { gatewayRes = await (gatewayPromise || doGateway()); } catch (e) { gatewayRes = { ok: false, error: String(e), status: 502 }; }
184
+ }
185
+ if (gatewayRes?.ok) return { ...gatewayRes, model: gatewayRes.model || "auto", fallbackGateway: true, fallback: true, firstError: first.error, secondError: secondRes?.error, viaGateway: true };
186
+ 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 };
187
+ }
188
+
189
+ async function summarizeHistory(messages) {
190
+ const prompt = [
191
+ { role: "system", content: "你是对话压缩助手,把以下历史对话压缩成 800 字以内的中文摘要,保留关键操作与结果、用户的偏好与待办、模型设置与群组操作及时间线,不要遗漏重要细节。" },
192
+ { role: "user", content: messages.map((m) => `${m.role}: ${m.content || JSON.stringify(m.tool_calls || "")}`).join("\n").slice(0, 90000) },
193
+ ];
194
+ const r = await chatWithFallback({ messages: prompt });
195
+ if (!r.ok) return null;
196
+ const txt = String(r.message?.content || "").trim();
197
+ return txt ? `【历史摘要】${txt}` : null;
198
+ }
199
+
200
+ // 兼容测试的额外覆盖参数
201
+ if (chatWithFallbackImpl) {
202
+ const orig = chatWithFallback;
203
+ // 允许测试覆盖 summarize 内部的 chatWithFallback
204
+ const wrapped = async (opts) => chatWithFallbackImpl(opts);
205
+ return { chatWithFallback: wrapped, summarizeHistory: async (msgs) => {
206
+ const prompt = [
207
+ { role: "system", content: "你是对话压缩助手,把以下历史对话压缩成 800 字以内的中文摘要,保留关键操作与结果、用户的偏好与待办、模型设置与群组操作及时间线,不要遗漏重要细节。" },
208
+ { role: "user", content: msgs.map((m) => `${m.role}: ${m.content || JSON.stringify(m.tool_calls || "")}`).join("\n").slice(0, 90000) },
209
+ ];
210
+ const r = await wrapped({ messages: prompt });
211
+ if (!r.ok) return null;
212
+ const txt = String(r.message?.content || "").trim();
213
+ return txt ? `【历史摘要】${txt}` : null;
214
+ } };
215
+ }
216
+
217
+ return { chatWithFallback, summarizeHistory };
218
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * SSE 聚合深模块:把网关可能返回的 text/event-stream 聚合为单次 JSON 形状
3
+ * 纯函数,便于单测;与 gateway.js 共享
4
+ */
5
+ export function parseSse(text) {
6
+ const lines = String(text || "").split(/\r?\n/);
7
+ let content = "";
8
+ const toolCallsMap = new Map();
9
+ let model = "auto";
10
+ let usage = null;
11
+ let finishReason = "stop";
12
+ let sseOk = false;
13
+
14
+ for (const line of lines) {
15
+ const t = String(line).trim();
16
+ if (!t.startsWith("data:")) continue;
17
+ const payload = t.slice(5).trim();
18
+ if (!payload || payload === "[DONE]") continue;
19
+ try {
20
+ const obj = JSON.parse(payload);
21
+ sseOk = true;
22
+ const ch = obj.choices?.[0];
23
+ if (ch?.finish_reason) finishReason = ch.finish_reason;
24
+ if (ch?.delta?.content) content += ch.delta.content;
25
+ else if (ch?.delta?.reasoning_content) content += ch.delta.reasoning_content;
26
+ else if (ch?.message?.content) content += ch.message.content;
27
+ else if (ch?.message?.reasoning_content) content += ch.message.reasoning_content;
28
+ else if (typeof ch?.text === "string") content += ch.text;
29
+ else if (typeof obj.content === "string") content += obj.content;
30
+
31
+ if (ch?.delta?.tool_calls) {
32
+ for (const tc of ch.delta.tool_calls) {
33
+ const idx = tc.index ?? 0;
34
+ const cur = toolCallsMap.get(idx) || { id: tc.id || `chatcmpl-tool-${idx}`, type: tc.type || "function", function: { name: "", arguments: "" } };
35
+ if (tc.id) cur.id = tc.id;
36
+ if (tc.type) cur.type = tc.type;
37
+ if (tc.function?.name) cur.function.name = tc.function.name;
38
+ if (typeof tc.function?.arguments === "string") cur.function.arguments += tc.function.arguments;
39
+ toolCallsMap.set(idx, cur);
40
+ }
41
+ }
42
+ if (ch?.message?.tool_calls) {
43
+ for (const tc of ch.message.tool_calls) {
44
+ const idx = tc.index ?? toolCallsMap.size;
45
+ toolCallsMap.set(idx, tc);
46
+ }
47
+ }
48
+ if (obj.model) model = obj.model;
49
+ if (obj.usage) usage = obj.usage;
50
+ if (obj.choices?.[0]?.message?.content && !content) content = obj.choices[0].message.content;
51
+ if (obj.choices?.[0]?.message?.reasoning_content && !content) content = obj.choices[0].message.reasoning_content;
52
+ if (obj.choices?.[0]?.message?.tool_calls && toolCallsMap.size === 0) {
53
+ for (const tc of obj.choices[0].message.tool_calls) toolCallsMap.set(tc.index ?? 0, tc);
54
+ }
55
+ } catch {}
56
+ }
57
+
58
+ const toolCalls = [...toolCallsMap.values()].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
59
+ return { content, toolCalls, model, usage, finishReason, sseOk };
60
+ }
61
+
62
+ export function sseToMessage(parsed, { hasContent, hasToolCalls } = {}) {
63
+ const hc = hasContent ?? !!parsed.content;
64
+ const ht = hasToolCalls ?? parsed.toolCalls.length > 0;
65
+ if (!parsed.sseOk || (!hc && !ht)) return null;
66
+ const msg = { role: "assistant", content: parsed.content || "" };
67
+ if (ht) msg.tool_calls = parsed.toolCalls;
68
+ return msg;
69
+ }