mslxdff 0.1.67 → 0.1.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/bench/probe.js +68 -0
- package/src/bench/report.js +56 -0
- package/src/bench/runner.js +74 -0
- package/src/cli/commands/provider/bench.js +140 -0
- package/src/cli/commands/provider/index.js +2 -0
- package/src/metrics.js +63 -0
package/package.json
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { joinUrl } from "../providers/base.js";
|
|
2
|
+
|
|
3
|
+
function normalizeModelsPayload(json) {
|
|
4
|
+
if (!json) return [];
|
|
5
|
+
if (Array.isArray(json)) return json;
|
|
6
|
+
if (Array.isArray(json.data)) return json.data;
|
|
7
|
+
if (Array.isArray(json.models)) return json.models;
|
|
8
|
+
if (json.data && typeof json.data === "object" && Array.isArray(json.data.data)) return json.data.data;
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function toModelId(m) {
|
|
13
|
+
if (!m) return "";
|
|
14
|
+
if (typeof m === "string") return m;
|
|
15
|
+
if (typeof m.id === "string") return m.id;
|
|
16
|
+
if (typeof m.model === "string") return m.model;
|
|
17
|
+
if (typeof m.name === "string") return m.name;
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function probeModels({
|
|
22
|
+
baseUrl,
|
|
23
|
+
modelsPath,
|
|
24
|
+
chatPath,
|
|
25
|
+
headers = {},
|
|
26
|
+
fetchImpl = globalThis.fetch,
|
|
27
|
+
timeoutMs = 8000,
|
|
28
|
+
} = {}) {
|
|
29
|
+
const base = String(baseUrl || "").replace(/\/+$/, "");
|
|
30
|
+
if (!base) return { ok: false, error: "missing baseUrl", tried: [], data: [] };
|
|
31
|
+
const candidates = [];
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
const push = (p) => {
|
|
34
|
+
const s = String(p || "").trim();
|
|
35
|
+
if (!s) return;
|
|
36
|
+
const norm = s.startsWith("/") ? s : `/${s}`;
|
|
37
|
+
if (!seen.has(norm)) { seen.add(norm); candidates.push(norm); }
|
|
38
|
+
};
|
|
39
|
+
if (modelsPath) push(modelsPath);
|
|
40
|
+
// 通用回退:/v1/models 与 /models
|
|
41
|
+
push("/v1/models");
|
|
42
|
+
push("/models");
|
|
43
|
+
// workbuddy 异形已在 defaultModelsPath 注入,若仍未命中则 candidates 已含
|
|
44
|
+
const tried = [];
|
|
45
|
+
let lastError = "";
|
|
46
|
+
for (const p of candidates) {
|
|
47
|
+
const url = joinUrl(base, p);
|
|
48
|
+
tried.push(url);
|
|
49
|
+
try {
|
|
50
|
+
const controller = new AbortController();
|
|
51
|
+
const t = setTimeout(() => controller.abort(new Error(`probe timeout ${timeoutMs}ms`)), timeoutMs);
|
|
52
|
+
let res;
|
|
53
|
+
try {
|
|
54
|
+
res = await fetchImpl(url, { headers, signal: controller.signal });
|
|
55
|
+
} finally { clearTimeout(t); }
|
|
56
|
+
if (res instanceof Error) { lastError = res.message || String(res); continue; }
|
|
57
|
+
if (!res.ok) { lastError = `HTTP ${res.status}`; continue; }
|
|
58
|
+
let json = {};
|
|
59
|
+
try { json = await res.json(); } catch { json = {}; }
|
|
60
|
+
const raw = normalizeModelsPayload(json);
|
|
61
|
+
const data = raw.map((m) => ({ id: toModelId(m), raw: m })).filter((x) => x.id).map((x) => ({ id: x.id, ...x.raw }));
|
|
62
|
+
return { ok: true, data, tried, url, rawCount: raw.length };
|
|
63
|
+
} catch (e) {
|
|
64
|
+
lastError = e?.message || String(e);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { ok: false, error: lastError || "all probes failed", tried, data: [] };
|
|
68
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export function sortResults(list) {
|
|
2
|
+
const arr = [...(list || [])];
|
|
3
|
+
arr.sort((a, b) => {
|
|
4
|
+
if (a.ok !== b.ok) return a.ok ? -1 : 1;
|
|
5
|
+
if (a.ok && b.ok) {
|
|
6
|
+
const at = a.ttfbMs ?? a.totalMs ?? 1e9;
|
|
7
|
+
const bt = b.ttfbMs ?? b.totalMs ?? 1e9;
|
|
8
|
+
if (at !== bt) return at - bt;
|
|
9
|
+
const ap = a.tps ?? a.charsPerSec ?? -1;
|
|
10
|
+
const bp = b.tps ?? b.charsPerSec ?? -1;
|
|
11
|
+
return bp - ap;
|
|
12
|
+
}
|
|
13
|
+
return 0;
|
|
14
|
+
});
|
|
15
|
+
return arr;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function pad(s, n, align = "left") {
|
|
19
|
+
const str = String(s ?? "");
|
|
20
|
+
if (str.length >= n) return str.slice(0, n);
|
|
21
|
+
const d = n - str.length;
|
|
22
|
+
if (align === "right") return " ".repeat(d) + str;
|
|
23
|
+
return str + " ".repeat(d);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatReport(results, { json = false } = {}) {
|
|
27
|
+
const sorted = sortResults(results);
|
|
28
|
+
const winner = sorted.find((r) => r.ok) || null;
|
|
29
|
+
if (json) {
|
|
30
|
+
return { text: JSON.stringify(sorted, null, 2), json: sorted, winner, sorted };
|
|
31
|
+
}
|
|
32
|
+
const lines = [];
|
|
33
|
+
lines.push("模型 状态 TTFB 总耗时 速度 tokens 备注");
|
|
34
|
+
lines.push("─".repeat(84));
|
|
35
|
+
for (const r of sorted) {
|
|
36
|
+
const isWin = winner && r.id === winner.id;
|
|
37
|
+
const mark = isWin ? "*" : " ";
|
|
38
|
+
const id = pad((isWin ? "* " : " ") + r.id, 28);
|
|
39
|
+
const label = pad(r.label || (r.ok ? "成功" : "失败"), 8);
|
|
40
|
+
const ttfb = pad(r.ttfbMs != null ? `${r.ttfbMs}ms` : "—", 6, "right");
|
|
41
|
+
const total = pad(r.totalMs != null ? `${r.totalMs}ms` : "—", 7, "right");
|
|
42
|
+
let speed = "—";
|
|
43
|
+
if (r.tps != null) speed = `${r.tps} t/s`;
|
|
44
|
+
else if (r.charsPerSec != null) speed = `${r.charsPerSec} 字/秒`;
|
|
45
|
+
speed = pad(speed, 11, "right");
|
|
46
|
+
const tok = r.tokens?.completion != null ? String(r.tokens.completion) : r.chars != null ? String(r.chars) : "—";
|
|
47
|
+
const note = r.ok ? "" : (r.error || "").slice(0, 28);
|
|
48
|
+
lines.push(`${mark}${id} ${label} ${ttfb} ${total} ${speed} ${pad(tok, 6, "right")} ${note}`);
|
|
49
|
+
}
|
|
50
|
+
lines.push("─".repeat(84));
|
|
51
|
+
if (winner) lines.push(`最快:${winner.id} TTFB ${winner.ttfbMs}ms 总 ${winner.totalMs}ms ${winner.tps != null ? `${winner.tps} t/s` : `${winner.charsPerSec ?? "—"} 字/秒`}`);
|
|
52
|
+
else lines.push("无可用模型(均失败)");
|
|
53
|
+
const failed = sorted.filter((r) => !r.ok).length;
|
|
54
|
+
lines.push(`完成:${sorted.length} 个,成功 ${sorted.length - failed},失败 ${failed}`);
|
|
55
|
+
return { text: lines.join("\n"), json: sorted, winner, sorted };
|
|
56
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { joinUrl } from "../providers/base.js";
|
|
2
|
+
import { computeMetrics, extractUsageFromJson } from "../metrics.js";
|
|
3
|
+
|
|
4
|
+
function classifyError(status, bodyText) {
|
|
5
|
+
const t = String(bodyText || "").slice(0, 300);
|
|
6
|
+
if (status === 401) return { label: "鉴权失败", retryable: false };
|
|
7
|
+
if (status === 402 || /insufficient balance/i.test(t)) return { label: "余额不足", retryable: false };
|
|
8
|
+
if (status === 403) return { label: /insufficient/i.test(t) ? "余额不足" : "鉴权失败", retryable: false };
|
|
9
|
+
if (status === 429) return { label: "限流", retryable: true };
|
|
10
|
+
if (status >= 500) return { label: `上游错误 ${status}`, retryable: true };
|
|
11
|
+
if (status === 404) return { label: "模型不存在", retryable: false };
|
|
12
|
+
return { label: `HTTP ${status}`, retryable: false };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function runOne({
|
|
16
|
+
baseUrl,
|
|
17
|
+
chatPath = "/v1/chat/completions",
|
|
18
|
+
model,
|
|
19
|
+
providerId,
|
|
20
|
+
apiKey,
|
|
21
|
+
headers = {},
|
|
22
|
+
prompt = "hi",
|
|
23
|
+
maxTokens = 32,
|
|
24
|
+
timeoutMs = 30000,
|
|
25
|
+
fetchImpl = globalThis.fetch,
|
|
26
|
+
clock = Date.now,
|
|
27
|
+
} = {}) {
|
|
28
|
+
const started = clock();
|
|
29
|
+
const t0 = typeof performance !== "undefined" && performance.now ? performance.now() : started;
|
|
30
|
+
if (!baseUrl) return { id: model, ok: false, error: "missing baseUrl", label: "配置错误", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null };
|
|
31
|
+
if (!model) return { id: model, ok: false, error: "missing model", label: "配置错误", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null };
|
|
32
|
+
if (!apiKey) return { id: model, ok: false, error: "missing apiKey", label: "未配置 Key", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null };
|
|
33
|
+
const url = joinUrl(String(baseUrl).replace(/\/+$/, ""), chatPath);
|
|
34
|
+
const body = { model: String(model).split("/").pop(), stream: false, messages: [{ role: "user", content: prompt }], max_tokens: maxTokens };
|
|
35
|
+
// workbuddy 需额外 workbuddy 透传头由调用方 headers 注入;此处直接透传
|
|
36
|
+
const finalHeaders = { "Content-Type": "application/json", Accept: "application/json", ...headers };
|
|
37
|
+
if (apiKey && !finalHeaders.Authorization) finalHeaders.Authorization = `Bearer ${apiKey}`;
|
|
38
|
+
|
|
39
|
+
let ttfbMs = null;
|
|
40
|
+
let res;
|
|
41
|
+
try {
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const timer = setTimeout(() => controller.abort(new Error(`timeout ${timeoutMs}ms`)), timeoutMs);
|
|
44
|
+
const fetchStart = typeof performance !== "undefined" && performance.now ? performance.now() : clock();
|
|
45
|
+
try {
|
|
46
|
+
res = await fetchImpl(url, { method: "POST", headers: finalHeaders, body: JSON.stringify(body), signal: controller.signal });
|
|
47
|
+
} finally { clearTimeout(timer); }
|
|
48
|
+
ttfbMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - fetchStart);
|
|
49
|
+
if (res instanceof Error) throw res;
|
|
50
|
+
const totalMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0);
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
let txt = "";
|
|
53
|
+
try { txt = await res.text(); } catch {}
|
|
54
|
+
const cls = classifyError(res.status, txt);
|
|
55
|
+
return { id: model, providerId, ok: false, status: res.status, error: txt.slice(0, 300) || `HTTP ${res.status}`, label: cls.label, ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
|
|
56
|
+
}
|
|
57
|
+
let json = {};
|
|
58
|
+
let txt = "";
|
|
59
|
+
try { txt = await res.text(); json = JSON.parse(txt); } catch { json = {}; }
|
|
60
|
+
const usage = extractUsageFromJson(json);
|
|
61
|
+
const content = json?.choices?.[0]?.message?.content || json?.choices?.[0]?.text || txt || "";
|
|
62
|
+
const chars = typeof content === "string" ? content.length : 0;
|
|
63
|
+
const promptTokens = usage?.prompt_tokens ?? null;
|
|
64
|
+
const completionTokens = usage?.completion_tokens ?? null;
|
|
65
|
+
const { tps, charsPerSec } = computeMetrics({ ttfbMs, totalMs, promptTokens, completionTokens, chars });
|
|
66
|
+
const totalTokens = usage?.total_tokens ?? (promptTokens !== null && completionTokens !== null ? promptTokens + completionTokens : null);
|
|
67
|
+
return { id: model, providerId, ok: true, status: res.status, ttfbMs, totalMs, tps, charsPerSec, tokens: { prompt: promptTokens, completion: completionTokens, total: totalTokens }, chars, label: "成功", raw: json };
|
|
68
|
+
} catch (e) {
|
|
69
|
+
const totalMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0);
|
|
70
|
+
const msg = e?.message || String(e);
|
|
71
|
+
const isTimeout = /timeout|abort/i.test(msg);
|
|
72
|
+
return { id: model, providerId, ok: false, error: msg.slice(0, 300), label: isTimeout ? "超时" : "网络错误", ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { probeModels } from "../../../bench/probe.js";
|
|
2
|
+
import { runOne } from "../../../bench/runner.js";
|
|
3
|
+
import { formatReport } from "../../../bench/report.js";
|
|
4
|
+
import { defaultModelsPath, defaultChatPath } from "../../../state/provider-config.js";
|
|
5
|
+
|
|
6
|
+
function parseBenchArgs(rest) {
|
|
7
|
+
const opts = { json: false, prompt: "hi", maxTokens: 32, timeoutMs: 30000 };
|
|
8
|
+
for (let i = 0; i < rest.length; i++) {
|
|
9
|
+
const a = rest[i];
|
|
10
|
+
if (a === "--json" || a === "-json") opts.json = true;
|
|
11
|
+
else if (a === "--prompt" && rest[i + 1]) opts.prompt = rest[++i];
|
|
12
|
+
else if (a.startsWith("--prompt=")) opts.prompt = a.slice(9);
|
|
13
|
+
else if (a === "--max-tokens" && rest[i + 1]) opts.maxTokens = Number(rest[++i]) || 32;
|
|
14
|
+
else if (a.startsWith("--max-tokens=")) opts.maxTokens = Number(a.split("=")[1]) || 32;
|
|
15
|
+
else if (a === "--timeout" && rest[i + 1]) opts.timeoutMs = Number(rest[++i]) || 30000;
|
|
16
|
+
else if (a.startsWith("--timeout=")) opts.timeoutMs = Number(a.split("=")[1]) || 30000;
|
|
17
|
+
}
|
|
18
|
+
return opts;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildHeadersForProvider(providerId, apiKey, auth) {
|
|
22
|
+
const h = {};
|
|
23
|
+
if (String(providerId).toLowerCase() === "workbuddy") {
|
|
24
|
+
h["Content-Type"] = "application/json";
|
|
25
|
+
h["Accept"] = "text/event-stream";
|
|
26
|
+
h["User-Agent"] = "CLI/2.115.0 WorkBuddy/2.115.0";
|
|
27
|
+
h["Origin"] = "https://www.codebuddy.cn";
|
|
28
|
+
h["Referer"] = "https://www.codebuddy.cn/";
|
|
29
|
+
h["X-Product"] = "SaaS";
|
|
30
|
+
if (apiKey) h["Authorization"] = `Bearer ${apiKey}`;
|
|
31
|
+
if (auth?.uid) h["X-User-Id"] = auth.uid;
|
|
32
|
+
h["X-Domain"] = auth?.domain || "www.codebuddy.cn";
|
|
33
|
+
if (auth?.enterpriseId) {
|
|
34
|
+
h["X-Enterprise-Id"] = auth.enterpriseId;
|
|
35
|
+
h["X-Tenant-Id"] = auth.enterpriseId;
|
|
36
|
+
}
|
|
37
|
+
return h;
|
|
38
|
+
}
|
|
39
|
+
if (apiKey) h["Authorization"] = `Bearer ${apiKey}`;
|
|
40
|
+
return h;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function handleProviderBench(id, sub, rest, args, deps = {}) {
|
|
44
|
+
const isBench = sub === "bench" || sub === "benchmark" || sub === "eval" || sub === "test";
|
|
45
|
+
if (!isBench) return false;
|
|
46
|
+
const fetchImpl = deps.fetchImpl || globalThis.fetch;
|
|
47
|
+
const loadConfigs = deps.loadProviderConfigs || (await import("../../../state.js")).loadProviderConfigs;
|
|
48
|
+
const loadKeys = deps.loadProviderKeys || (await import("../../../state.js")).loadProviderKeys;
|
|
49
|
+
const loadAllowed = deps.loadProviderAllowedModels || (await import("../../../state.js")).loadProviderAllowedModels;
|
|
50
|
+
const loadAllowAny = deps.loadProviderAllowAnyModels || (await import("../../../state.js")).loadProviderAllowAnyModels;
|
|
51
|
+
const loadBaseUrl = deps.loadProviderBaseUrl || (await import("../../../state.js")).loadProviderBaseUrl;
|
|
52
|
+
const loadAuths = deps.loadProviderAuths || (async () => { try { const m = await import("../../../state.js"); return m.loadProviderAuths ? m.loadProviderAuths(id) : []; } catch { return []; } }) && (await import("../../../state.js")).loadProviderAuths;
|
|
53
|
+
|
|
54
|
+
const providerId = String(id || "").trim();
|
|
55
|
+
if (!providerId) {
|
|
56
|
+
console.error("usage: mslxdff -provider <id> bench [--json] [--prompt hi] [--max-tokens 32]");
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
const opts = parseBenchArgs(rest || []);
|
|
60
|
+
const configs = loadConfigs();
|
|
61
|
+
const cfg = configs[providerId] || {};
|
|
62
|
+
const keys = loadKeys(providerId) || [];
|
|
63
|
+
const allowed = loadAllowed(providerId) || [];
|
|
64
|
+
const allowAny = loadAllowAny(providerId);
|
|
65
|
+
const baseUrl = (loadBaseUrl(providerId) || cfg.baseUrl || "").trim();
|
|
66
|
+
let auths = [];
|
|
67
|
+
try { const m = await import("../../../state.js"); auths = m.loadProviderAuths ? m.loadProviderAuths(providerId) : []; } catch {}
|
|
68
|
+
if (!baseUrl) {
|
|
69
|
+
console.error(`provider ${providerId}: missing baseUrl — 先设置: mslxdff -provider ${providerId} set-url https://api.example.com/v1`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
if (!keys.length) {
|
|
73
|
+
console.error(`provider ${providerId}: 未配置 Key — 先设置: mslxdff -provider ${providerId} <key>`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
// 空勾选 → 不发 chat,仅探活模型列表并提示
|
|
77
|
+
if (!allowed.length) {
|
|
78
|
+
const modelsPath = cfg.modelsPath || defaultModelsPath(providerId);
|
|
79
|
+
console.log(`provider ${providerId}: 未设置 allowlist(allowAny=${allowAny ? "ON" : "OFF"}),不发起测速,仅探活模型列表...`);
|
|
80
|
+
console.log(`尝试:GET ${baseUrl}${modelsPath} → GET ${baseUrl}/v1/models → GET ${baseUrl}/models`);
|
|
81
|
+
const headers = buildHeadersForProvider(providerId, keys[0], auths[0]);
|
|
82
|
+
const probed = await probeModels({ baseUrl, modelsPath, headers, fetchImpl, timeoutMs: 8000 });
|
|
83
|
+
if (!probed.ok) {
|
|
84
|
+
console.error(`探活失败:${probed.error}`);
|
|
85
|
+
console.error(`已尝试:${probed.tried.join(", ")}`);
|
|
86
|
+
console.error(`请手动设置 allowlist:mslxdff -provider ${providerId} allowlist set <model>`);
|
|
87
|
+
if (opts.json) console.log(JSON.stringify({ ok: false, error: probed.error, tried: probed.tried }, null, 2));
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
const list = probed.data || [];
|
|
91
|
+
if (!list.length) {
|
|
92
|
+
console.log("探活成功但返回空列表,请确认上游是否暴露 /v1/models");
|
|
93
|
+
if (opts.json) console.log(JSON.stringify({ ok: true, data: [] }, null, 2));
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
console.log(`\n发现 ${list.length} 个模型:`);
|
|
97
|
+
for (const m of list.slice(0, 30)) {
|
|
98
|
+
console.log(` - ${m.id || m.model || m.name || JSON.stringify(m).slice(0, 80)}`);
|
|
99
|
+
}
|
|
100
|
+
if (list.length > 30) console.log(` ... 还有 ${list.length - 30} 个未展示`);
|
|
101
|
+
console.log(`\n下一步:勾选后再测(只测勾选,避免扣费)`);
|
|
102
|
+
console.log(` mslxdff -provider ${providerId} allowlist set ${list.slice(0, 2).map((m) => m.id).join(" ")}`);
|
|
103
|
+
console.log(` mslxdff -provider ${providerId} bench${opts.json ? " --json" : ""}`);
|
|
104
|
+
if (opts.json) console.log(JSON.stringify({ ok: true, data: list, hint: `pick then bench` }, null, 2));
|
|
105
|
+
process.exit(0);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 有勾选 → 逐个测
|
|
109
|
+
console.log(`bench ${providerId}: 共 ${allowed.length} 个已勾选模型,逐个测速(串行,${opts.timeoutMs}ms 超时)...`);
|
|
110
|
+
if (!opts.json) console.log(`prompt="${opts.prompt}" maxTokens=${opts.maxTokens}\n`);
|
|
111
|
+
const chatPath = cfg.chatPath || defaultChatPath(providerId);
|
|
112
|
+
const results = [];
|
|
113
|
+
for (let i = 0; i < allowed.length; i++) {
|
|
114
|
+
const raw = allowed[i];
|
|
115
|
+
const model = String(raw || "").trim();
|
|
116
|
+
if (!opts.json) process.stdout.write(` [${i + 1}/${allowed.length}] ${model} ... `);
|
|
117
|
+
// 轮询 key/auth:按索引取,超长循环
|
|
118
|
+
const kIdx = i % keys.length;
|
|
119
|
+
const aIdx = Math.min(kIdx, auths.length - 1);
|
|
120
|
+
const key = keys[kIdx];
|
|
121
|
+
const auth = auths[aIdx] || auths[0] || null;
|
|
122
|
+
const headers = buildHeadersForProvider(providerId, key, auth);
|
|
123
|
+
const r = await runOne({ baseUrl, chatPath, model, providerId, apiKey: key, headers, prompt: opts.prompt, maxTokens: opts.maxTokens, timeoutMs: opts.timeoutMs, fetchImpl });
|
|
124
|
+
results.push(r);
|
|
125
|
+
if (!opts.json) {
|
|
126
|
+
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} 字/秒` : "—"}`);
|
|
127
|
+
else console.log(`FAIL ${r.label} ${r.error ? `(${r.error.slice(0, 60)})` : ""}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const report = formatReport(results, { json: opts.json });
|
|
131
|
+
console.log("\n" + report.text);
|
|
132
|
+
if (opts.json) {
|
|
133
|
+
// json 已在 text 中输出一次(report.text 是 JSON),无需重复
|
|
134
|
+
}
|
|
135
|
+
const failed = results.filter((r) => !r.ok).length;
|
|
136
|
+
if (failed && !opts.json) {
|
|
137
|
+
console.log(`\n提示:失败 ${failed} 个多为 402余额不足/429限流/超时,可清冷却或换 Key 后重试`);
|
|
138
|
+
}
|
|
139
|
+
process.exit(failed ? 2 : 0);
|
|
140
|
+
}
|
|
@@ -187,6 +187,8 @@ export async function handleProvider(args) {
|
|
|
187
187
|
if (await handleProviderAllowlist(id, sub, rest)) return true;
|
|
188
188
|
const { handleProviderModels } = await import("./models.js");
|
|
189
189
|
if (await handleProviderModels(id, sub, args, rest)) return true;
|
|
190
|
+
const { handleProviderBench } = await import("./bench.js");
|
|
191
|
+
if (await handleProviderBench(id, sub, rest, args)) return true;
|
|
190
192
|
const { handleProviderKeys } = await import("./keys.js");
|
|
191
193
|
await handleProviderKeys(id, sub, rest, args);
|
|
192
194
|
return true;
|
package/src/metrics.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { normalizeFullId, toFullId } from "./providers/model-id.js";
|
|
2
|
+
|
|
3
|
+
export { normalizeFullId, toFullId };
|
|
4
|
+
|
|
5
|
+
// 从上游 usage 或文本兜底算 tps / charsPerSec
|
|
6
|
+
export function computeMetrics({ ttfbMs, totalMs, promptTokens, completionTokens, chars }) {
|
|
7
|
+
const ttfb = Number(ttfbMs);
|
|
8
|
+
const total = Number(totalMs);
|
|
9
|
+
const hasTtfb = Number.isFinite(ttfb) && ttfb >= 0;
|
|
10
|
+
const hasTotal = Number.isFinite(total) && total >= 0;
|
|
11
|
+
const completionMs = hasTtfb && hasTotal ? Math.max(0, total - ttfb) : hasTotal ? total : null;
|
|
12
|
+
const comp = Number(completionTokens);
|
|
13
|
+
const hasComp = Number.isFinite(comp) && comp > 0 && Number.isFinite(completionMs) && completionMs > 0;
|
|
14
|
+
const tps = hasComp ? Number((comp / (completionMs / 1000)).toFixed(1)) : null;
|
|
15
|
+
const c = Number(chars);
|
|
16
|
+
const hasChars = Number.isFinite(c) && c > 0 && Number.isFinite(completionMs) && completionMs > 0;
|
|
17
|
+
const charsPerSec = !hasComp && hasChars ? Math.round(c / (completionMs / 1000)) : null;
|
|
18
|
+
return { completionMs, tps, charsPerSec };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 从 Response 文本或 SSE 末帧提取 usage
|
|
22
|
+
export function extractUsageFromJson(parsed) {
|
|
23
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
24
|
+
const u = parsed.usage;
|
|
25
|
+
if (!u || typeof u !== "object") return null;
|
|
26
|
+
const prompt = Number(u.prompt_tokens ?? u.promptTokens ?? u.input_tokens);
|
|
27
|
+
const comp = Number(u.completion_tokens ?? u.completionTokens ?? u.output_tokens);
|
|
28
|
+
const total = Number(u.total_tokens ?? u.totalTokens);
|
|
29
|
+
const out = {};
|
|
30
|
+
if (Number.isFinite(prompt)) out.prompt_tokens = prompt;
|
|
31
|
+
if (Number.isFinite(comp)) out.completion_tokens = comp;
|
|
32
|
+
if (Number.isFinite(total)) out.total_tokens = total;
|
|
33
|
+
// reasoning_tokens 透传
|
|
34
|
+
const rt = u.completion_tokens_details?.reasoning_tokens ?? u.reasoning_tokens;
|
|
35
|
+
if (Number.isFinite(Number(rt))) out.reasoning_tokens = Number(rt);
|
|
36
|
+
return Object.keys(out).length ? out : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function extractUsageFromSseText(sseText) {
|
|
40
|
+
if (!sseText) return null;
|
|
41
|
+
const lines = String(sseText).split("\n");
|
|
42
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
43
|
+
const line = lines[i].trim();
|
|
44
|
+
if (!line.startsWith("data:")) continue;
|
|
45
|
+
const data = line.slice(5).trim();
|
|
46
|
+
if (data === "[DONE]") continue;
|
|
47
|
+
try {
|
|
48
|
+
const j = JSON.parse(data);
|
|
49
|
+
if (j.usage) return extractUsageFromJson(j);
|
|
50
|
+
if (j.choices?.[0]?.finish_reason && j.usage) return extractUsageFromJson(j);
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 归一模型全称:provider + raw -> opencode/xxx
|
|
57
|
+
export function resolveFullId(rawModel, providerHint) {
|
|
58
|
+
const raw = String(rawModel || "").trim();
|
|
59
|
+
if (!raw) return "";
|
|
60
|
+
if (raw.includes("/")) return normalizeFullId(raw);
|
|
61
|
+
const prov = String(providerHint || "opencode").trim() || "opencode";
|
|
62
|
+
return toFullId(prov, raw);
|
|
63
|
+
}
|