mslxdff 0.1.66 → 0.1.68
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 +1 -1
- package/src/bench/probe.js +68 -0
- package/src/bench/report.js +56 -0
- package/src/bench/runner.js +74 -0
- package/src/chat/cooling.js +99 -0
- package/src/chat/direct.js +57 -0
- package/src/chat/gateway.js +148 -0
- package/src/chat/orchestrator.js +218 -0
- package/src/chat/sse.js +69 -0
- package/src/chat/upstream.js +59 -501
- package/src/cli/bootstrap.js +1 -408
- package/src/cli/commands/provider/bench.js +140 -0
- package/src/cli/commands/provider/index.js +2 -0
- package/src/cli/provider-row.js +73 -0
- package/src/cli/status.js +3 -52
- package/src/metrics.js +63 -0
- package/src/providers/dispatcher.js +18 -6
- package/src/providers/generic.js +2 -0
- package/src/providers/openrouter.js +16 -72
- package/src/providers/workbuddy/auth.js +175 -0
- package/src/providers/workbuddy/balance.js +84 -0
- package/src/providers/workbuddy/chat.js +310 -0
- package/src/providers/workbuddy/index.js +263 -0
- package/src/providers/workbuddy/models.js +111 -0
- package/src/providers/workbuddy/rotation-log.js +54 -0
- package/src/providers/workbuddy.js +2 -652
- package/src/routes/chat/broadband-handler.js +25 -46
- package/src/routes/chat/exhausted-handler.js +2 -2
- package/src/routes/chat/hedge-handler.js +65 -83
- package/src/routes/chat/local-handler.js +32 -61
- package/src/routes/chat/peer-handler.js +32 -23
- package/src/routes/chat/relay-pipeline.js +151 -0
- package/src/runtime/bootstrap.js +408 -0
- package/src/state/memory.js +2 -21
- package/src/state/merge.js +26 -0
- package/src/state/provider-config.js +143 -0
- package/src/state/schemas/provider.js +83 -133
- package/src/state/store.js +2 -28
package/package.json
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { joinUrl } from "../providers/base.js";
|
|
2
|
+
|
|
3
|
+
function normalizeModelsPayload(json) {
|
|
4
|
+
if (!json) return [];
|
|
5
|
+
if (Array.isArray(json)) return json;
|
|
6
|
+
if (Array.isArray(json.data)) return json.data;
|
|
7
|
+
if (Array.isArray(json.models)) return json.models;
|
|
8
|
+
if (json.data && typeof json.data === "object" && Array.isArray(json.data.data)) return json.data.data;
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function toModelId(m) {
|
|
13
|
+
if (!m) return "";
|
|
14
|
+
if (typeof m === "string") return m;
|
|
15
|
+
if (typeof m.id === "string") return m.id;
|
|
16
|
+
if (typeof m.model === "string") return m.model;
|
|
17
|
+
if (typeof m.name === "string") return m.name;
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function probeModels({
|
|
22
|
+
baseUrl,
|
|
23
|
+
modelsPath,
|
|
24
|
+
chatPath,
|
|
25
|
+
headers = {},
|
|
26
|
+
fetchImpl = globalThis.fetch,
|
|
27
|
+
timeoutMs = 8000,
|
|
28
|
+
} = {}) {
|
|
29
|
+
const base = String(baseUrl || "").replace(/\/+$/, "");
|
|
30
|
+
if (!base) return { ok: false, error: "missing baseUrl", tried: [], data: [] };
|
|
31
|
+
const candidates = [];
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
const push = (p) => {
|
|
34
|
+
const s = String(p || "").trim();
|
|
35
|
+
if (!s) return;
|
|
36
|
+
const norm = s.startsWith("/") ? s : `/${s}`;
|
|
37
|
+
if (!seen.has(norm)) { seen.add(norm); candidates.push(norm); }
|
|
38
|
+
};
|
|
39
|
+
if (modelsPath) push(modelsPath);
|
|
40
|
+
// 通用回退:/v1/models 与 /models
|
|
41
|
+
push("/v1/models");
|
|
42
|
+
push("/models");
|
|
43
|
+
// workbuddy 异形已在 defaultModelsPath 注入,若仍未命中则 candidates 已含
|
|
44
|
+
const tried = [];
|
|
45
|
+
let lastError = "";
|
|
46
|
+
for (const p of candidates) {
|
|
47
|
+
const url = joinUrl(base, p);
|
|
48
|
+
tried.push(url);
|
|
49
|
+
try {
|
|
50
|
+
const controller = new AbortController();
|
|
51
|
+
const t = setTimeout(() => controller.abort(new Error(`probe timeout ${timeoutMs}ms`)), timeoutMs);
|
|
52
|
+
let res;
|
|
53
|
+
try {
|
|
54
|
+
res = await fetchImpl(url, { headers, signal: controller.signal });
|
|
55
|
+
} finally { clearTimeout(t); }
|
|
56
|
+
if (res instanceof Error) { lastError = res.message || String(res); continue; }
|
|
57
|
+
if (!res.ok) { lastError = `HTTP ${res.status}`; continue; }
|
|
58
|
+
let json = {};
|
|
59
|
+
try { json = await res.json(); } catch { json = {}; }
|
|
60
|
+
const raw = normalizeModelsPayload(json);
|
|
61
|
+
const data = raw.map((m) => ({ id: toModelId(m), raw: m })).filter((x) => x.id).map((x) => ({ id: x.id, ...x.raw }));
|
|
62
|
+
return { ok: true, data, tried, url, rawCount: raw.length };
|
|
63
|
+
} catch (e) {
|
|
64
|
+
lastError = e?.message || String(e);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { ok: false, error: lastError || "all probes failed", tried, data: [] };
|
|
68
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export function sortResults(list) {
|
|
2
|
+
const arr = [...(list || [])];
|
|
3
|
+
arr.sort((a, b) => {
|
|
4
|
+
if (a.ok !== b.ok) return a.ok ? -1 : 1;
|
|
5
|
+
if (a.ok && b.ok) {
|
|
6
|
+
const at = a.ttfbMs ?? a.totalMs ?? 1e9;
|
|
7
|
+
const bt = b.ttfbMs ?? b.totalMs ?? 1e9;
|
|
8
|
+
if (at !== bt) return at - bt;
|
|
9
|
+
const ap = a.tps ?? a.charsPerSec ?? -1;
|
|
10
|
+
const bp = b.tps ?? b.charsPerSec ?? -1;
|
|
11
|
+
return bp - ap;
|
|
12
|
+
}
|
|
13
|
+
return 0;
|
|
14
|
+
});
|
|
15
|
+
return arr;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function pad(s, n, align = "left") {
|
|
19
|
+
const str = String(s ?? "");
|
|
20
|
+
if (str.length >= n) return str.slice(0, n);
|
|
21
|
+
const d = n - str.length;
|
|
22
|
+
if (align === "right") return " ".repeat(d) + str;
|
|
23
|
+
return str + " ".repeat(d);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatReport(results, { json = false } = {}) {
|
|
27
|
+
const sorted = sortResults(results);
|
|
28
|
+
const winner = sorted.find((r) => r.ok) || null;
|
|
29
|
+
if (json) {
|
|
30
|
+
return { text: JSON.stringify(sorted, null, 2), json: sorted, winner, sorted };
|
|
31
|
+
}
|
|
32
|
+
const lines = [];
|
|
33
|
+
lines.push("模型 状态 TTFB 总耗时 速度 tokens 备注");
|
|
34
|
+
lines.push("─".repeat(84));
|
|
35
|
+
for (const r of sorted) {
|
|
36
|
+
const isWin = winner && r.id === winner.id;
|
|
37
|
+
const mark = isWin ? "*" : " ";
|
|
38
|
+
const id = pad((isWin ? "* " : " ") + r.id, 28);
|
|
39
|
+
const label = pad(r.label || (r.ok ? "成功" : "失败"), 8);
|
|
40
|
+
const ttfb = pad(r.ttfbMs != null ? `${r.ttfbMs}ms` : "—", 6, "right");
|
|
41
|
+
const total = pad(r.totalMs != null ? `${r.totalMs}ms` : "—", 7, "right");
|
|
42
|
+
let speed = "—";
|
|
43
|
+
if (r.tps != null) speed = `${r.tps} t/s`;
|
|
44
|
+
else if (r.charsPerSec != null) speed = `${r.charsPerSec} 字/秒`;
|
|
45
|
+
speed = pad(speed, 11, "right");
|
|
46
|
+
const tok = r.tokens?.completion != null ? String(r.tokens.completion) : r.chars != null ? String(r.chars) : "—";
|
|
47
|
+
const note = r.ok ? "" : (r.error || "").slice(0, 28);
|
|
48
|
+
lines.push(`${mark}${id} ${label} ${ttfb} ${total} ${speed} ${pad(tok, 6, "right")} ${note}`);
|
|
49
|
+
}
|
|
50
|
+
lines.push("─".repeat(84));
|
|
51
|
+
if (winner) lines.push(`最快:${winner.id} TTFB ${winner.ttfbMs}ms 总 ${winner.totalMs}ms ${winner.tps != null ? `${winner.tps} t/s` : `${winner.charsPerSec ?? "—"} 字/秒`}`);
|
|
52
|
+
else lines.push("无可用模型(均失败)");
|
|
53
|
+
const failed = sorted.filter((r) => !r.ok).length;
|
|
54
|
+
lines.push(`完成:${sorted.length} 个,成功 ${sorted.length - failed},失败 ${failed}`);
|
|
55
|
+
return { text: lines.join("\n"), json: sorted, winner, sorted };
|
|
56
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { joinUrl } from "../providers/base.js";
|
|
2
|
+
import { computeMetrics, extractUsageFromJson } from "../metrics.js";
|
|
3
|
+
|
|
4
|
+
function classifyError(status, bodyText) {
|
|
5
|
+
const t = String(bodyText || "").slice(0, 300);
|
|
6
|
+
if (status === 401) return { label: "鉴权失败", retryable: false };
|
|
7
|
+
if (status === 402 || /insufficient balance/i.test(t)) return { label: "余额不足", retryable: false };
|
|
8
|
+
if (status === 403) return { label: /insufficient/i.test(t) ? "余额不足" : "鉴权失败", retryable: false };
|
|
9
|
+
if (status === 429) return { label: "限流", retryable: true };
|
|
10
|
+
if (status >= 500) return { label: `上游错误 ${status}`, retryable: true };
|
|
11
|
+
if (status === 404) return { label: "模型不存在", retryable: false };
|
|
12
|
+
return { label: `HTTP ${status}`, retryable: false };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function runOne({
|
|
16
|
+
baseUrl,
|
|
17
|
+
chatPath = "/v1/chat/completions",
|
|
18
|
+
model,
|
|
19
|
+
providerId,
|
|
20
|
+
apiKey,
|
|
21
|
+
headers = {},
|
|
22
|
+
prompt = "hi",
|
|
23
|
+
maxTokens = 32,
|
|
24
|
+
timeoutMs = 30000,
|
|
25
|
+
fetchImpl = globalThis.fetch,
|
|
26
|
+
clock = Date.now,
|
|
27
|
+
} = {}) {
|
|
28
|
+
const started = clock();
|
|
29
|
+
const t0 = typeof performance !== "undefined" && performance.now ? performance.now() : started;
|
|
30
|
+
if (!baseUrl) return { id: model, ok: false, error: "missing baseUrl", label: "配置错误", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null };
|
|
31
|
+
if (!model) return { id: model, ok: false, error: "missing model", label: "配置错误", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null };
|
|
32
|
+
if (!apiKey) return { id: model, ok: false, error: "missing apiKey", label: "未配置 Key", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null };
|
|
33
|
+
const url = joinUrl(String(baseUrl).replace(/\/+$/, ""), chatPath);
|
|
34
|
+
const body = { model: String(model).split("/").pop(), stream: false, messages: [{ role: "user", content: prompt }], max_tokens: maxTokens };
|
|
35
|
+
// workbuddy 需额外 workbuddy 透传头由调用方 headers 注入;此处直接透传
|
|
36
|
+
const finalHeaders = { "Content-Type": "application/json", Accept: "application/json", ...headers };
|
|
37
|
+
if (apiKey && !finalHeaders.Authorization) finalHeaders.Authorization = `Bearer ${apiKey}`;
|
|
38
|
+
|
|
39
|
+
let ttfbMs = null;
|
|
40
|
+
let res;
|
|
41
|
+
try {
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const timer = setTimeout(() => controller.abort(new Error(`timeout ${timeoutMs}ms`)), timeoutMs);
|
|
44
|
+
const fetchStart = typeof performance !== "undefined" && performance.now ? performance.now() : clock();
|
|
45
|
+
try {
|
|
46
|
+
res = await fetchImpl(url, { method: "POST", headers: finalHeaders, body: JSON.stringify(body), signal: controller.signal });
|
|
47
|
+
} finally { clearTimeout(timer); }
|
|
48
|
+
ttfbMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - fetchStart);
|
|
49
|
+
if (res instanceof Error) throw res;
|
|
50
|
+
const totalMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0);
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
let txt = "";
|
|
53
|
+
try { txt = await res.text(); } catch {}
|
|
54
|
+
const cls = classifyError(res.status, txt);
|
|
55
|
+
return { id: model, providerId, ok: false, status: res.status, error: txt.slice(0, 300) || `HTTP ${res.status}`, label: cls.label, ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
|
|
56
|
+
}
|
|
57
|
+
let json = {};
|
|
58
|
+
let txt = "";
|
|
59
|
+
try { txt = await res.text(); json = JSON.parse(txt); } catch { json = {}; }
|
|
60
|
+
const usage = extractUsageFromJson(json);
|
|
61
|
+
const content = json?.choices?.[0]?.message?.content || json?.choices?.[0]?.text || txt || "";
|
|
62
|
+
const chars = typeof content === "string" ? content.length : 0;
|
|
63
|
+
const promptTokens = usage?.prompt_tokens ?? null;
|
|
64
|
+
const completionTokens = usage?.completion_tokens ?? null;
|
|
65
|
+
const { tps, charsPerSec } = computeMetrics({ ttfbMs, totalMs, promptTokens, completionTokens, chars });
|
|
66
|
+
const totalTokens = usage?.total_tokens ?? (promptTokens !== null && completionTokens !== null ? promptTokens + completionTokens : null);
|
|
67
|
+
return { id: model, providerId, ok: true, status: res.status, ttfbMs, totalMs, tps, charsPerSec, tokens: { prompt: promptTokens, completion: completionTokens, total: totalTokens }, chars, label: "成功", raw: json };
|
|
68
|
+
} catch (e) {
|
|
69
|
+
const totalMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0);
|
|
70
|
+
const msg = e?.message || String(e);
|
|
71
|
+
const isTimeout = /timeout|abort/i.test(msg);
|
|
72
|
+
return { id: model, providerId, ok: false, error: msg.slice(0, 300), label: isTimeout ? "超时" : "网络错误", ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -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
|
+
}
|