mslxdff 0.1.84 → 0.1.86
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/via-routes.js +87 -0
- package/src/bench/workbuddy-bench.js +70 -0
- package/src/cli/commands/provider/bench-via.js +23 -0
- package/src/cli/commands/provider/bench.js +9 -1
- package/src/cli/commands/provider/config.js +60 -0
- package/src/cli/commands/provider/index.js +11 -129
- package/src/cli/provider-row.js +67 -12
- package/src/cli/status.js +6 -6
- package/src/routes/chat/gateway.js +33 -2
- package/src/routes/chat/via-route-handler.js +144 -0
package/package.json
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { defaultStateFile } from "../state/store.js";
|
|
5
|
+
import { atomicWriteSync } from "../state/persist.js";
|
|
6
|
+
|
|
7
|
+
export function defaultViaRoutesFile() {
|
|
8
|
+
if (process.env.MSLXDFF_VIA_ROUTES_FILE) return String(process.env.MSLXDFF_VIA_ROUTES_FILE).trim();
|
|
9
|
+
const sf = defaultStateFile();
|
|
10
|
+
return join(dirname(sf), "via-routes.json");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function viaTtlMs() {
|
|
14
|
+
const raw = process.env.MSLXDFF_VIA_ROUTE_TTL_MS;
|
|
15
|
+
if (raw === undefined || raw === null || raw === "") return 0;
|
|
16
|
+
const s = String(raw).trim().toLowerCase();
|
|
17
|
+
if (s === "0" || s === "off" || s === "false" || s === "no" || s === "disable" || s === "disabled") return 0;
|
|
18
|
+
const n = Number(s);
|
|
19
|
+
if (Number.isInteger(n) && n >= 0) return n;
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function loadViaRoutes(file) {
|
|
24
|
+
const f = file || defaultViaRoutesFile();
|
|
25
|
+
try {
|
|
26
|
+
if (!existsSync(f)) return { version: 1, at: null, routes: {}, meta: {} };
|
|
27
|
+
const j = JSON.parse(readFileSync(f, "utf8"));
|
|
28
|
+
if (j && typeof j === "object" && j.routes && typeof j.routes === "object") return j;
|
|
29
|
+
if (j && typeof j === "object" && !j.routes) return { version: 1, at: j.at || null, routes: j, meta: {} };
|
|
30
|
+
return { version: 1, at: null, routes: {}, meta: {} };
|
|
31
|
+
} catch {
|
|
32
|
+
return { version: 1, at: null, routes: {}, meta: {} };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getViaRoute(model, { file, ttlMs } = {}) {
|
|
37
|
+
const id = String(model || "").trim();
|
|
38
|
+
if (!id) return null;
|
|
39
|
+
const f = file || defaultViaRoutesFile();
|
|
40
|
+
const data = loadViaRoutes(f);
|
|
41
|
+
const entry = data.routes?.[id];
|
|
42
|
+
if (!entry) return null;
|
|
43
|
+
const t = ttlMs !== undefined ? ttlMs : viaTtlMs();
|
|
44
|
+
if (t > 0 && entry.at) {
|
|
45
|
+
const atMs = Date.parse(entry.at);
|
|
46
|
+
if (Number.isFinite(atMs) && Date.now() - atMs > t) return null;
|
|
47
|
+
}
|
|
48
|
+
return entry;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function saveViaRoutes(results, { file, meta } = {}) {
|
|
52
|
+
const f = file || defaultViaRoutesFile();
|
|
53
|
+
const now = new Date().toISOString();
|
|
54
|
+
const prev = loadViaRoutes(f);
|
|
55
|
+
const nextRoutes = { ...(prev.routes || {}) };
|
|
56
|
+
for (const r of results || []) {
|
|
57
|
+
const id = String(r.model || r.id || "").trim();
|
|
58
|
+
if (!id) continue;
|
|
59
|
+
const best = String(r.best || "direct").trim() || "direct";
|
|
60
|
+
const direct = r.direct ? { ok: Boolean(r.direct.ok), ttfbMs: r.direct.ttfbMs ?? r.direct.totalMs ?? null, totalMs: r.direct.totalMs ?? null, label: r.direct.label || null, error: r.direct.error ? String(r.direct.error).slice(0, 300) : null } : null;
|
|
61
|
+
const via = {};
|
|
62
|
+
for (const [k, v] of Object.entries(r.via || {})) {
|
|
63
|
+
via[k] = v?.ok ? { ok: true, ttfbMs: v.ttfbMs ?? v.totalMs ?? null, totalMs: v.totalMs ?? null } : { ok: false, ttfbMs: v?.ttfbMs ?? null, totalMs: v?.totalMs ?? null, label: v?.label || v?.error || "offline" };
|
|
64
|
+
}
|
|
65
|
+
nextRoutes[id] = {
|
|
66
|
+
best,
|
|
67
|
+
direct,
|
|
68
|
+
via,
|
|
69
|
+
deltaMs: r.deltaMs ?? null,
|
|
70
|
+
provider: r.provider || id.split("/")[0] || "",
|
|
71
|
+
at: now,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const out = {
|
|
75
|
+
version: 1,
|
|
76
|
+
at: now,
|
|
77
|
+
routes: nextRoutes,
|
|
78
|
+
meta: meta || prev.meta || {},
|
|
79
|
+
};
|
|
80
|
+
atomicWriteSync(f, out);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function clearViaRoutes(file) {
|
|
85
|
+
const f = file || defaultViaRoutesFile();
|
|
86
|
+
atomicWriteSync(f, { version: 1, at: new Date().toISOString(), routes: {}, meta: {} });
|
|
87
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { computeMetrics } from "../metrics.js";
|
|
2
|
+
|
|
3
|
+
function buildWorkbuddyHeaders(apiKey, auth) {
|
|
4
|
+
const h = {
|
|
5
|
+
"Content-Type": "application/json",
|
|
6
|
+
Accept: "text/event-stream",
|
|
7
|
+
"User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
|
|
8
|
+
Origin: "https://www.codebuddy.cn",
|
|
9
|
+
Referer: "https://www.codebuddy.cn/",
|
|
10
|
+
"X-Product": "SaaS",
|
|
11
|
+
};
|
|
12
|
+
if (apiKey) h["Authorization"] = `Bearer ${apiKey}`;
|
|
13
|
+
if (auth?.uid) h["X-User-Id"] = auth.uid;
|
|
14
|
+
h["X-Domain"] = auth?.domain || "www.codebuddy.cn";
|
|
15
|
+
if (auth?.enterpriseId) { h["X-Enterprise-Id"] = auth.enterpriseId; h["X-Tenant-Id"] = auth.enterpriseId; }
|
|
16
|
+
return h;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function workbuddyBenchOne({ baseUrl, chatPath = "/v2/chat/completions", model, apiKey, auth, prompt = "hi", maxTokens = 5, timeoutMs = 30000, fetchImpl = globalThis.fetch }) {
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
const timer = setTimeout(() => controller.abort(new Error(`timeout ${timeoutMs}ms`)), timeoutMs);
|
|
22
|
+
const t0 = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
23
|
+
let ttfbMs = null;
|
|
24
|
+
let content = "";
|
|
25
|
+
try {
|
|
26
|
+
const url = String(baseUrl).replace(/\/+$/, "") + String(chatPath || "/v2/chat/completions");
|
|
27
|
+
const headers = buildWorkbuddyHeaders(apiKey, auth);
|
|
28
|
+
const rawModel = String(model || "").trim();
|
|
29
|
+
const body = { model: rawModel, stream: true, messages: [{ role: "user", content: prompt }], max_tokens: maxTokens };
|
|
30
|
+
const res = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body), signal: controller.signal });
|
|
31
|
+
if (res instanceof Error) throw res;
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
let txt = "";
|
|
34
|
+
try { txt = await res.text(); } catch {}
|
|
35
|
+
const label = res.status === 401 ? "鉴权失败" : res.status === 402 ? "余额不足" : res.status === 429 ? "限流" : res.status >= 500 ? `上游错误 ${res.status}` : `HTTP ${res.status}`;
|
|
36
|
+
return { id: model, ok: false, status: res.status, label, error: txt.slice(0, 300), ttfbMs, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - t0), tps: null, charsPerSec: null, tokens: null };
|
|
37
|
+
}
|
|
38
|
+
// SSE stream parsing — 复用 cline 逻辑
|
|
39
|
+
const reader = res.body.getReader();
|
|
40
|
+
const decoder = new TextDecoder();
|
|
41
|
+
let buf = "";
|
|
42
|
+
for (;;) {
|
|
43
|
+
const { done, value } = await reader.read();
|
|
44
|
+
if (done) break;
|
|
45
|
+
if (ttfbMs === null) ttfbMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - t0);
|
|
46
|
+
buf += decoder.decode(value, { stream: true });
|
|
47
|
+
let idx;
|
|
48
|
+
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
49
|
+
const line = buf.slice(0, idx);
|
|
50
|
+
buf = buf.slice(idx + 1);
|
|
51
|
+
if (!line.startsWith("data:")) continue;
|
|
52
|
+
const payload = line.slice(5).trim();
|
|
53
|
+
if (!payload || payload === "[DONE]") continue;
|
|
54
|
+
try {
|
|
55
|
+
const j = JSON.parse(payload);
|
|
56
|
+
const c = j?.choices?.[0]?.delta?.content || j?.choices?.[0]?.message?.content || "";
|
|
57
|
+
if (typeof c === "string") content += c;
|
|
58
|
+
} catch {}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const totalMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - t0);
|
|
62
|
+
const chars = content.length;
|
|
63
|
+
const { tps, charsPerSec } = computeMetrics({ ttfbMs, totalMs, promptTokens: null, completionTokens: null, chars });
|
|
64
|
+
return { id: model, ok: true, status: 200, label: "成功", ttfbMs, totalMs, tps, charsPerSec, tokens: null, chars };
|
|
65
|
+
} catch (e) {
|
|
66
|
+
const msg = e?.message || String(e);
|
|
67
|
+
const totalMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - t0);
|
|
68
|
+
return { id: model, ok: false, label: /timeout|abort/i.test(msg) ? "超时" : "网络错误", error: msg.slice(0, 300), ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
|
|
69
|
+
} finally { clearTimeout(timer); }
|
|
70
|
+
}
|
|
@@ -4,6 +4,7 @@ import { refreshTokenForBase } from "../../../providers/cline/auth.js";
|
|
|
4
4
|
import { runOne } from "../../../bench/runner.js";
|
|
5
5
|
import { formatViaReport } from "../../../bench/report.js";
|
|
6
6
|
import { clineBenchOne } from "../../../bench/cline-bench.js";
|
|
7
|
+
import { workbuddyBenchOne } from "../../../bench/workbuddy-bench.js";
|
|
7
8
|
|
|
8
9
|
export function buildHeadersForProvider(providerId, apiKey, auth) {
|
|
9
10
|
const h = {};
|
|
@@ -93,6 +94,9 @@ export async function handleVia({ providerId, opts, fetchImpl, loadConfigs, load
|
|
|
93
94
|
const aIdx = Math.min(kIdx, Math.max(auths.length - 1, 0));
|
|
94
95
|
const key = keys[kIdx] || keys[0] || "";
|
|
95
96
|
const auth = auths[aIdx] || auths[0] || null;
|
|
97
|
+
if (String(p).toLowerCase() === "workbuddy") {
|
|
98
|
+
return workbuddyBenchOne({ baseUrl, chatPath, model, apiKey: key, auth, prompt: "hi", maxTokens: 5, timeoutMs: opts.timeoutMs, fetchImpl });
|
|
99
|
+
}
|
|
96
100
|
const cKeys = keys.filter((k) => isRefreshToken(k, p));
|
|
97
101
|
if (cKeys.length) {
|
|
98
102
|
const normBase = String(baseUrl).replace(/\/+$/, "");
|
|
@@ -114,6 +118,16 @@ export async function handleVia({ providerId, opts, fetchImpl, loadConfigs, load
|
|
|
114
118
|
const aIdx = Math.min(kIdx, Math.max(auths.length - 1, 0));
|
|
115
119
|
const key = keys[kIdx] || keys[0] || "";
|
|
116
120
|
const auth = auths[aIdx] || auths[0] || null;
|
|
121
|
+
// workbuddy 强制 stream:true SSE 中继(与直连一致)
|
|
122
|
+
if (String(p).toLowerCase() === "workbuddy") {
|
|
123
|
+
const rawModel = String(model).startsWith(`${p}/`) ? String(model).slice(p.length + 1) : String(model);
|
|
124
|
+
const targetUrl = `${String(baseUrl).replace(/\/+$/, "")}${chatPath}`;
|
|
125
|
+
const headers = buildHeadersForProvider(p, key, auth);
|
|
126
|
+
const body = { model: rawModel, stream: true, messages: [{ role: "user", content: "hi" }], max_tokens: 5 };
|
|
127
|
+
const peer = peers.find((pe) => pe.url === peerUrl || (pe.name || pe.id) === peerUrl);
|
|
128
|
+
const peerToken = peer?.token || token;
|
|
129
|
+
return viaProbe({ peerUrl, token: peerToken, relayTarget: targetUrl, relayHeaders: headers, relayBody: body, timeoutMs: opts.timeoutMs, fetchImpl });
|
|
130
|
+
}
|
|
117
131
|
const cKeys = keys.filter((k) => isRefreshToken(k, p));
|
|
118
132
|
if (cKeys.length) {
|
|
119
133
|
const normBase = String(baseUrl).replace(/\/+$/, "");
|
|
@@ -154,6 +168,15 @@ export async function handleVia({ providerId, opts, fetchImpl, loadConfigs, load
|
|
|
154
168
|
}
|
|
155
169
|
const meta = { at: new Date().toISOString(), samples: opts.samples, timeout: opts.timeoutMs, includeOpencode, peers: peers.map((p) => p.id), opencodeSkipped: !includeOpencode };
|
|
156
170
|
const report = formatViaReport(allResults, { peers, meta, json: opts.json });
|
|
171
|
+
if (opts.apply) {
|
|
172
|
+
const { saveViaRoutes } = await import("../../../bench/via-routes.js");
|
|
173
|
+
const saved = saveViaRoutes(allResults, { meta });
|
|
174
|
+
const viaLog2 = (s) => (opts.json ? console.error(s) : console.log(s));
|
|
175
|
+
viaLog2(`\nvia-routes 已落盘: ${saved.at} 共 ${Object.keys(saved.routes).length} 条 → ${saved.routes[Object.keys(saved.routes)[0]] ? "" : ""}${(await import("../../../bench/via-routes.js")).defaultViaRoutesFile()}`);
|
|
176
|
+
for (const [m, e] of Object.entries(saved.routes)) {
|
|
177
|
+
if (allResults.some((r) => r.model === m)) viaLog2(` ${m} → ${e.best}${e.deltaMs ? ` (${e.deltaMs}ms)` : ""}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
157
180
|
if (opts.json) console.log(report.text);
|
|
158
181
|
else console.log("\n" + report.text);
|
|
159
182
|
process.exit(0);
|
|
@@ -5,14 +5,16 @@ import { defaultModelsPath, defaultChatPath } from "../../../state/provider-conf
|
|
|
5
5
|
import { isRefreshToken } from "../../../providers/cline/headers.js";
|
|
6
6
|
import { refreshTokenForBase } from "../../../providers/cline/auth.js";
|
|
7
7
|
import { clineBenchOne } from "../../../bench/cline-bench.js";
|
|
8
|
+
import { workbuddyBenchOne } from "../../../bench/workbuddy-bench.js";
|
|
8
9
|
import { buildHeadersForProvider, handleVia } from "./bench-via.js";
|
|
9
10
|
|
|
10
11
|
function parseBenchArgs(rest) {
|
|
11
|
-
const opts = { json: false, prompt: "hi", maxTokens: 32, timeoutMs: 30000, via: false, includeOpencode: false, samples: 1 };
|
|
12
|
+
const opts = { json: false, prompt: "hi", maxTokens: 32, timeoutMs: 30000, via: false, includeOpencode: false, samples: 1, apply: false };
|
|
12
13
|
for (let i = 0; i < rest.length; i++) {
|
|
13
14
|
const a = rest[i];
|
|
14
15
|
if (a === "--json" || a === "-json") opts.json = true;
|
|
15
16
|
else if (a === "--via" || a === "--bench-via") opts.via = true;
|
|
17
|
+
else if (a === "--apply" || a === "--write" || a === "--save") opts.apply = true;
|
|
16
18
|
else if (a === "--include-opencode") opts.includeOpencode = true;
|
|
17
19
|
else if (a === "--samples" && rest[i + 1]) opts.samples = Number(rest[++i]) || 1;
|
|
18
20
|
else if (a.startsWith("--samples=")) opts.samples = Number(a.split("=")[1]) || 1;
|
|
@@ -95,6 +97,12 @@ export async function handleProviderBench(id, sub, rest, args, deps = {}) {
|
|
|
95
97
|
const aIdx = Math.min(kIdx, Math.max(auths.length - 1, 0));
|
|
96
98
|
const key = keys[kIdx];
|
|
97
99
|
const auth = auths[aIdx] || auths[0] || null;
|
|
100
|
+
if (String(providerId).toLowerCase() === "workbuddy") {
|
|
101
|
+
const r = await workbuddyBenchOne({ baseUrl, chatPath, model, apiKey: key, auth, prompt: opts.prompt, maxTokens: opts.maxTokens, timeoutMs: opts.timeoutMs, fetchImpl });
|
|
102
|
+
results.push(r);
|
|
103
|
+
if (!opts.json) { if (r.ok) console.log(`OK TTFB ${r.ttfbMs}ms 总 ${r.totalMs}ms ${r.tps != null ? `${r.tps} t/s` : r.charsPerSec != null ? `${r.charsPerSec} 字/秒` : "—"}`); else console.log(`FAIL ${r.label} ${r.error ? `(${r.error.slice(0, 60)})` : ""}`); }
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
98
106
|
if (rtKeys.length) {
|
|
99
107
|
const rt = rtKeys[kIdx % rtKeys.length];
|
|
100
108
|
const at = await refreshTokenForBase({ refreshToken: rt, baseUrl: normBase, fetchImpl });
|
|
@@ -1,4 +1,64 @@
|
|
|
1
1
|
export async function handleProviderConfig(id, sub, rest) {
|
|
2
|
+
if (sub === "del" || sub === "delete" || sub === "rm" || sub === "remove-provider" || sub === "del-provider") {
|
|
3
|
+
const nid = String(id || "").trim().toLowerCase();
|
|
4
|
+
if (nid === "opencode" || nid === "oc") {
|
|
5
|
+
console.error("opencode 是内置供应商,不能删除");
|
|
6
|
+
process.exit(1);
|
|
7
|
+
}
|
|
8
|
+
const { loadProviderConfigs, saveProviderConfig, saveProviderShareKeys } = await import("../../../state.js");
|
|
9
|
+
const { isPidAlive, readPid } = await import("../../../daemon.js");
|
|
10
|
+
const configs = loadProviderConfigs();
|
|
11
|
+
if (!configs[id] && !configs[nid]) {
|
|
12
|
+
// 也查旧的 providerKeys
|
|
13
|
+
const { readState } = await import("../../../state/store.js");
|
|
14
|
+
const { defaultStateFile } = await import("../../../state/store.js");
|
|
15
|
+
const raw = readState(defaultStateFile());
|
|
16
|
+
const hasLegacy = raw.providerKeys && raw.providerKeys[id];
|
|
17
|
+
if (!hasLegacy) {
|
|
18
|
+
console.error(`provider not found: ${id}`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// 真删:空对象会触发 saveProviderConfig 的 delete 分支
|
|
23
|
+
saveProviderConfig(id, { baseUrl: "", keys: [], auths: [], allowedModels: [], modelsPath: "", chatPath: "" });
|
|
24
|
+
// 兼容旧路径的残留
|
|
25
|
+
try {
|
|
26
|
+
const { readState, writeStateImmediate, defaultStateFile } = await import("../../../state/store.js");
|
|
27
|
+
const file = defaultStateFile();
|
|
28
|
+
const raw = readState(file);
|
|
29
|
+
let changed = false;
|
|
30
|
+
if (raw.providerKeys && raw.providerKeys[id] !== undefined) {
|
|
31
|
+
const nk = { ...raw.providerKeys };
|
|
32
|
+
delete nk[id];
|
|
33
|
+
writeStateImmediate(file, { providerKeys: nk });
|
|
34
|
+
changed = true;
|
|
35
|
+
}
|
|
36
|
+
if (raw.providerShareKeys && raw.providerShareKeys[id] !== undefined) {
|
|
37
|
+
const ns = { ...raw.providerShareKeys };
|
|
38
|
+
delete ns[id];
|
|
39
|
+
writeStateImmediate(file, { providerShareKeys: ns });
|
|
40
|
+
changed = true;
|
|
41
|
+
}
|
|
42
|
+
// 清理模型错误/延迟中该供应商前缀的条目(可选,不阻塞)
|
|
43
|
+
void changed;
|
|
44
|
+
} catch {}
|
|
45
|
+
try { saveProviderShareKeys(id, false); } catch {}
|
|
46
|
+
console.log(`已删除供应商: ${id} — 配置已清空`);
|
|
47
|
+
// 需要重启才生效,自动重启
|
|
48
|
+
const pid = readPid();
|
|
49
|
+
if (pid && isPidAlive(pid)) {
|
|
50
|
+
console.log("检测到 daemon 运行中,自动重启以生效…");
|
|
51
|
+
const { spawnSync } = await import("node:child_process");
|
|
52
|
+
const { fileURLToPath } = await import("node:url");
|
|
53
|
+
const bin = fileURLToPath(new URL("../../../../bin/mslxdff.js", import.meta.url));
|
|
54
|
+
const r = spawnSync(process.execPath, [bin, "-restart"], { stdio: "inherit" });
|
|
55
|
+
if (r.status !== 0) console.log("自动重启失败,请手动执行: mslxdff -restart");
|
|
56
|
+
else console.log("已自动重启完成");
|
|
57
|
+
} else {
|
|
58
|
+
console.log("daemon 未运行,下次启动时生效");
|
|
59
|
+
}
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
2
62
|
if (sub === "set-models-path" || sub === "setModelsPath" || sub === "models-path") {
|
|
3
63
|
const p = rest[1];
|
|
4
64
|
if (!p) {
|
|
@@ -6,73 +6,12 @@ export async function handleProviders(args) {
|
|
|
6
6
|
const idx = args.findIndex((x) => x === "-providers" || x === "--providers");
|
|
7
7
|
const sub = args[idx + 1];
|
|
8
8
|
if (!sub || sub === "list" || sub === "status") {
|
|
9
|
-
const {
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const orShare = loadProviderShareKeys("openrouter");
|
|
16
|
-
const orAllowed = loadProviderAllowedModels("openrouter");
|
|
17
|
-
const genericIds = new Set(Object.keys(configs).filter((id) => id !== "opencode" && id !== "openrouter"));
|
|
18
|
-
try {
|
|
19
|
-
const raw = JSON.parse(readFileSync(defaultStateFile(), "utf8"));
|
|
20
|
-
const pk = raw.providerKeys || {};
|
|
21
|
-
for (const id of Object.keys(pk)) if (id !== "opencode" && id !== "openrouter") genericIds.add(id);
|
|
22
|
-
const cfgRaw = raw.providerConfigs || {};
|
|
23
|
-
for (const id of Object.keys(cfgRaw)) if (id !== "opencode" && id !== "openrouter") genericIds.add(id);
|
|
24
|
-
} catch {}
|
|
25
|
-
for (const k of Object.keys(process.env)) {
|
|
26
|
-
const m = k.match(/^MSLXDFF_(.+)_KEY$/);
|
|
27
|
-
if (m) {
|
|
28
|
-
const id = m[1].toLowerCase().replace(/__/g, "-");
|
|
29
|
-
if (id !== "openrouter" && id !== "opencode") genericIds.add(id);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
try {
|
|
33
|
-
const raw = JSON.parse(readFileSync(defaultStateFile(), "utf8"));
|
|
34
|
-
const cfgs = raw.providerConfigs || {};
|
|
35
|
-
for (const id of Object.keys(cfgs)) {
|
|
36
|
-
if (id === "opencode" || id === "openrouter") continue;
|
|
37
|
-
const am = cfgs[id]?.allowedModels;
|
|
38
|
-
if (Array.isArray(am) && am.length) genericIds.add(id);
|
|
39
|
-
}
|
|
40
|
-
} catch {}
|
|
41
|
-
const list = [];
|
|
42
|
-
const { loadProviderAllowAnyModels: _la0 } = await import("../../../state.js");
|
|
43
|
-
const opAllowAny = _la0("opencode");
|
|
44
|
-
const orAllowAny = _la0("openrouter");
|
|
45
|
-
const opAllowed = loadProviderAllowedModels("opencode");
|
|
46
|
-
list.push({ id: "opencode", enabled: opencodeEnabled, baseUrl: opencodeBase, keys: [], share: false, allowed: opAllowed, allowAny: opAllowAny, note: "built-in, no key, cannot share" });
|
|
47
|
-
list.push({ id: "openrouter", enabled: orKeys.length > 0, baseUrl: orBase, keys: orKeys, share: orShare, allowed: orAllowed, allowAny: orAllowAny, note: orKeys.length ? "" : "no keys" });
|
|
48
|
-
for (const gid of [...genericIds].sort()) {
|
|
49
|
-
const cfg = configs[gid];
|
|
50
|
-
const keys = loadProviderKeys(gid);
|
|
51
|
-
const baseUrl = loadProviderBaseUrl(gid) || cfg?.baseUrl || "";
|
|
52
|
-
const share = loadProviderShareKeys(gid);
|
|
53
|
-
const allowed = loadProviderAllowedModels(gid);
|
|
54
|
-
const allowAny = _la0(gid);
|
|
55
|
-
const enabled = Boolean(baseUrl && keys.length);
|
|
56
|
-
let note = "";
|
|
57
|
-
if (!baseUrl && !keys.length && !allowed.length && allowAny === false) note = "no baseUrl, no keys, BLOCKED (allowAny OFF)";
|
|
58
|
-
else if (!baseUrl && !keys.length && !allowed.length) note = "no baseUrl, no keys";
|
|
59
|
-
else if (!baseUrl && !allowed.length && !allowAny) note = "missing baseUrl, BLOCKED";
|
|
60
|
-
else if (!keys.length && !allowed.length && !allowAny) note = "no keys, BLOCKED";
|
|
61
|
-
else if (!baseUrl) note = "missing baseUrl";
|
|
62
|
-
else if (!keys.length) note = "no keys";
|
|
63
|
-
list.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, share, allowed, allowAny, note });
|
|
64
|
-
}
|
|
65
|
-
console.log(`providers (${list.length}):`);
|
|
66
|
-
for (const p of list) {
|
|
67
|
-
const state = p.enabled ? "enabled " : "disabled";
|
|
68
|
-
const keysInfo = p.keys.length ? `${p.keys.length} key${p.keys.length > 1 ? "s" : ""} ${p.keys.map((k) => `${k.slice(0, 4)}…${k.slice(-4)}`).join(", ")}` : "0 keys";
|
|
69
|
-
const shareInfo = p.id === "opencode" ? "cannot share" : `share=${p.share ? "ON" : "off"}`;
|
|
70
|
-
const allowInfo = p.allowed.length ? `allow=${p.allowed.length}(${p.allowed.slice(0, 3).join(",")}${p.allowed.length > 3 ? "..." : ""})` : (p.allowAny ? "allow=all" : "allow=none(BLOCKED)");
|
|
71
|
-
const note = p.note ? ` (${p.note})` : "";
|
|
72
|
-
console.log(` ${p.id.padEnd(12)} ${state} ${keysInfo.padEnd(28)} ${allowInfo.padEnd(22)} baseUrl=${p.baseUrl} ${shareInfo}${note}`);
|
|
73
|
-
}
|
|
74
|
-
console.log(`\nuse: mslxdff -provider <id> list to inspect one, mslxdff -provider <id> allowlist set <model...> to restrict`);
|
|
75
|
-
console.log(` mslxdff -provider <id> allowAny on|off (empty allowlist = block or allow all)`);
|
|
9
|
+
const { buildProviderRows, formatProviderSection } = await import("../../provider-row.js");
|
|
10
|
+
const rows = buildProviderRows({});
|
|
11
|
+
const enabled = rows.filter((r) => r.enabled).length;
|
|
12
|
+
console.log(`providers ${rows.length} 个 · ${enabled} 已启用 — mslxdff -provider <id> list 查看详情`);
|
|
13
|
+
console.log(formatProviderSection(rows));
|
|
14
|
+
console.log(`\n提示: mslxdff -provider <id> list 单看一个 · allowlist set <model...> 限制模型 · allowAny on|off 空名单放行/阻断`);
|
|
76
15
|
process.exit(0);
|
|
77
16
|
}
|
|
78
17
|
console.error("usage: mslxdff -providers list");
|
|
@@ -106,68 +45,11 @@ export async function handleProvider(args) {
|
|
|
106
45
|
process.exit(1);
|
|
107
46
|
}
|
|
108
47
|
if (id === "list" || id === "status") {
|
|
109
|
-
const {
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const orShare = loadProviderShareKeys("openrouter");
|
|
115
|
-
const genericIds = new Set(Object.keys(configs).filter((x) => x !== "opencode" && x !== "openrouter"));
|
|
116
|
-
try {
|
|
117
|
-
const raw = JSON.parse(readFileSync(defaultStateFile(), "utf8"));
|
|
118
|
-
const pk = raw.providerKeys || {};
|
|
119
|
-
for (const k of Object.keys(pk)) if (k !== "opencode" && k !== "openrouter") genericIds.add(k);
|
|
120
|
-
const cfgRaw = raw.providerConfigs || {};
|
|
121
|
-
for (const k of Object.keys(cfgRaw)) if (k !== "opencode" && k !== "openrouter") genericIds.add(k);
|
|
122
|
-
} catch {}
|
|
123
|
-
for (const k of Object.keys(process.env)) {
|
|
124
|
-
const m = k.match(/^MSLXDFF_(.+)_KEY$/);
|
|
125
|
-
if (m) {
|
|
126
|
-
const gid = m[1].toLowerCase().replace(/__/g, "-");
|
|
127
|
-
if (gid !== "openrouter" && gid !== "opencode") genericIds.add(gid);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
try {
|
|
131
|
-
const raw = JSON.parse(readFileSync(defaultStateFile(), "utf8"));
|
|
132
|
-
const cfgs = raw.providerConfigs || {};
|
|
133
|
-
for (const gid of Object.keys(cfgs)) {
|
|
134
|
-
if (gid === "opencode" || gid === "openrouter") continue;
|
|
135
|
-
const am = cfgs[gid]?.allowedModels;
|
|
136
|
-
if (Array.isArray(am) && am.length) genericIds.add(gid);
|
|
137
|
-
}
|
|
138
|
-
} catch {}
|
|
139
|
-
const list = [];
|
|
140
|
-
const { loadProviderAllowAnyModels: _la0 } = await import("../../../state.js");
|
|
141
|
-
const { loadProviderAllowedModels } = await import("../../../state.js");
|
|
142
|
-
const opAllowed = loadProviderAllowedModels("opencode");
|
|
143
|
-
const opAllowAny = _la0("opencode");
|
|
144
|
-
const orAllowed = loadProviderAllowedModels("openrouter");
|
|
145
|
-
const orAllowAny = _la0("openrouter");
|
|
146
|
-
list.push({ id: "opencode", enabled: true, baseUrl: opencodeBase, keys: [], share: false, allowed: opAllowed, allowAny: opAllowAny, note: "built-in, no key, cannot share" });
|
|
147
|
-
list.push({ id: "openrouter", enabled: orKeys.length > 0, baseUrl: orBase, keys: orKeys, share: orShare, allowed: orAllowed, allowAny: orAllowAny, note: orKeys.length ? "" : "no keys" });
|
|
148
|
-
for (const gid of [...genericIds].sort()) {
|
|
149
|
-
const cfg = configs[gid];
|
|
150
|
-
const keys = loadProviderKeys(gid);
|
|
151
|
-
const baseUrl = loadProviderBaseUrl(gid) || cfg?.baseUrl || "";
|
|
152
|
-
const share = loadProviderShareKeys(gid);
|
|
153
|
-
const allowed = loadProviderAllowedModels(gid);
|
|
154
|
-
const allowAny = _la0(gid);
|
|
155
|
-
const enabled = Boolean(baseUrl && keys.length);
|
|
156
|
-
let note = "";
|
|
157
|
-
if (!baseUrl && !keys.length) note = "no baseUrl, no keys";
|
|
158
|
-
else if (!baseUrl) note = "missing baseUrl";
|
|
159
|
-
else if (!keys.length) note = "no keys";
|
|
160
|
-
list.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, share, note });
|
|
161
|
-
}
|
|
162
|
-
console.log(`providers (${list.length}):`);
|
|
163
|
-
for (const p of list) {
|
|
164
|
-
const state = p.enabled ? "enabled " : "disabled";
|
|
165
|
-
const keysInfo = p.keys.length ? `${p.keys.length} key${p.keys.length > 1 ? "s" : ""} ${p.keys.map((k) => `${k.slice(0, 4)}…${k.slice(-4)}`).join(", ")}` : "0 keys";
|
|
166
|
-
const shareInfo = p.id === "opencode" ? "cannot share" : `share=${p.share ? "ON" : "off"}`;
|
|
167
|
-
const note = p.note ? ` (${p.note})` : "";
|
|
168
|
-
console.log(` ${p.id.padEnd(12)} ${state} ${keysInfo.padEnd(28)} baseUrl=${p.baseUrl} ${shareInfo}${note}`);
|
|
169
|
-
}
|
|
170
|
-
console.log(`\nuse: mslxdff -provider <id> list to inspect one, mslxdff -provider add <id> <baseUrl> <key> to add generic`);
|
|
48
|
+
const { buildProviderRows, formatProviderSection } = await import("../../provider-row.js");
|
|
49
|
+
const rows = buildProviderRows({});
|
|
50
|
+
const enabled = rows.filter((r) => r.enabled).length;
|
|
51
|
+
console.log(`providers ${rows.length} 个 · ${enabled} 已启用 — mslxdff -provider <id> list 单看一个`);
|
|
52
|
+
console.log(formatProviderSection(rows));
|
|
171
53
|
process.exit(0);
|
|
172
54
|
}
|
|
173
55
|
if (id === "add") {
|
package/src/cli/provider-row.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
-
import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, loadProviderAllowedModels, loadProviderShareKeys, loadProviderBaseUrl } from "../state.js";
|
|
2
|
+
import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderShareKeys, loadProviderBaseUrl } from "../state.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* 聚合 genericIds:来自 providerConfigs + providerKeys + env MSLXDFF_*_KEY
|
|
@@ -12,7 +12,8 @@ export function buildProviderRows({ stateFile, env = process.env } = {}) {
|
|
|
12
12
|
const upstreamBase = env.UPSTREAM_BASE_URL || "https://opencode.ai";
|
|
13
13
|
const rows = [];
|
|
14
14
|
const opAllowed = (() => { try { return loadProviderAllowedModels("opencode", stateFile ? { file: stateFile } : undefined); } catch { return []; } })();
|
|
15
|
-
|
|
15
|
+
const opAllowAny = (() => { try { return loadProviderAllowAnyModels("opencode", stateFile ? { file: stateFile } : undefined); } catch { return true; } })();
|
|
16
|
+
rows.push({ id: "opencode", enabled: true, baseUrl: upstreamBase, keys: [], allowed: opAllowed, allowAny: opAllowAny !== false, share: false, note: "built-in, no key, cannot share", authCount: 0 });
|
|
16
17
|
|
|
17
18
|
const genericIds = new Set(Object.keys(configs).filter((id) => id !== "opencode"));
|
|
18
19
|
try {
|
|
@@ -30,16 +31,22 @@ export function buildProviderRows({ stateFile, env = process.env } = {}) {
|
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
// 保证 openrouter 始终可见(即使 0 keys,供用户发现)
|
|
35
|
+
genericIds.add("openrouter");
|
|
33
36
|
for (const gid of [...genericIds].sort()) {
|
|
34
37
|
const cfg = configs[gid];
|
|
35
38
|
let keys = [];
|
|
36
39
|
let baseUrl = "";
|
|
37
40
|
let share = false;
|
|
38
41
|
let allowed = [];
|
|
42
|
+
let allowAny = false;
|
|
39
43
|
try { keys = loadProviderKeys(gid, stateFile ? { file: stateFile } : undefined); } catch {}
|
|
40
44
|
try { baseUrl = loadProviderBaseUrl(gid, stateFile ? { file: stateFile } : undefined) || cfg?.baseUrl || (gid === "openrouter" ? "https://openrouter.ai/api/v1" : gid === "workbuddy" ? "https://copilot.tencent.com" : ""); } catch { baseUrl = cfg?.baseUrl || ""; }
|
|
41
45
|
try { share = loadProviderShareKeys(gid, stateFile ? { file: stateFile } : undefined); } catch {}
|
|
42
46
|
try { allowed = loadProviderAllowedModels(gid, stateFile ? { file: stateFile } : undefined); } catch {}
|
|
47
|
+
try { allowAny = loadProviderAllowAnyModels(gid, stateFile ? { file: stateFile } : undefined); } catch {}
|
|
48
|
+
// openrouter 特殊:opencode 例外默认 allowAny true,其余默认 false
|
|
49
|
+
if (gid === "opencode") allowAny = true;
|
|
43
50
|
let enabled = Boolean(baseUrl && keys.length) || (gid === "openrouter" && keys.length > 0);
|
|
44
51
|
const auths = gid === "workbuddy" ? (() => { try { return loadProviderAuths(gid, stateFile ? { file: stateFile } : undefined) || []; } catch { return []; } })() : [];
|
|
45
52
|
let note = "";
|
|
@@ -54,20 +61,68 @@ export function buildProviderRows({ stateFile, env = process.env } = {}) {
|
|
|
54
61
|
} else if (gid === "workbuddy" && auths.length && auths.length !== keys.length) {
|
|
55
62
|
note = `${auths.length} auth(s) / ${keys.length} key(s) — 数量不一致请重跑 workbuddy-token-auto.js`;
|
|
56
63
|
}
|
|
57
|
-
rows.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, allowed, share, note, authCount: auths.length });
|
|
64
|
+
rows.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, allowed, allowAny, share, note, authCount: auths.length });
|
|
58
65
|
}
|
|
59
66
|
return rows;
|
|
60
67
|
}
|
|
61
68
|
|
|
69
|
+
function maskKey(k) {
|
|
70
|
+
const s = String(k || "").trim();
|
|
71
|
+
if (s.length <= 8) return `${s.slice(0, 3)}…${s.slice(-2)}`;
|
|
72
|
+
return `${s.slice(0, 3)}…${s.slice(-4)}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
62
75
|
export function formatProviderRow(p) {
|
|
63
|
-
const
|
|
64
|
-
const
|
|
76
|
+
const isBlocked = !p.allowed.length && !p.allowAny && p.id !== "opencode";
|
|
77
|
+
const dot = !p.enabled ? "○" : isBlocked ? "◐" : "●";
|
|
78
|
+
const state = !p.enabled ? "未启用" : isBlocked ? "已启用·阻断" : "已启用";
|
|
79
|
+
// keys
|
|
65
80
|
let keysInfo;
|
|
66
|
-
if (p.id === "opencode") keysInfo = "无需 key
|
|
67
|
-
else
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
81
|
+
if (p.id === "opencode") keysInfo = "无需 key";
|
|
82
|
+
else if (!p.keys.length) keysInfo = "0 keys";
|
|
83
|
+
else if (p.keys.length === 1) keysInfo = `1 key ${maskKey(p.keys[0])}`;
|
|
84
|
+
else keysInfo = `${p.keys.length} keys ${p.keys.slice(0, 2).map(maskKey).join(", ")}${p.keys.length > 2 ? ` +${p.keys.length - 2}` : ""}`;
|
|
85
|
+
if (p.authCount) keysInfo += ` · ${p.authCount} acc`;
|
|
86
|
+
// allow — 空名单 + allowAny OFF = 阻断(安全默认)
|
|
87
|
+
let allowInfo;
|
|
88
|
+
if (!p.allowed.length) {
|
|
89
|
+
if (p.id === "opencode") allowInfo = "allow all";
|
|
90
|
+
else if (p.allowAny) allowInfo = "allow all";
|
|
91
|
+
else allowInfo = "allow none (BLOCKED)";
|
|
92
|
+
} else {
|
|
93
|
+
const head = p.allowed.slice(0, 2).join(", ");
|
|
94
|
+
const more = p.allowed.length > 2 ? ` …+${p.allowed.length - 2}` : "";
|
|
95
|
+
allowInfo = `allow ${p.allowed.length} → ${head}${more}`;
|
|
96
|
+
}
|
|
97
|
+
// share
|
|
98
|
+
const shareInfo = p.id === "opencode" ? "无法共享" : `共享 ${p.share ? "开" : "关"}`;
|
|
99
|
+
// base
|
|
100
|
+
const base = p.baseUrl && p.baseUrl !== "(none)" ? p.baseUrl : "(none)";
|
|
101
|
+
const baseLine = ` └ ${base}${p.note ? ` · ${p.note}` : ""}`;
|
|
102
|
+
// 主行:id 固定 12,状态 6,keys 22,allow 自适应,share 固定
|
|
103
|
+
const main = ` ${dot} ${p.id.padEnd(12)} ${state} ${keysInfo.padEnd(22)} ${allowInfo.padEnd(28)} ${shareInfo}`;
|
|
104
|
+
return `${main}\n${baseLine}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function formatProviderSection(rows) {
|
|
108
|
+
if (!rows.length) {
|
|
109
|
+
return [
|
|
110
|
+
" (空) 暂无供应商 — 加一个试试:",
|
|
111
|
+
" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx",
|
|
112
|
+
" 或 node workbuddy-token-auto.js (WorkBuddy 一键接入)",
|
|
113
|
+
].join("\n");
|
|
114
|
+
}
|
|
115
|
+
const enabled = rows.filter((r) => r.enabled);
|
|
116
|
+
const disabled = rows.filter((r) => !r.enabled);
|
|
117
|
+
const out = [];
|
|
118
|
+
if (enabled.length) {
|
|
119
|
+
out.push(` 已启用 (${enabled.length})`);
|
|
120
|
+
for (const p of enabled) out.push(formatProviderRow(p));
|
|
121
|
+
}
|
|
122
|
+
if (disabled.length) {
|
|
123
|
+
if (enabled.length) out.push("");
|
|
124
|
+
out.push(` 未启用 / 需配置 (${disabled.length})`);
|
|
125
|
+
for (const p of disabled) out.push(formatProviderRow(p));
|
|
126
|
+
}
|
|
127
|
+
return out.join("\n");
|
|
73
128
|
}
|
package/src/cli/status.js
CHANGED
|
@@ -13,7 +13,7 @@ import { refreshGroupMembers } from "../groups.js";
|
|
|
13
13
|
import { fmtShanghaiYMDHM } from "../time.js";
|
|
14
14
|
import { fmtStatus, fmtUptime, fmtTs } from "./format.js";
|
|
15
15
|
import { compareSemver } from "./policy.js";
|
|
16
|
-
import { buildProviderRows, formatProviderRow } from "./provider-row.js";
|
|
16
|
+
import { buildProviderRows, formatProviderRow, formatProviderSection } from "./provider-row.js";
|
|
17
17
|
|
|
18
18
|
export async function printStatus(VERSION) {
|
|
19
19
|
const daemon = readPid();
|
|
@@ -53,12 +53,12 @@ export async function printStatus(VERSION) {
|
|
|
53
53
|
try {
|
|
54
54
|
const providerRows = buildProviderRows({});
|
|
55
55
|
const enabledCount = providerRows.filter((r) => r.enabled).length;
|
|
56
|
-
console.log(`\nupstream providers
|
|
57
|
-
|
|
58
|
-
console.log(formatProviderRow(p));
|
|
59
|
-
}
|
|
56
|
+
console.log(`\nupstream providers ${providerRows.length} 个 · ${enabledCount} 已启用 — mslxdff -providers list 查看详情`);
|
|
57
|
+
console.log(formatProviderSection(providerRows));
|
|
60
58
|
if (enabledCount === 1 && providerRows.length === 1) {
|
|
61
|
-
console.log(
|
|
59
|
+
console.log(`\n 空状态:仅 opencode 内置免费通道`);
|
|
60
|
+
console.log(` → 加一个私有上游:mslxdff -provider add bai https://api.b.ai/v1 <key>`);
|
|
61
|
+
console.log(` → 或 WorkBuddy 一键接入:node workbuddy-token-auto.js`);
|
|
62
62
|
}
|
|
63
63
|
} catch (e) {
|
|
64
64
|
console.log(`\nupstream providers: (unavailable — ${String(e?.message || e).slice(0, 80)})`);
|
|
@@ -10,6 +10,7 @@ import { handleHedge } from "./hedge-handler.js";
|
|
|
10
10
|
import { handleLocalRelay } from "./local-handler.js";
|
|
11
11
|
import { handlePeerRelay } from "./peer-handler.js";
|
|
12
12
|
import { handleBroadbandRelay } from "./broadband-handler.js";
|
|
13
|
+
import { handleViaRoute } from "./via-route-handler.js";
|
|
13
14
|
import { handleExhaustedLocal, handleExhaustedAll } from "./exhausted-handler.js";
|
|
14
15
|
import { normalizeFullId, getModelAlias } from "../../providers/model-id.js";
|
|
15
16
|
|
|
@@ -111,7 +112,7 @@ export function createChatGateway({ upstream, auto, logs, peers, maxHops, groups
|
|
|
111
112
|
for (const e of sel.errors) evt("plugin-hook-error", { reqId, hook: "model:select", plugin: e.plugin, error: e.error });
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
const handlerCtx = { reqId, model: null, body, hops, peers, plugins, evt, logError, logCall, logs };
|
|
115
|
+
const handlerCtx = { reqId, model: null, body, hops, peers, plugins, evt, logError, logCall, logs, workbuddyUid };
|
|
115
116
|
|
|
116
117
|
// ===== Selector: 首次 auto 并发择优 =====
|
|
117
118
|
if (useAuto && order.length > 1 && auto && !lockModel) {
|
|
@@ -176,8 +177,38 @@ export function createChatGateway({ upstream, auto, logs, peers, maxHops, groups
|
|
|
176
177
|
}
|
|
177
178
|
}
|
|
178
179
|
|
|
180
|
+
// ===== VIA-ROUTE 单路径择路(显式锁模型,不并发) =====
|
|
181
|
+
let viaRouteLastErr = null;
|
|
182
|
+
if (!useAuto && requested && requested.includes("/") && canForwardPeers && !lockModel && peers) {
|
|
183
|
+
try {
|
|
184
|
+
const vr = await handleViaRoute({
|
|
185
|
+
model: requested,
|
|
186
|
+
body,
|
|
187
|
+
peers,
|
|
188
|
+
handlerCtx,
|
|
189
|
+
evt,
|
|
190
|
+
logCall,
|
|
191
|
+
logError,
|
|
192
|
+
mark,
|
|
193
|
+
perf0,
|
|
194
|
+
stages,
|
|
195
|
+
startedAt,
|
|
196
|
+
plugins,
|
|
197
|
+
res,
|
|
198
|
+
requested,
|
|
199
|
+
useAuto,
|
|
200
|
+
lockModel,
|
|
201
|
+
auto,
|
|
202
|
+
});
|
|
203
|
+
if (vr.handled) return;
|
|
204
|
+
if (vr.lastErr) viaRouteLastErr = vr.lastErr;
|
|
205
|
+
} catch (e) {
|
|
206
|
+
evt("via-route-exception", { reqId, model: requested, error: errMsg(e) });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
179
210
|
// ===== Executor: 串行 trial =====
|
|
180
|
-
let lastErr =
|
|
211
|
+
let lastErr = viaRouteLastErr;
|
|
181
212
|
for (let idx = 0; idx < order.length; idx++) {
|
|
182
213
|
const model = order[idx];
|
|
183
214
|
handlerCtx.model = model;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { createRelayPipeline } from "./relay-pipeline.js";
|
|
2
|
+
import { relay, SLOW_TOTAL_MS, STREAM_TIMEOUT_MS, STALL_TIMEOUT_MS, SCORE_STALL_MS } from "../stream.js";
|
|
3
|
+
import { buildFallbackInfo } from "../fallback.js";
|
|
4
|
+
import { getViaRoute } from "../../bench/via-routes.js";
|
|
5
|
+
import { loadProviderKeys } from "../../state.js";
|
|
6
|
+
import { SHARE_KEYS_HEADER } from "../../providers/share-keys.js";
|
|
7
|
+
import { errMsg } from "../helpers.js";
|
|
8
|
+
|
|
9
|
+
function shortLabel(p) {
|
|
10
|
+
const raw = String(p?.name || p?.id || p?.url || "").trim();
|
|
11
|
+
if (!raw) return "";
|
|
12
|
+
if (raw.includes("://")) {
|
|
13
|
+
try { const u = new URL(raw); return `${u.hostname}${u.port ? `:${u.port}` : ""}`; } catch { return raw.slice(-16); }
|
|
14
|
+
}
|
|
15
|
+
return raw;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function handleViaRoute({
|
|
19
|
+
model,
|
|
20
|
+
body,
|
|
21
|
+
peers,
|
|
22
|
+
handlerCtx,
|
|
23
|
+
evt,
|
|
24
|
+
logCall,
|
|
25
|
+
logError,
|
|
26
|
+
mark,
|
|
27
|
+
perf0,
|
|
28
|
+
stages,
|
|
29
|
+
startedAt,
|
|
30
|
+
plugins,
|
|
31
|
+
res,
|
|
32
|
+
requested,
|
|
33
|
+
useAuto,
|
|
34
|
+
lockModel,
|
|
35
|
+
auto,
|
|
36
|
+
}) {
|
|
37
|
+
const route = getViaRoute(model);
|
|
38
|
+
if (!route || !route.best || route.best === "direct" || !String(route.best).startsWith("via:")) return { handled: false };
|
|
39
|
+
const peerLabel = String(route.best).slice(4).trim();
|
|
40
|
+
if (!peerLabel) return { handled: false };
|
|
41
|
+
// 找到对应该 label 的 peer(仅走 best 单路径,不并发)
|
|
42
|
+
const ordered = (() => { try { return peers.ordered(); } catch { return []; } })();
|
|
43
|
+
const byErr = (() => { try { return peers.orderedByLastError(); } catch { return []; } })();
|
|
44
|
+
const all = [...ordered, ...byErr];
|
|
45
|
+
const uniq = [];
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
for (const p of all) { const k = p.url; if (!seen.has(k)) { seen.add(k); uniq.push(p); } }
|
|
48
|
+
let peer = uniq.find((p) => shortLabel(p) === peerLabel || String(p.url || "").includes(peerLabel) || String(p.id || "") === peerLabel || String(p.name || "") === peerLabel);
|
|
49
|
+
if (!peer) {
|
|
50
|
+
try {
|
|
51
|
+
const { loadPeers } = await import("../../state.js");
|
|
52
|
+
const disk = loadPeers() || [];
|
|
53
|
+
peer = disk.find((p) => shortLabel(p) === peerLabel || String(p.url || "").includes(peerLabel));
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
if (!peer) {
|
|
57
|
+
evt("via-route-miss", { reqId: handlerCtx.reqId, model, peerLabel, reason: "peer not found" });
|
|
58
|
+
return { handled: false };
|
|
59
|
+
}
|
|
60
|
+
evt("via-route-hit", { reqId: handlerCtx.reqId, model, peer: peer.url, peerLabel, routeBest: route.best, at: route.at });
|
|
61
|
+
// 单路径转发,不并发:直接打 best peer
|
|
62
|
+
const hops = handlerCtx.hops || 0;
|
|
63
|
+
const providerId = String(model).split("/")[0] || "";
|
|
64
|
+
let shareHeader = null;
|
|
65
|
+
try {
|
|
66
|
+
const keys = loadProviderKeys(providerId) || [];
|
|
67
|
+
if (keys.length) shareHeader = `${providerId}=${keys.join(",")}`;
|
|
68
|
+
} catch {}
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
const timer = setTimeout(() => controller.abort(new Error("via-route timeout 30000ms")), 30000);
|
|
71
|
+
let upRes;
|
|
72
|
+
try {
|
|
73
|
+
const headers = {
|
|
74
|
+
"Content-Type": "application/json",
|
|
75
|
+
"Authorization": `Bearer ${peer.token || ""}`,
|
|
76
|
+
"x-mslxdff-hops": String(hops + 1),
|
|
77
|
+
"x-mslxdff-model-lock": model,
|
|
78
|
+
"Accept": "text/event-stream",
|
|
79
|
+
};
|
|
80
|
+
if (shareHeader) headers[SHARE_KEYS_HEADER] = shareHeader;
|
|
81
|
+
// workbuddyUid 透传
|
|
82
|
+
if (handlerCtx.workbuddyUid) headers["x-mslxdff-workbuddy-uid"] = handlerCtx.workbuddyUid;
|
|
83
|
+
evt("via-route-request", { reqId: handlerCtx.reqId, peer: peer.url, model, hops: hops + 1, hasShare: Boolean(shareHeader) });
|
|
84
|
+
upRes = await fetch(`${String(peer.url).replace(/\/+$/, "")}/v1/chat/completions`, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers,
|
|
87
|
+
body: JSON.stringify({ ...body, model }),
|
|
88
|
+
signal: controller.signal,
|
|
89
|
+
});
|
|
90
|
+
} catch (e) {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
const msg = errMsg(e);
|
|
93
|
+
evt("via-route-error", { reqId: handlerCtx.reqId, peer: peer.url, model, error: msg });
|
|
94
|
+
try { await peers.recordError(peer.url); } catch {}
|
|
95
|
+
try { await peers.recordResult(peer.url, { ok: false }); } catch {}
|
|
96
|
+
return { handled: false, lastErr: { model, status: 502, message: msg } };
|
|
97
|
+
}
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
const failed = upRes instanceof Error || upRes.status >= 400;
|
|
100
|
+
if (failed) {
|
|
101
|
+
const status = upRes instanceof Error ? 502 : upRes.status;
|
|
102
|
+
let bodyText = "";
|
|
103
|
+
try { bodyText = await upRes.clone().text(); } catch {}
|
|
104
|
+
const msg = bodyText.slice(0, 300) || errMsg(upRes) || `peer ${status}`;
|
|
105
|
+
evt("via-route-peer-error", { reqId: handlerCtx.reqId, peer: peer.url, model, status, message: msg.slice(0, 200) });
|
|
106
|
+
try { await peers.recordError(peer.url); } catch {}
|
|
107
|
+
try { await peers.recordResult(peer.url, { ok: false }); } catch {}
|
|
108
|
+
// 502/429 等可 fallback 到 direct
|
|
109
|
+
return { handled: false, lastErr: { model, upstream: upRes, status, message: msg } };
|
|
110
|
+
}
|
|
111
|
+
// 成功:记录并走 pipeline 中继(复用 peer 的 streaming 逻辑)
|
|
112
|
+
try { await peers.recordResult(peer.url, { ok: true, latencyMs: 0, model }); } catch {}
|
|
113
|
+
evt("via-route-win", { reqId: handlerCtx.reqId, peer: peer.url, model });
|
|
114
|
+
const pipeline = createRelayPipeline({
|
|
115
|
+
relay,
|
|
116
|
+
buildFallbackInfo,
|
|
117
|
+
auto,
|
|
118
|
+
plugins,
|
|
119
|
+
evt,
|
|
120
|
+
mark,
|
|
121
|
+
logCall,
|
|
122
|
+
logError: logError || (() => {}),
|
|
123
|
+
constants: { STREAM_TIMEOUT_MS, SLOW_TOTAL_MS, STALL_TIMEOUT_MS, SCORE_STALL_MS },
|
|
124
|
+
startedAt,
|
|
125
|
+
stages,
|
|
126
|
+
});
|
|
127
|
+
await pipeline.execute({
|
|
128
|
+
res,
|
|
129
|
+
upRes,
|
|
130
|
+
body,
|
|
131
|
+
requested: requested ?? model,
|
|
132
|
+
actual: model,
|
|
133
|
+
lastErr: null,
|
|
134
|
+
via: "peer",
|
|
135
|
+
lockModel: lockModel || model,
|
|
136
|
+
useAuto: Boolean(useAuto),
|
|
137
|
+
handlerCtx: { ...handlerCtx, model },
|
|
138
|
+
mark,
|
|
139
|
+
perf0,
|
|
140
|
+
stages,
|
|
141
|
+
startedAt,
|
|
142
|
+
});
|
|
143
|
+
return { handled: true };
|
|
144
|
+
}
|