mslxdff 0.1.83 → 0.1.85
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/cline-bench.js +54 -0
- package/src/bench/via-routes.js +87 -0
- package/src/bench/via.js +16 -4
- package/src/bench/workbuddy-bench.js +70 -0
- package/src/cli/commands/provider/bench-via.js +183 -0
- package/src/cli/commands/provider/bench.js +13 -211
- 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,54 @@
|
|
|
1
|
+
import { clineHeaders } from "../providers/cline/headers.js";
|
|
2
|
+
import { computeMetrics } from "../metrics.js";
|
|
3
|
+
|
|
4
|
+
export async function clineBenchOne({ baseUrl, model, accessToken, prompt, maxTokens, timeoutMs, fetchImpl }) {
|
|
5
|
+
const controller = new AbortController();
|
|
6
|
+
const timer = setTimeout(() => controller.abort(new Error(`timeout ${timeoutMs}ms`)), timeoutMs);
|
|
7
|
+
const t0 = performance.now();
|
|
8
|
+
let ttfbMs = null;
|
|
9
|
+
let content = "";
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetchImpl(`${baseUrl}/api/v1/chat/completions`, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: { ...clineHeaders(`sess_bench_${Date.now()}`, accessToken), Accept: "text/event-stream" },
|
|
14
|
+
body: JSON.stringify({ model, messages: [{ role: "user", content: prompt }], stream: true, max_tokens: maxTokens, session_id: `sess_bench_${Date.now()}`, reasoning_effort: "high" }),
|
|
15
|
+
signal: controller.signal,
|
|
16
|
+
});
|
|
17
|
+
if (res instanceof Error) throw res;
|
|
18
|
+
if (!res.ok) {
|
|
19
|
+
let txt = "";
|
|
20
|
+
try { txt = await res.text(); } catch {}
|
|
21
|
+
const label = res.status === 401 ? "鉴权失败" : res.status === 429 ? "限流" : res.status >= 500 ? `上游错误 ${res.status}` : `HTTP ${res.status}`;
|
|
22
|
+
return { id: model, ok: false, status: res.status, label, error: txt.slice(0, 300), ttfbMs, totalMs: Math.round(performance.now() - t0), tps: null, charsPerSec: null, tokens: null };
|
|
23
|
+
}
|
|
24
|
+
const reader = res.body.getReader();
|
|
25
|
+
const decoder = new TextDecoder();
|
|
26
|
+
let buf = "";
|
|
27
|
+
for (;;) {
|
|
28
|
+
const { done, value } = await reader.read();
|
|
29
|
+
if (done) break;
|
|
30
|
+
if (ttfbMs === null) ttfbMs = Math.round(performance.now() - t0);
|
|
31
|
+
buf += decoder.decode(value, { stream: true });
|
|
32
|
+
let idx;
|
|
33
|
+
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
34
|
+
const line = buf.slice(0, idx);
|
|
35
|
+
buf = buf.slice(idx + 1);
|
|
36
|
+
if (!line.startsWith("data:")) continue;
|
|
37
|
+
const payload = line.slice(5).trim();
|
|
38
|
+
if (!payload || payload === "[DONE]") continue;
|
|
39
|
+
try {
|
|
40
|
+
const j = JSON.parse(payload);
|
|
41
|
+
const c = j?.choices?.[0]?.delta?.content || j?.choices?.[0]?.message?.content || "";
|
|
42
|
+
if (typeof c === "string") content += c;
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const totalMs = Math.round(performance.now() - t0);
|
|
47
|
+
const chars = content.length;
|
|
48
|
+
const { tps, charsPerSec } = computeMetrics({ ttfbMs, totalMs, promptTokens: null, completionTokens: null, chars });
|
|
49
|
+
return { id: model, ok: true, status: 200, label: "成功", ttfbMs, totalMs, tps, charsPerSec, tokens: null, chars };
|
|
50
|
+
} catch (e) {
|
|
51
|
+
const msg = e?.message || String(e);
|
|
52
|
+
return { id: model, ok: false, label: /timeout|abort/i.test(msg) ? "超时" : "网络错误", error: msg.slice(0, 300), ttfbMs, totalMs: Math.round(performance.now() - t0), tps: null, charsPerSec: null, tokens: null };
|
|
53
|
+
} finally { clearTimeout(timer); }
|
|
54
|
+
}
|
|
@@ -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
|
+
}
|
package/src/bench/via.js
CHANGED
|
@@ -72,6 +72,8 @@ export async function orchestrateVia({
|
|
|
72
72
|
token,
|
|
73
73
|
timeoutMs = 30000,
|
|
74
74
|
clock = Date.now,
|
|
75
|
+
onProgress = null,
|
|
76
|
+
delayMs = 0,
|
|
75
77
|
} = {}) {
|
|
76
78
|
const filtered = includeOpencode ? models : models.filter((m) => {
|
|
77
79
|
const s = typeof m === "string" ? m : (m.id || m.model || m.provider || "");
|
|
@@ -87,17 +89,23 @@ export async function orchestrateVia({
|
|
|
87
89
|
return { provider, model: id, id };
|
|
88
90
|
});
|
|
89
91
|
const results = [];
|
|
90
|
-
|
|
92
|
+
const total = normModels.length;
|
|
93
|
+
for (let idx = 0; idx < normModels.length; idx++) {
|
|
94
|
+
const entry = normModels[idx];
|
|
91
95
|
const provider = entry.provider;
|
|
92
96
|
const model = entry.model;
|
|
93
|
-
|
|
97
|
+
const seq = idx + 1;
|
|
98
|
+
// direct —— 串行,完成后立刻回调供实时进度
|
|
94
99
|
let direct = null;
|
|
95
100
|
if (directRunner) {
|
|
96
101
|
try { direct = await directRunner({ provider, model, timeoutMs, clock }); } catch (e) { direct = { ok: false, label: "网络错误", error: String(e), ttfbMs: null, totalMs: 0 }; }
|
|
97
102
|
} else {
|
|
98
103
|
direct = { ok: false, label: "未配置 directRunner", error: "missing directRunner", ttfbMs: null, totalMs: 0 };
|
|
99
104
|
}
|
|
105
|
+
if (typeof onProgress === "function") try { await onProgress({ phase: "direct", provider, model, seq, total, result: direct }); } catch {}
|
|
106
|
+
if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
|
|
100
107
|
const via = {};
|
|
108
|
+
// 串行逐个 peer,避免 A/B/C 同时打同一上游并发限流
|
|
101
109
|
for (const peer of peers) {
|
|
102
110
|
const raw = String(peer.name || peer.id || peer.url || "peer");
|
|
103
111
|
let peerId = raw;
|
|
@@ -106,12 +114,16 @@ export async function orchestrateVia({
|
|
|
106
114
|
try { const u = new URL(raw); const host = u.hostname; const port = u.port ? `:${u.port}` : ""; if (host) peerId = `${host}${port}`; else peerId = raw.slice(-16); } catch { peerId = raw.slice(-16); }
|
|
107
115
|
}
|
|
108
116
|
const peerToken = peer.token || token || "";
|
|
117
|
+
let r;
|
|
109
118
|
try {
|
|
110
|
-
|
|
119
|
+
r = await viaProbeFn({ peerUrl: peer.url, token: peerToken, providerId: provider, model, prompt: "hi", maxTokens: 5, timeoutMs, clock });
|
|
111
120
|
via[peerId] = r;
|
|
112
121
|
} catch (e) {
|
|
113
|
-
|
|
122
|
+
r = { ok: false, label: "网络错误", error: String(e?.message || e), ttfbMs: null, totalMs: 0 };
|
|
123
|
+
via[peerId] = r;
|
|
114
124
|
}
|
|
125
|
+
if (typeof onProgress === "function") try { await onProgress({ phase: "via", provider, model, seq, total, peerId, result: r }); } catch {}
|
|
126
|
+
if (delayMs) await new Promise((rr) => setTimeout(rr, delayMs));
|
|
115
127
|
}
|
|
116
128
|
// best
|
|
117
129
|
let best = "direct";
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { defaultChatPath } from "../../../state/provider-config.js";
|
|
2
|
+
import { isRefreshToken, clineHeaders } from "../../../providers/cline/headers.js";
|
|
3
|
+
import { refreshTokenForBase } from "../../../providers/cline/auth.js";
|
|
4
|
+
import { runOne } from "../../../bench/runner.js";
|
|
5
|
+
import { formatViaReport } from "../../../bench/report.js";
|
|
6
|
+
import { clineBenchOne } from "../../../bench/cline-bench.js";
|
|
7
|
+
import { workbuddyBenchOne } from "../../../bench/workbuddy-bench.js";
|
|
8
|
+
|
|
9
|
+
export function buildHeadersForProvider(providerId, apiKey, auth) {
|
|
10
|
+
const h = {};
|
|
11
|
+
if (String(providerId).toLowerCase() === "workbuddy") {
|
|
12
|
+
h["Content-Type"] = "application/json";
|
|
13
|
+
h["Accept"] = "text/event-stream";
|
|
14
|
+
h["User-Agent"] = "CLI/2.115.0 WorkBuddy/2.115.0";
|
|
15
|
+
h["Origin"] = "https://www.codebuddy.cn";
|
|
16
|
+
h["Referer"] = "https://www.codebuddy.cn/";
|
|
17
|
+
h["X-Product"] = "SaaS";
|
|
18
|
+
if (apiKey) h["Authorization"] = `Bearer ${apiKey}`;
|
|
19
|
+
if (auth?.uid) h["X-User-Id"] = auth.uid;
|
|
20
|
+
h["X-Domain"] = auth?.domain || "www.codebuddy.cn";
|
|
21
|
+
if (auth?.enterpriseId) { h["X-Enterprise-Id"] = auth.enterpriseId; h["X-Tenant-Id"] = auth.enterpriseId; }
|
|
22
|
+
return h;
|
|
23
|
+
}
|
|
24
|
+
if (apiKey) h["Authorization"] = `Bearer ${apiKey}`;
|
|
25
|
+
return h;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function handleVia({ providerId, opts, fetchImpl, loadConfigs, loadKeys, loadAllowed, loadBaseUrl }) {
|
|
29
|
+
const { getOnlinePeers, orchestrateVia, resolveIncludeOpencode } = await import("../../../bench/via.js");
|
|
30
|
+
const peers = await getOnlinePeers();
|
|
31
|
+
if (!peers.length) {
|
|
32
|
+
const msg = "未加入组或无在线 peer,--via 无意义。先 mslxdff -group list / -addtogroup";
|
|
33
|
+
if (opts.json) console.log(JSON.stringify({ meta: { at: new Date().toISOString(), samples: opts.samples, timeout: opts.timeoutMs, includeOpencode: false, peers: [], opencodeSkipped: true }, results: [], advice: msg }, null, 2));
|
|
34
|
+
else console.log(msg);
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
38
|
+
let includeOpencode = opts.includeOpencode;
|
|
39
|
+
if (includeOpencode) {
|
|
40
|
+
const confirmFn = async () => {
|
|
41
|
+
const readline = await import("node:readline");
|
|
42
|
+
return new Promise((res) => {
|
|
43
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
44
|
+
rl.question("将消耗 B/C/D 的 opencode 额度,确认测 opencode via?y/N ", (ans) => { rl.close(); res(ans.trim().toLowerCase() === "y"); });
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
const log = (s) => (opts.json ? console.error(s) : console.log(s));
|
|
48
|
+
includeOpencode = await resolveIncludeOpencode({ includeOpencode, isTTY, confirmFn, log });
|
|
49
|
+
}
|
|
50
|
+
const { loadToken } = await import("../../../state.js");
|
|
51
|
+
let token = "";
|
|
52
|
+
try { token = (await loadToken()).token || ""; } catch {}
|
|
53
|
+
let targetIds = [];
|
|
54
|
+
const isAll = providerId === "bench" || providerId === "all";
|
|
55
|
+
if (isAll) {
|
|
56
|
+
const configs = loadConfigs();
|
|
57
|
+
const ids = new Set(Object.keys(configs));
|
|
58
|
+
ids.add("opencode"); ids.add("openrouter");
|
|
59
|
+
for (const pid of [...ids]) {
|
|
60
|
+
const allowed = loadAllowed(pid) || [];
|
|
61
|
+
if (allowed.length) targetIds.push(pid);
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const { readFileSync } = await import("node:fs");
|
|
65
|
+
const { defaultStateFile } = await import("../../../state.js");
|
|
66
|
+
const raw = JSON.parse(readFileSync(defaultStateFile(), "utf8"));
|
|
67
|
+
for (const k of Object.keys(raw.providerConfigs || {})) if (!targetIds.includes(k) && (loadAllowed(k) || []).length) targetIds.push(k);
|
|
68
|
+
for (const k of Object.keys(raw.providerKeys || {})) if (!targetIds.includes(k) && (loadAllowed(k) || []).length) targetIds.push(k);
|
|
69
|
+
} catch {}
|
|
70
|
+
if (!targetIds.length) targetIds = ["openrouter", "workbuddy", "clinebot"].filter((p) => (loadAllowed(p) || []).length);
|
|
71
|
+
} else {
|
|
72
|
+
targetIds = [providerId];
|
|
73
|
+
}
|
|
74
|
+
const allResults = [];
|
|
75
|
+
const viaLog = (s) => (opts.json ? console.error(s) : console.log(s));
|
|
76
|
+
viaLog(`bench-via: direct vs via peers (samples=${opts.samples}, timeout=${opts.timeoutMs}ms, ${includeOpencode ? "opencode=included" : "opencode=skipped"}) peers=${peers.map((p) => p.id).join(",")} 模式: 串行(直连→逐 peer)`);
|
|
77
|
+
const delayMs = Number(process.env.MSLXDFF_BENCH_DELAY_MS || 120) || 0;
|
|
78
|
+
for (const pid of targetIds) {
|
|
79
|
+
const cfg = (loadConfigs()[pid] || {});
|
|
80
|
+
const keys = loadKeys(pid) || [];
|
|
81
|
+
const allowed = loadAllowed(pid) || [];
|
|
82
|
+
const baseUrl = (loadBaseUrl(pid) || cfg.baseUrl || "").trim();
|
|
83
|
+
if (!allowed.length) { viaLog(`provider ${pid}: 无勾选模型,跳过`); continue; }
|
|
84
|
+
if (!baseUrl && pid !== "opencode") { viaLog(`provider ${pid}: missing baseUrl 跳过`); continue; }
|
|
85
|
+
if (!keys.length && pid !== "opencode") { viaLog(`provider ${pid}: 未配置 Key 跳过`); continue; }
|
|
86
|
+
const chatPath = cfg.chatPath || defaultChatPath(pid);
|
|
87
|
+
let auths = [];
|
|
88
|
+
try { const m = await import("../../../state.js"); auths = m.loadProviderAuths ? m.loadProviderAuths(pid) : []; } catch {}
|
|
89
|
+
const models = allowed.map((id) => ({ provider: pid, model: String(id), id: String(id) }));
|
|
90
|
+
const directRunner = async ({ provider, model }) => {
|
|
91
|
+
const p = provider || pid;
|
|
92
|
+
const idx = models.findIndex((x) => x.model === model);
|
|
93
|
+
const kIdx = idx >= 0 ? idx % (keys.length || 1) : 0;
|
|
94
|
+
const aIdx = Math.min(kIdx, Math.max(auths.length - 1, 0));
|
|
95
|
+
const key = keys[kIdx] || keys[0] || "";
|
|
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
|
+
}
|
|
100
|
+
const cKeys = keys.filter((k) => isRefreshToken(k, p));
|
|
101
|
+
if (cKeys.length) {
|
|
102
|
+
const normBase = String(baseUrl).replace(/\/+$/, "");
|
|
103
|
+
const chatBase = normBase.endsWith("/api/v1") ? normBase.slice(0, -7) : normBase;
|
|
104
|
+
const rt = cKeys[kIdx % cKeys.length];
|
|
105
|
+
const at = await refreshTokenForBase({ refreshToken: rt, baseUrl: normBase, fetchImpl });
|
|
106
|
+
if (!at) return { ok: false, label: "鉴权失败", error: "refresh failed", ttfbMs: null, totalMs: 0 };
|
|
107
|
+
return clineBenchOne({ baseUrl: chatBase, model, accessToken: at, prompt: "hi", maxTokens: 5, timeoutMs: opts.timeoutMs, fetchImpl });
|
|
108
|
+
}
|
|
109
|
+
const headers = buildHeadersForProvider(p, key, auth);
|
|
110
|
+
return runOne({ baseUrl: p === "opencode" ? (process.env.UPSTREAM_BASE_URL || "https://opencode.ai") : baseUrl, chatPath, model, providerId: p, apiKey: key || "public", headers, prompt: "hi", maxTokens: 5, timeoutMs: opts.timeoutMs, fetchImpl });
|
|
111
|
+
};
|
|
112
|
+
const { viaProbe } = await import("../../../bench/via-probe.js");
|
|
113
|
+
const viaProbeFn = async (args) => {
|
|
114
|
+
const { peerUrl, providerId, model } = args;
|
|
115
|
+
const p = providerId || pid;
|
|
116
|
+
const idx = models.findIndex((x) => x.model === model);
|
|
117
|
+
const kIdx = idx >= 0 ? idx % (keys.length || 1) : 0;
|
|
118
|
+
const aIdx = Math.min(kIdx, Math.max(auths.length - 1, 0));
|
|
119
|
+
const key = keys[kIdx] || keys[0] || "";
|
|
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
|
+
}
|
|
131
|
+
const cKeys = keys.filter((k) => isRefreshToken(k, p));
|
|
132
|
+
if (cKeys.length) {
|
|
133
|
+
const normBase = String(baseUrl).replace(/\/+$/, "");
|
|
134
|
+
const chatBase = normBase.endsWith("/api/v1") ? normBase.slice(0, -7) : normBase;
|
|
135
|
+
const rt = cKeys[0];
|
|
136
|
+
const at = await refreshTokenForBase({ refreshToken: rt, baseUrl: normBase, fetchImpl });
|
|
137
|
+
if (!at) return { ok: false, label: "鉴权失败", error: "refresh failed", ttfbMs: null, totalMs: 0 };
|
|
138
|
+
const targetUrl = `${chatBase}/api/v1/chat/completions`;
|
|
139
|
+
const headers = clineHeaders(`sess_bench_via_${Date.now()}`, at);
|
|
140
|
+
const rawModel = String(model).startsWith(`${p}/`) ? String(model).slice(p.length + 1) : String(model);
|
|
141
|
+
const body = { model: rawModel, messages: [{ role: "user", content: "hi" }], stream: true, max_tokens: 5, session_id: `sess_bench_via_${Date.now()}`, reasoning_effort: "high" };
|
|
142
|
+
const peer = peers.find((pe) => pe.url === peerUrl || (pe.name || pe.id) === peerUrl);
|
|
143
|
+
const peerToken = peer?.token || token;
|
|
144
|
+
return viaProbe({ peerUrl, token: peerToken, relayTarget: targetUrl, relayHeaders: headers, relayBody: body, timeoutMs: opts.timeoutMs, fetchImpl });
|
|
145
|
+
}
|
|
146
|
+
const rawModel = String(model).startsWith(`${p}/`) ? String(model).slice(p.length + 1) : String(model);
|
|
147
|
+
const targetUrl = p === "opencode" ? `${process.env.UPSTREAM_BASE_URL || "https://opencode.ai"}/zen/v1/chat/completions` : `${String(baseUrl).replace(/\/+$/, "")}${chatPath}`;
|
|
148
|
+
const headers = buildHeadersForProvider(p, key, auth);
|
|
149
|
+
const body = { model: rawModel, stream: false, messages: [{ role: "user", content: "hi" }], max_tokens: 5 };
|
|
150
|
+
const peer = peers.find((pe) => pe.url === peerUrl || (pe.name || pe.id) === peerUrl);
|
|
151
|
+
const peerToken = peer?.token || token;
|
|
152
|
+
return viaProbe({ peerUrl, token: peerToken, relayTarget: targetUrl, relayHeaders: headers, relayBody: body, timeoutMs: opts.timeoutMs, fetchImpl });
|
|
153
|
+
};
|
|
154
|
+
if (!opts.json) viaLog(`\n[${pid}] 共 ${models.length} 个模型,串行测试中...`);
|
|
155
|
+
else viaLog(`[${pid}] ${models.length} models sequential`);
|
|
156
|
+
const onProgress = async ({ phase, provider, model, seq, total, peerId, result }) => {
|
|
157
|
+
const short = String(model).length > 28 ? String(model).slice(0, 28) : String(model);
|
|
158
|
+
const ms = result?.ttfbMs != null ? `${result.ttfbMs}ms` : result?.totalMs != null ? `${result.totalMs}ms` : "—";
|
|
159
|
+
const okTag = result?.ok ? "成功" : (result?.label || "失败");
|
|
160
|
+
const extra = result?.ok ? "" : result?.error ? ` (${String(result.error).slice(0, 40)})` : "";
|
|
161
|
+
if (phase === "direct") viaLog(` [${seq}/${total}] ${provider}/${short} 直连 ${ms} ${okTag}${extra} 完成`);
|
|
162
|
+
else viaLog(` ↳ via ${peerId} ${ms} ${okTag}${extra}`);
|
|
163
|
+
};
|
|
164
|
+
const { orchestrateVia } = await import("../../../bench/via.js");
|
|
165
|
+
const part = await orchestrateVia({ models, peers, directRunner, viaProbeFn, includeOpencode, token, timeoutMs: opts.timeoutMs, onProgress, delayMs });
|
|
166
|
+
allResults.push(...part);
|
|
167
|
+
if (!opts.json) viaLog(` ${pid}: ${part.length} 模型完成 ✓`);
|
|
168
|
+
}
|
|
169
|
+
const meta = { at: new Date().toISOString(), samples: opts.samples, timeout: opts.timeoutMs, includeOpencode, peers: peers.map((p) => p.id), opencodeSkipped: !includeOpencode };
|
|
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
|
+
}
|
|
180
|
+
if (opts.json) console.log(report.text);
|
|
181
|
+
else console.log("\n" + report.text);
|
|
182
|
+
process.exit(0);
|
|
183
|
+
}
|
|
@@ -1,69 +1,20 @@
|
|
|
1
1
|
import { probeModels } from "../../../bench/probe.js";
|
|
2
2
|
import { runOne } from "../../../bench/runner.js";
|
|
3
|
-
import { formatReport
|
|
3
|
+
import { formatReport } from "../../../bench/report.js";
|
|
4
4
|
import { defaultModelsPath, defaultChatPath } from "../../../state/provider-config.js";
|
|
5
|
-
import { isRefreshToken
|
|
5
|
+
import { isRefreshToken } from "../../../providers/cline/headers.js";
|
|
6
6
|
import { refreshTokenForBase } from "../../../providers/cline/auth.js";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const controller = new AbortController();
|
|
11
|
-
const timer = setTimeout(() => controller.abort(new Error(`timeout ${timeoutMs}ms`)), timeoutMs);
|
|
12
|
-
const t0 = performance.now();
|
|
13
|
-
let ttfbMs = null;
|
|
14
|
-
let content = "";
|
|
15
|
-
try {
|
|
16
|
-
const res = await fetchImpl(`${baseUrl}/api/v1/chat/completions`, {
|
|
17
|
-
method: "POST",
|
|
18
|
-
headers: { ...clineHeaders(`sess_bench_${Date.now()}`, accessToken), Accept: "text/event-stream" },
|
|
19
|
-
body: JSON.stringify({ model, messages: [{ role: "user", content: prompt }], stream: true, max_tokens: maxTokens, session_id: `sess_bench_${Date.now()}`, reasoning_effort: "high" }),
|
|
20
|
-
signal: controller.signal,
|
|
21
|
-
});
|
|
22
|
-
if (res instanceof Error) throw res;
|
|
23
|
-
if (!res.ok) {
|
|
24
|
-
let txt = "";
|
|
25
|
-
try { txt = await res.text(); } catch {}
|
|
26
|
-
const label = res.status === 401 ? "鉴权失败" : res.status === 429 ? "限流" : res.status >= 500 ? `上游错误 ${res.status}` : `HTTP ${res.status}`;
|
|
27
|
-
return { id: model, ok: false, status: res.status, label, error: txt.slice(0, 300), ttfbMs, totalMs: Math.round(performance.now() - t0), tps: null, charsPerSec: null, tokens: null };
|
|
28
|
-
}
|
|
29
|
-
const reader = res.body.getReader();
|
|
30
|
-
const decoder = new TextDecoder();
|
|
31
|
-
let buf = "";
|
|
32
|
-
for (;;) {
|
|
33
|
-
const { done, value } = await reader.read();
|
|
34
|
-
if (done) break;
|
|
35
|
-
if (ttfbMs === null) ttfbMs = Math.round(performance.now() - t0);
|
|
36
|
-
buf += decoder.decode(value, { stream: true });
|
|
37
|
-
let idx;
|
|
38
|
-
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
39
|
-
const line = buf.slice(0, idx);
|
|
40
|
-
buf = buf.slice(idx + 1);
|
|
41
|
-
if (!line.startsWith("data:")) continue;
|
|
42
|
-
const payload = line.slice(5).trim();
|
|
43
|
-
if (!payload || payload === "[DONE]") continue;
|
|
44
|
-
try {
|
|
45
|
-
const j = JSON.parse(payload);
|
|
46
|
-
const c = j?.choices?.[0]?.delta?.content || j?.choices?.[0]?.message?.content || "";
|
|
47
|
-
if (typeof c === "string") content += c;
|
|
48
|
-
} catch {}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
const totalMs = Math.round(performance.now() - t0);
|
|
52
|
-
const chars = content.length;
|
|
53
|
-
const { tps, charsPerSec } = computeMetrics({ ttfbMs, totalMs, promptTokens: null, completionTokens: null, chars });
|
|
54
|
-
return { id: model, ok: true, status: 200, label: "成功", ttfbMs, totalMs, tps, charsPerSec, tokens: null, chars };
|
|
55
|
-
} catch (e) {
|
|
56
|
-
const msg = e?.message || String(e);
|
|
57
|
-
return { id: model, ok: false, label: /timeout|abort/i.test(msg) ? "超时" : "网络错误", error: msg.slice(0, 300), ttfbMs, totalMs: Math.round(performance.now() - t0), tps: null, charsPerSec: null, tokens: null };
|
|
58
|
-
} finally { clearTimeout(timer); }
|
|
59
|
-
}
|
|
7
|
+
import { clineBenchOne } from "../../../bench/cline-bench.js";
|
|
8
|
+
import { workbuddyBenchOne } from "../../../bench/workbuddy-bench.js";
|
|
9
|
+
import { buildHeadersForProvider, handleVia } from "./bench-via.js";
|
|
60
10
|
|
|
61
11
|
function parseBenchArgs(rest) {
|
|
62
|
-
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 };
|
|
63
13
|
for (let i = 0; i < rest.length; i++) {
|
|
64
14
|
const a = rest[i];
|
|
65
15
|
if (a === "--json" || a === "-json") opts.json = true;
|
|
66
16
|
else if (a === "--via" || a === "--bench-via") opts.via = true;
|
|
17
|
+
else if (a === "--apply" || a === "--write" || a === "--save") opts.apply = true;
|
|
67
18
|
else if (a === "--include-opencode") opts.includeOpencode = true;
|
|
68
19
|
else if (a === "--samples" && rest[i + 1]) opts.samples = Number(rest[++i]) || 1;
|
|
69
20
|
else if (a.startsWith("--samples=")) opts.samples = Number(a.split("=")[1]) || 1;
|
|
@@ -77,160 +28,6 @@ function parseBenchArgs(rest) {
|
|
|
77
28
|
return opts;
|
|
78
29
|
}
|
|
79
30
|
|
|
80
|
-
function buildHeadersForProvider(providerId, apiKey, auth) {
|
|
81
|
-
const h = {};
|
|
82
|
-
if (String(providerId).toLowerCase() === "workbuddy") {
|
|
83
|
-
h["Content-Type"] = "application/json";
|
|
84
|
-
h["Accept"] = "text/event-stream";
|
|
85
|
-
h["User-Agent"] = "CLI/2.115.0 WorkBuddy/2.115.0";
|
|
86
|
-
h["Origin"] = "https://www.codebuddy.cn";
|
|
87
|
-
h["Referer"] = "https://www.codebuddy.cn/";
|
|
88
|
-
h["X-Product"] = "SaaS";
|
|
89
|
-
if (apiKey) h["Authorization"] = `Bearer ${apiKey}`;
|
|
90
|
-
if (auth?.uid) h["X-User-Id"] = auth.uid;
|
|
91
|
-
h["X-Domain"] = auth?.domain || "www.codebuddy.cn";
|
|
92
|
-
if (auth?.enterpriseId) { h["X-Enterprise-Id"] = auth.enterpriseId; h["X-Tenant-Id"] = auth.enterpriseId; }
|
|
93
|
-
return h;
|
|
94
|
-
}
|
|
95
|
-
if (apiKey) h["Authorization"] = `Bearer ${apiKey}`;
|
|
96
|
-
return h;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async function handleVia({ providerId, opts, fetchImpl, loadConfigs, loadKeys, loadAllowed, loadBaseUrl }) {
|
|
100
|
-
const { getOnlinePeers, orchestrateVia, resolveIncludeOpencode } = await import("../../../bench/via.js");
|
|
101
|
-
const peers = await getOnlinePeers();
|
|
102
|
-
if (!peers.length) {
|
|
103
|
-
const msg = "未加入组或无在线 peer,--via 无意义。先 mslxdff -group list / -addtogroup";
|
|
104
|
-
if (opts.json) console.log(JSON.stringify({ meta: { at: new Date().toISOString(), samples: opts.samples, timeout: opts.timeoutMs, includeOpencode: false, peers: [], opencodeSkipped: true }, results: [], advice: msg }, null, 2));
|
|
105
|
-
else console.log(msg);
|
|
106
|
-
process.exit(0);
|
|
107
|
-
}
|
|
108
|
-
const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
109
|
-
let includeOpencode = opts.includeOpencode;
|
|
110
|
-
if (includeOpencode) {
|
|
111
|
-
const confirmFn = async () => {
|
|
112
|
-
const readline = await import("node:readline");
|
|
113
|
-
return new Promise((res) => {
|
|
114
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
115
|
-
rl.question("将消耗 B/C/D 的 opencode 额度,确认测 opencode via?y/N ", (ans) => { rl.close(); res(ans.trim().toLowerCase() === "y"); });
|
|
116
|
-
});
|
|
117
|
-
};
|
|
118
|
-
const log = (s) => (opts.json ? console.error(s) : console.log(s));
|
|
119
|
-
includeOpencode = await resolveIncludeOpencode({ includeOpencode, isTTY, confirmFn, log });
|
|
120
|
-
}
|
|
121
|
-
const { loadToken } = await import("../../../state.js");
|
|
122
|
-
let token = "";
|
|
123
|
-
try { token = (await loadToken()).token || ""; } catch {}
|
|
124
|
-
// target providers
|
|
125
|
-
let targetIds = [];
|
|
126
|
-
const isAll = providerId === "bench" || providerId === "all";
|
|
127
|
-
if (isAll) {
|
|
128
|
-
const configs = loadConfigs();
|
|
129
|
-
const ids = new Set(Object.keys(configs));
|
|
130
|
-
ids.add("opencode"); ids.add("openrouter");
|
|
131
|
-
try { const m = await import("../../../state.js"); const raw = m.loadProviderKeys ? null : null; } catch {}
|
|
132
|
-
// collect from providerKeys via loadKeys probing? simple: iterate ids and keep those with allowed
|
|
133
|
-
for (const pid of [...ids]) {
|
|
134
|
-
const allowed = loadAllowed(pid) || [];
|
|
135
|
-
if (allowed.length) targetIds.push(pid);
|
|
136
|
-
else if (pid === "opencode" && !includeOpencode) continue;
|
|
137
|
-
}
|
|
138
|
-
// also check generic ids from state raw
|
|
139
|
-
try {
|
|
140
|
-
const { readFileSync } = await import("node:fs");
|
|
141
|
-
const { defaultStateFile } = await import("../../../state.js");
|
|
142
|
-
const raw = JSON.parse(readFileSync(defaultStateFile(), "utf8"));
|
|
143
|
-
for (const k of Object.keys(raw.providerConfigs || {})) if (!targetIds.includes(k) && (loadAllowed(k) || []).length) targetIds.push(k);
|
|
144
|
-
for (const k of Object.keys(raw.providerKeys || {})) if (!targetIds.includes(k) && (loadAllowed(k) || []).length) targetIds.push(k);
|
|
145
|
-
} catch {}
|
|
146
|
-
if (!targetIds.length) targetIds = ["openrouter", "workbuddy", "clinebot"].filter((p) => (loadAllowed(p) || []).length);
|
|
147
|
-
} else {
|
|
148
|
-
targetIds = [providerId];
|
|
149
|
-
}
|
|
150
|
-
const allResults = [];
|
|
151
|
-
const viaLog = (s) => (opts.json ? console.error(s) : console.log(s));
|
|
152
|
-
viaLog(`bench-via: direct vs via peers (samples=${opts.samples}, timeout=${opts.timeoutMs}ms, ${includeOpencode ? "opencode=included" : "opencode=skipped"}) peers=${peers.map((p) => p.id).join(",")}`);
|
|
153
|
-
for (const pid of targetIds) {
|
|
154
|
-
const cfg = (loadConfigs()[pid] || {});
|
|
155
|
-
const keys = loadKeys(pid) || [];
|
|
156
|
-
const allowed = loadAllowed(pid) || [];
|
|
157
|
-
const baseUrl = (loadBaseUrl(pid) || cfg.baseUrl || "").trim();
|
|
158
|
-
if (!allowed.length) {
|
|
159
|
-
viaLog(`provider ${pid}: 无勾选模型,跳过`);
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
162
|
-
if (!baseUrl && pid !== "opencode") { viaLog(`provider ${pid}: missing baseUrl 跳过`); continue; }
|
|
163
|
-
if (!keys.length && pid !== "opencode") { viaLog(`provider ${pid}: 未配置 Key 跳过`); continue; }
|
|
164
|
-
const chatPath = cfg.chatPath || defaultChatPath(pid);
|
|
165
|
-
let auths = [];
|
|
166
|
-
try { const m = await import("../../../state.js"); auths = m.loadProviderAuths ? m.loadProviderAuths(pid) : []; } catch {}
|
|
167
|
-
const models = allowed.map((id) => ({ provider: pid, model: String(id), id: String(id) }));
|
|
168
|
-
// directRunner per provider
|
|
169
|
-
const directRunner = async ({ provider, model }) => {
|
|
170
|
-
const p = provider || pid;
|
|
171
|
-
const idx = models.findIndex((x) => x.model === model);
|
|
172
|
-
const kIdx = idx >= 0 ? idx % (keys.length || 1) : 0;
|
|
173
|
-
const aIdx = Math.min(kIdx, Math.max(auths.length - 1, 0));
|
|
174
|
-
const key = keys[kIdx] || keys[0] || "";
|
|
175
|
-
const auth = auths[aIdx] || auths[0] || null;
|
|
176
|
-
const cKeys = keys.filter((k) => isRefreshToken(k, p));
|
|
177
|
-
if (cKeys.length) {
|
|
178
|
-
const normBase = String(baseUrl).replace(/\/+$/, "");
|
|
179
|
-
const chatBase = normBase.endsWith("/api/v1") ? normBase.slice(0, -7) : normBase;
|
|
180
|
-
const rt = cKeys[kIdx % cKeys.length];
|
|
181
|
-
const at = await refreshTokenForBase({ refreshToken: rt, baseUrl: normBase, fetchImpl });
|
|
182
|
-
if (!at) return { ok: false, label: "鉴权失败", error: "refresh failed", ttfbMs: null, totalMs: 0 };
|
|
183
|
-
return clineBenchOne({ baseUrl: chatBase, model, accessToken: at, prompt: "hi", maxTokens: 5, timeoutMs: opts.timeoutMs, fetchImpl });
|
|
184
|
-
}
|
|
185
|
-
const headers = buildHeadersForProvider(p, key, auth);
|
|
186
|
-
return runOne({ baseUrl: p === "opencode" ? (process.env.UPSTREAM_BASE_URL || "https://opencode.ai") : baseUrl, chatPath, model, providerId: p, apiKey: key || "public", headers, prompt: "hi", maxTokens: 5, timeoutMs: opts.timeoutMs, fetchImpl });
|
|
187
|
-
};
|
|
188
|
-
const { viaProbe } = await import("../../../bench/via-probe.js");
|
|
189
|
-
const { joinUrl: _joinUrl } = await import("../../../providers/base.js");
|
|
190
|
-
const viaProbeFn = async (args) => {
|
|
191
|
-
// 纯中继:A 把 targetUrl+headers+body 发给 B,B 原样 fetch 到上游(不查 B 本地 providerConfigs)
|
|
192
|
-
const { peerUrl, providerId, model } = args;
|
|
193
|
-
const p = providerId || pid;
|
|
194
|
-
const idx = models.findIndex((x) => x.model === model);
|
|
195
|
-
const kIdx = idx >= 0 ? idx % (keys.length || 1) : 0;
|
|
196
|
-
const aIdx = Math.min(kIdx, Math.max(auths.length - 1, 0));
|
|
197
|
-
const key = keys[kIdx] || keys[0] || "";
|
|
198
|
-
const auth = auths[aIdx] || auths[0] || null;
|
|
199
|
-
const cKeys = keys.filter((k) => isRefreshToken(k, p));
|
|
200
|
-
if (cKeys.length) {
|
|
201
|
-
const normBase = String(baseUrl).replace(/\/+$/, "");
|
|
202
|
-
const chatBase = normBase.endsWith("/api/v1") ? normBase.slice(0, -7) : normBase;
|
|
203
|
-
const rt = cKeys[0];
|
|
204
|
-
const at = await refreshTokenForBase({ refreshToken: rt, baseUrl: normBase, fetchImpl });
|
|
205
|
-
if (!at) return { ok: false, label: "鉴权失败", error: "refresh failed", ttfbMs: null, totalMs: 0 };
|
|
206
|
-
const targetUrl = `${chatBase}/api/v1/chat/completions`;
|
|
207
|
-
const headers = clineHeaders(`sess_bench_via_${Date.now()}`, at);
|
|
208
|
-
const rawModel = String(model).startsWith(`${p}/`) ? String(model).slice(p.length + 1) : String(model);
|
|
209
|
-
const body = { model: rawModel, messages: [{ role: "user", content: "hi" }], stream: true, max_tokens: 5, session_id: `sess_bench_via_${Date.now()}`, reasoning_effort: "high" };
|
|
210
|
-
const peer = peers.find((pe) => pe.url === peerUrl || (pe.name || pe.id) === peerUrl);
|
|
211
|
-
const peerToken = peer?.token || token;
|
|
212
|
-
return viaProbe({ peerUrl, token: peerToken, relayTarget: targetUrl, relayHeaders: headers, relayBody: body, timeoutMs: opts.timeoutMs, fetchImpl });
|
|
213
|
-
}
|
|
214
|
-
const rawModel = String(model).startsWith(`${p}/`) ? String(model).slice(p.length + 1) : String(model);
|
|
215
|
-
const targetUrl = p === "opencode" ? `${process.env.UPSTREAM_BASE_URL || "https://opencode.ai"}/zen/v1/chat/completions` : `${String(baseUrl).replace(/\/+$/, "")}${chatPath}`;
|
|
216
|
-
const headers = buildHeadersForProvider(p, key, auth);
|
|
217
|
-
const body = { model: rawModel, stream: false, messages: [{ role: "user", content: "hi" }], max_tokens: 5 };
|
|
218
|
-
// 用对端 peer.token 做 relay 鉴权(B 只认自己的 Bearer)
|
|
219
|
-
const peer = peers.find((pe) => pe.url === peerUrl || (pe.name || pe.id) === peerUrl);
|
|
220
|
-
const peerToken = peer?.token || token;
|
|
221
|
-
return viaProbe({ peerUrl, token: peerToken, relayTarget: targetUrl, relayHeaders: headers, relayBody: body, timeoutMs: opts.timeoutMs, fetchImpl });
|
|
222
|
-
};
|
|
223
|
-
const part = await orchestrateVia({ models, peers, directRunner, viaProbeFn, includeOpencode, token, timeoutMs: opts.timeoutMs });
|
|
224
|
-
allResults.push(...part);
|
|
225
|
-
if (!opts.json) viaLog(` ${pid}: ${part.length} 模型完成`);
|
|
226
|
-
}
|
|
227
|
-
const meta = { at: new Date().toISOString(), samples: opts.samples, timeout: opts.timeoutMs, includeOpencode, peers: peers.map((p) => p.id), opencodeSkipped: !includeOpencode };
|
|
228
|
-
const report = formatViaReport(allResults, { peers, meta, json: opts.json });
|
|
229
|
-
if (opts.json) console.log(report.text);
|
|
230
|
-
else console.log("\n" + report.text);
|
|
231
|
-
process.exit(0);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
31
|
export async function handleProviderBench(id, sub, rest, args, deps = {}) {
|
|
235
32
|
const _restArr = rest || [];
|
|
236
33
|
const _hasVia = _restArr.includes("--via") || _restArr.includes("--bench-via");
|
|
@@ -238,7 +35,6 @@ export async function handleProviderBench(id, sub, rest, args, deps = {}) {
|
|
|
238
35
|
const isBench = sub === "bench" || sub === "benchmark" || sub === "eval" || sub === "test" || _isBenchViaAll;
|
|
239
36
|
if (!isBench) return false;
|
|
240
37
|
const opts = parseBenchArgs(_restArr);
|
|
241
|
-
// via branch
|
|
242
38
|
if (opts.via) {
|
|
243
39
|
const loadConfigs = deps.loadProviderConfigs || (await import("../../../state.js")).loadProviderConfigs;
|
|
244
40
|
const loadKeys = deps.loadProviderKeys || (await import("../../../state.js")).loadProviderKeys;
|
|
@@ -301,6 +97,12 @@ export async function handleProviderBench(id, sub, rest, args, deps = {}) {
|
|
|
301
97
|
const aIdx = Math.min(kIdx, Math.max(auths.length - 1, 0));
|
|
302
98
|
const key = keys[kIdx];
|
|
303
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
|
+
}
|
|
304
106
|
if (rtKeys.length) {
|
|
305
107
|
const rt = rtKeys[kIdx % rtKeys.length];
|
|
306
108
|
const at = await refreshTokenForBase({ refreshToken: rt, baseUrl: normBase, fetchImpl });
|
|
@@ -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
|
+
}
|