mslxdff 0.1.78 → 0.1.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.78",
3
+ "version": "0.1.80",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,6 +23,104 @@ function pad(s, n, align = "left") {
23
23
  return str + " ".repeat(d);
24
24
  }
25
25
 
26
+ function shortPeerLabel(p) {
27
+ const raw = String(p?.name || p?.id || p?.url || String(p || "")).trim();
28
+ if (!raw) return "peer";
29
+ // 优先用 name;若是 url 则取 host:port 的尾段
30
+ if (raw.startsWith("http://") || raw.startsWith("https://") || raw.startsWith("relay://")) {
31
+ try { const u = new URL(raw); const host = u.hostname; const port = u.port ? `:${u.port}` : ""; if (host) return `${host}${port}`; } catch {}
32
+ return raw.slice(-12);
33
+ }
34
+ // name 可能是 url 字符串(如 "http://141.98..." 存于 name 字段)
35
+ if (raw.includes("://")) {
36
+ try { const u = new URL(raw); return u.hostname + (u.port ? `:${u.port}` : ""); } catch {}
37
+ }
38
+ return raw;
39
+ }
40
+ export function formatViaReport(results, { peers = [], meta = {}, json = false } = {}) {
41
+ const peerIds = (peers || []).map((p) => shortPeerLabel(p));
42
+ const samples = meta.samples ?? 1;
43
+ const timeout = meta.timeout ?? 30000;
44
+ const includeOpencode = Boolean(meta.includeOpencode);
45
+ const opencodeTag = includeOpencode ? "opencode=included" : "opencode=skipped";
46
+ const at = meta.at || new Date().toISOString();
47
+ // build json shape
48
+ const jsonObj = {
49
+ meta: { at, samples, timeout, includeOpencode, peers: peerIds, opencodeSkipped: !includeOpencode, ...meta, peers: peerIds },
50
+ results: (results || []).map((r) => ({
51
+ provider: r.provider,
52
+ model: r.model || r.id,
53
+ direct: r.direct ? { ttfb: r.direct.ttfbMs, total: r.direct.totalMs, ok: r.direct.ok, label: r.direct.label, error: r.direct.error } : null,
54
+ via: Object.fromEntries(Object.entries(r.via || {}).map(([k, v]) => [k, v.ok ? { ttfb: v.ttfbMs, total: v.totalMs } : { error: v.label || v.error || "offline", label: v.label }])),
55
+ best: r.best,
56
+ deltaMs: r.deltaMs,
57
+ opencodeSkipped: r.opencodeSkipped,
58
+ })),
59
+ advice: (() => {
60
+ const viaBest = (results || []).find((r) => r.best?.startsWith("via:"));
61
+ if (viaBest) return `${viaBest.provider}/${viaBest.model} 经 ${viaBest.best.slice(4)} 最快`;
62
+ if ((results || []).length) return `${results[0].provider} 走 direct 即可`;
63
+ return "无数据";
64
+ })(),
65
+ };
66
+ if (json) {
67
+ return { text: JSON.stringify(jsonObj, null, 2), json: jsonObj };
68
+ }
69
+ const lines = [];
70
+ lines.push(`bench-via: direct vs via peers (samples=${samples}, timeout=${timeout / 1000}s, ${opencodeTag}) peers=${peerIds.join(",") || "(none)"}`);
71
+ lines.push("");
72
+ // 表头:直连 + 每个组员一列(短化为 host:port 或 name),兼容旧测试 via B 断言与用户“直连/组员B”心智
73
+ const colW = 18;
74
+ const header = `${pad("Provider", 12)} ${pad("Model", 24)} ${pad("direct", colW, "right")} ${peerIds.map((id) => pad(`via ${id}`, colW, "right")).join(" ")} ${pad("best", 14)}`;
75
+ lines.push(header);
76
+ lines.push("─".repeat(header.length));
77
+ for (const r of results || []) {
78
+ const provider = pad(r.provider || "", 12);
79
+ const model = pad(r.model || r.id || "", 24);
80
+ const directOk = r.direct?.ok;
81
+ const directTxt = directOk ? `${r.direct.ttfbMs ?? "—"}ms` : (r.direct?.label || "—");
82
+ // determine best ttfb for ★
83
+ const all = [];
84
+ if (r.direct?.ok) all.push({ key: "direct", ttfb: r.direct.ttfbMs ?? r.direct.totalMs });
85
+ for (const pid of peerIds) {
86
+ const v = r.via?.[pid];
87
+ if (v?.ok) all.push({ key: `via:${pid}`, ttfb: v.ttfbMs ?? v.totalMs });
88
+ }
89
+ let bestKey = r.best;
90
+ if (!bestKey && all.length) bestKey = all.sort((a, b) => a.ttfb - b.ttfb)[0].key;
91
+ const isDirectBest = bestKey === "direct";
92
+ const directCell = pad(`${directTxt}${isDirectBest ? "★" : ""}`, colW, "right");
93
+ const viaCells = peerIds.map((pid) => {
94
+ const v = r.via?.[pid];
95
+ if (!v) return pad("—", colW, "right");
96
+ if (v.ok) {
97
+ const txt = `${v.ttfbMs ?? v.totalMs ?? "—"}ms`;
98
+ const star = bestKey === `via:${pid}` ? "★" : "";
99
+ return pad(`${txt}${star}`, colW, "right");
100
+ }
101
+ // 失败也展示延迟(先测延迟):如 42ms 鉴权失败,说明网络可达但该组员未配此供应商
102
+ const ms = v.ttfbMs != null ? `${v.ttfbMs}ms ` : "";
103
+ const label = v.label || v.error || "offline";
104
+ const short = label.includes("离线") ? "offline" : label.slice(0, 8);
105
+ return pad(`${ms}— ${short}`, colW, "right");
106
+ }).join(" ");
107
+ let bestTxt = r.best || "direct";
108
+ if (r.deltaMs != null && r.best?.startsWith("via:")) {
109
+ const pct = r.direct?.ttfbMs ? Math.round((r.deltaMs / r.direct.ttfbMs) * 100) : 0;
110
+ bestTxt = `${r.best} ${pct}%`;
111
+ }
112
+ lines.push(`${provider} ${model} ${directCell} ${viaCells} ${pad(bestTxt, 14)}`);
113
+ }
114
+ lines.push("─".repeat(header.length));
115
+ const viaBestExample = (results || []).find((r) => r.best?.startsWith("via:"));
116
+ if (viaBestExample) lines.push(`建议:A 经 ${viaBestExample.best.slice(4)} 打 ${viaBestExample.provider} 最快;其余走 direct。`);
117
+ else if ((results || []).length) lines.push(`建议:${results[0].provider} 走 direct 即可。`);
118
+ else lines.push("建议:无数据");
119
+ lines.push(`提示:via 已跳过 opencode(省额度),需对比 opencode 请加 --include-opencode`);
120
+ lines.push(`* via 单样本,仅作参考,多次 --samples 2 取均值更稳`);
121
+ return { text: lines.join("\n"), json: jsonObj };
122
+ }
123
+
26
124
  export function formatReport(results, { json = false } = {}) {
27
125
  const sorted = sortResults(results);
28
126
  const winner = sorted.find((r) => r.ok) || null;
@@ -0,0 +1,142 @@
1
+ import { joinUrl } from "../providers/base.js";
2
+ import { computeMetrics, extractUsageFromJson } from "../metrics.js";
3
+
4
+ function extractInnerMessage(bodyText) {
5
+ const t = String(bodyText || "");
6
+ try {
7
+ const j = JSON.parse(t);
8
+ const m = j?.error?.message || j?.error || j?.message || j?.data?.error || "";
9
+ if (typeof m === "string" && m.trim()) return m.trim().slice(0, 300);
10
+ if (typeof j?.error === "string") return j.error.slice(0, 300);
11
+ } catch {}
12
+ return t.slice(0, 300);
13
+ }
14
+
15
+ function classifyError(status, bodyText) {
16
+ const t = String(bodyText || "").slice(0, 500);
17
+ const low = t.toLowerCase();
18
+ if (status === 401) return { label: "鉴权失败", retryable: false };
19
+ if (status === 402 || /insufficient balance/i.test(t)) return { label: "余额不足", retryable: false };
20
+ if (low.includes("only available via cline")) return { label: "仅 Cline 客户端可用", retryable: false };
21
+ if (low.includes("invalid model format")) return { label: "模型格式错误", retryable: false };
22
+ if (status === 403) return { label: /insufficient/i.test(t) ? "余额不足" : "鉴权失败", retryable: false };
23
+ if (status === 429) return { label: "限流", retryable: true };
24
+ if (status >= 500) return { label: `上游错误 ${status}`, retryable: true };
25
+ if (status === 404) return { label: "模型不存在", retryable: false };
26
+ return { label: `HTTP ${status}`, retryable: false };
27
+ }
28
+
29
+ export async function viaProbe({
30
+ peerUrl,
31
+ token,
32
+ providerId,
33
+ model,
34
+ prompt = "hi",
35
+ maxTokens = 5,
36
+ timeoutMs = 30000,
37
+ fetchImpl = globalThis.fetch,
38
+ clock = Date.now,
39
+ shareKeys,
40
+ shareKeysHeader,
41
+ relayTarget,
42
+ relayHeaders,
43
+ relayBody,
44
+ targetUrl,
45
+ } = {}) {
46
+ const started = clock();
47
+ const t0 = typeof performance !== "undefined" && performance.now ? performance.now() : started;
48
+ const base = String(peerUrl || "").replace(/\/+$/, "");
49
+ if (!base) return { ok: false, label: "配置错误", error: "missing peerUrl", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null };
50
+ // 纯中继模式:A 把 targetUrl+headers+body 发给 B,B 原样 fetch 到上游(不查 B 本地 providerConfigs)
51
+ const rt = String(relayTarget || targetUrl || "").trim();
52
+ if (rt) {
53
+ const rh = relayHeaders && typeof relayHeaders === "object" ? relayHeaders : {};
54
+ const rb = relayBody !== undefined ? relayBody : null;
55
+ const relayUrl = joinUrl(base, "/v1/relay");
56
+ const relayHeadersOut = { "Content-Type": "application/json", Accept: "application/json" };
57
+ if (token) relayHeadersOut.Authorization = `Bearer ${token}`;
58
+ const payload = { targetUrl: rt, method: "POST", headers: rh, body: rb };
59
+ let ttfbMs = null;
60
+ try {
61
+ const controller = new AbortController();
62
+ const timer = setTimeout(() => controller.abort(new Error(`timeout ${timeoutMs}ms`)), timeoutMs);
63
+ const fetchStart = typeof performance !== "undefined" && performance.now ? performance.now() : clock();
64
+ let res;
65
+ try { res = await fetchImpl(relayUrl, { method: "POST", headers: relayHeadersOut, body: JSON.stringify(payload), signal: controller.signal }); } finally { clearTimeout(timer); }
66
+ ttfbMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - fetchStart);
67
+ const totalMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0);
68
+ if (!res.ok) {
69
+ let txt = ""; try { txt = await res.text(); } catch {}
70
+ const cls = classifyError(res.status, txt);
71
+ const msg = extractInnerMessage(txt) || `HTTP ${res.status}`;
72
+ return { ok: false, status: res.status, label: cls.label, error: msg, ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
73
+ }
74
+ let txt = ""; let j = {};
75
+ try { txt = await res.text(); j = JSON.parse(txt); } catch { j = {}; }
76
+ const relayStatus = res.headers.get("x-mslxdff-relay-status") ? Number(res.headers.get("x-mslxdff-relay-status")) : res.status;
77
+ if (relayStatus >= 400) {
78
+ const cls = classifyError(relayStatus, txt);
79
+ const msg = extractInnerMessage(txt) || `HTTP ${relayStatus}`;
80
+ return { ok: false, status: relayStatus, label: cls.label, error: msg, ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
81
+ }
82
+ const usage = extractUsageFromJson(j);
83
+ const content = j?.choices?.[0]?.message?.content || j?.choices?.[0]?.text || txt || "";
84
+ const chars = typeof content === "string" ? content.length : 0;
85
+ const pt = usage?.prompt_tokens ?? null;
86
+ const ct = usage?.completion_tokens ?? null;
87
+ const { tps, charsPerSec } = computeMetrics({ ttfbMs, totalMs, promptTokens: pt, completionTokens: ct, chars });
88
+ const totalTokens = usage?.total_tokens ?? (pt !== null && ct !== null ? pt + ct : null);
89
+ return { ok: true, status: relayStatus, label: "成功", ttfbMs, totalMs, tps, charsPerSec, tokens: { prompt: pt, completion: ct, total: totalTokens }, chars };
90
+ } catch (e) {
91
+ const totalMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0);
92
+ const msg = e?.message || String(e);
93
+ const isTimeout = /timeout|abort/i.test(msg);
94
+ return { ok: false, label: isTimeout ? "超时" : "网络错误", error: msg.slice(0, 300), ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
95
+ }
96
+ }
97
+ if (!model) return { ok: false, label: "配置错误", error: "missing model", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null };
98
+ let rawModel = String(model).trim();
99
+ if (providerId && rawModel.startsWith(`${providerId}/`)) rawModel = rawModel.slice(providerId.length + 1);
100
+ const url = joinUrl(base, "/v1/chat/completions");
101
+ const body = { model: rawModel, stream: false, messages: [{ role: "user", content: prompt }], max_tokens: maxTokens };
102
+ const headers = { "Content-Type": "application/json", Accept: "application/json" };
103
+ if (token) headers.Authorization = `Bearer ${token}`;
104
+ const sk = shareKeysHeader || shareKeys;
105
+ if (sk) headers["x-mslxdff-share-keys"] = String(sk);
106
+ let ttfbMs = null;
107
+ try {
108
+ const controller = new AbortController();
109
+ const timer = setTimeout(() => controller.abort(new Error(`timeout ${timeoutMs}ms`)), timeoutMs);
110
+ const fetchStart = typeof performance !== "undefined" && performance.now ? performance.now() : clock();
111
+ let res;
112
+ try {
113
+ res = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body), signal: controller.signal });
114
+ } finally { clearTimeout(timer); }
115
+ ttfbMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - fetchStart);
116
+ if (res instanceof Error) throw res;
117
+ const totalMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0);
118
+ if (!res.ok) {
119
+ let txt = "";
120
+ try { txt = await res.text(); } catch {}
121
+ const cls = classifyError(res.status, txt);
122
+ const msg = extractInnerMessage(txt) || `HTTP ${res.status}`;
123
+ return { ok: false, status: res.status, label: cls.label, error: msg, ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
124
+ }
125
+ let json = {};
126
+ let txt = "";
127
+ try { txt = await res.text(); json = JSON.parse(txt); } catch { json = {}; }
128
+ const usage = extractUsageFromJson(json);
129
+ const content = json?.choices?.[0]?.message?.content || json?.choices?.[0]?.text || txt || "";
130
+ const chars = typeof content === "string" ? content.length : 0;
131
+ const promptTokens = usage?.prompt_tokens ?? null;
132
+ const completionTokens = usage?.completion_tokens ?? null;
133
+ const { tps, charsPerSec } = computeMetrics({ ttfbMs, totalMs, promptTokens, completionTokens, chars });
134
+ const totalTokens = usage?.total_tokens ?? (promptTokens !== null && completionTokens !== null ? promptTokens + completionTokens : null);
135
+ return { ok: true, status: res.status, label: "成功", ttfbMs, totalMs, tps, charsPerSec, tokens: { prompt: promptTokens, completion: completionTokens, total: totalTokens }, chars };
136
+ } catch (e) {
137
+ const totalMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0);
138
+ const msg = e?.message || String(e);
139
+ const isTimeout = /timeout|abort/i.test(msg);
140
+ return { ok: false, label: isTimeout ? "超时" : "网络错误", error: msg.slice(0, 300), ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
141
+ }
142
+ }
@@ -0,0 +1,130 @@
1
+ import { viaProbe } from "./via-probe.js";
2
+
3
+ export async function resolveIncludeOpencode({ includeOpencode, isTTY, confirmFn, log = () => {} } = {}) {
4
+ if (!includeOpencode) return false;
5
+ if (!isTTY) {
6
+ try { log("via 已跳过 opencode(非 TTY,省额度;需对比请在 TTY 加 --include-opencode 并确认)"); } catch {}
7
+ return false;
8
+ }
9
+ if (typeof confirmFn === "function") {
10
+ try {
11
+ const ok = await confirmFn();
12
+ // confirmFn returns true for y, false otherwise
13
+ if (ok) return true;
14
+ try { log("已回落:via 跳过 opencode"); } catch {}
15
+ return false;
16
+ } catch { return false; }
17
+ }
18
+ return false;
19
+ }
20
+
21
+ export async function getOnlinePeers({ loadGroupsJoined, loadPeers, probeHealth } = {}) {
22
+ let candidates = [];
23
+ if (typeof loadPeers === "function") {
24
+ try { candidates = loadPeers() || []; } catch { candidates = []; }
25
+ } else {
26
+ try {
27
+ const m = await import("../state.js");
28
+ if (m.loadPeers) candidates = m.loadPeers() || [];
29
+ } catch {}
30
+ }
31
+ // also consider groupsJoined for validation - if no groups joined, via is meaningless, but we still allow static peers?
32
+ // For bench-via, if no groupsJoined at all, treat as no via capability -> return [] without probing?
33
+ // However static peers may exist without group; we probe them anyway.
34
+ // To satisfy "empty group -> skip probing" test, check loadGroupsJoined.
35
+ let joined = [];
36
+ if (typeof loadGroupsJoined === "function") {
37
+ try { joined = loadGroupsJoined() || []; } catch { joined = []; }
38
+ } else {
39
+ try {
40
+ const m = await import("../state.js");
41
+ if (m.loadGroupsJoined) joined = m.loadGroupsJoined() || [];
42
+ } catch {}
43
+ }
44
+ if (!joined.length) return [];
45
+ if (!candidates.length) return [];
46
+
47
+ // If probeHealth not provided, try import
48
+ let probe = probeHealth;
49
+ if (!probe) {
50
+ try {
51
+ const m = await import("../cli/group-helpers.js");
52
+ probe = m.probeHealth;
53
+ } catch { return candidates; }
54
+ }
55
+ if (!candidates.length) return [];
56
+ const online = [];
57
+ for (const p of candidates) {
58
+ try {
59
+ const r = await probe(p);
60
+ if (r && r.rank === 0 && !r.fail && !r.stale) online.push(p);
61
+ } catch {}
62
+ }
63
+ return online;
64
+ }
65
+
66
+ export async function orchestrateVia({
67
+ models = [],
68
+ peers = [],
69
+ directRunner = null,
70
+ viaProbeFn = viaProbe,
71
+ includeOpencode = false,
72
+ token,
73
+ timeoutMs = 30000,
74
+ clock = Date.now,
75
+ } = {}) {
76
+ const filtered = includeOpencode ? models : models.filter((m) => {
77
+ const s = typeof m === "string" ? m : (m.id || m.model || m.provider || "");
78
+ const first = String(s).split("/")[0].toLowerCase();
79
+ const prov = String(m?.provider || m?.providerId || first).toLowerCase();
80
+ return prov !== "opencode" && first !== "opencode";
81
+ });
82
+ // normalize model entries
83
+ const normModels = filtered.map((m) => {
84
+ if (typeof m === "string") return { provider: m.split("/")[0], model: m, id: m };
85
+ const id = m.id || m.model || "";
86
+ const provider = m.provider || m.providerId || (id.includes("/") ? id.split("/")[0] : "");
87
+ return { provider, model: id, id };
88
+ });
89
+ const results = [];
90
+ for (const entry of normModels) {
91
+ const provider = entry.provider;
92
+ const model = entry.model;
93
+ // direct
94
+ let direct = null;
95
+ if (directRunner) {
96
+ try { direct = await directRunner({ provider, model, timeoutMs, clock }); } catch (e) { direct = { ok: false, label: "网络错误", error: String(e), ttfbMs: null, totalMs: 0 }; }
97
+ } else {
98
+ direct = { ok: false, label: "未配置 directRunner", error: "missing directRunner", ttfbMs: null, totalMs: 0 };
99
+ }
100
+ const via = {};
101
+ for (const peer of peers) {
102
+ const raw = String(peer.name || peer.id || peer.url || "peer");
103
+ let peerId = raw;
104
+ // 仅当 raw 本身就是 url 时才短化;显式 name/id 保持原样,避免 B → b:8989
105
+ if (!peer.name && !peer.id && raw.includes("://")) { try { const u = new URL(raw); peerId = u.hostname + (u.port ? `:${u.port}` : ""); } catch { peerId = raw.slice(-16); } }
106
+ const peerToken = peer.token || token || "";
107
+ try {
108
+ const r = await viaProbeFn({ peerUrl: peer.url, token: peerToken, providerId: provider, model, prompt: "hi", maxTokens: 5, timeoutMs, clock });
109
+ via[peerId] = r;
110
+ } catch (e) {
111
+ via[peerId] = { ok: false, label: "网络错误", error: String(e?.message || e), ttfbMs: null, totalMs: 0 };
112
+ }
113
+ }
114
+ // best
115
+ let best = "direct";
116
+ let bestTtfb = direct?.ok ? (direct.ttfbMs ?? direct.totalMs) : Infinity;
117
+ let deltaMs = null;
118
+ for (const [pid, rv] of Object.entries(via)) {
119
+ if (!rv.ok) continue;
120
+ const t = rv.ttfbMs ?? rv.totalMs;
121
+ if (t < bestTtfb) { bestTtfb = t; best = `via:${pid}`; }
122
+ }
123
+ if (best.startsWith("via:")) {
124
+ const d = direct?.ttfbMs ?? direct?.totalMs ?? 0;
125
+ deltaMs = bestTtfb - d;
126
+ }
127
+ results.push({ provider, model, direct, via, best, deltaMs, opencodeSkipped: !includeOpencode });
128
+ }
129
+ return results;
130
+ }
@@ -136,9 +136,13 @@ export function createGatewayClient({
136
136
  }
137
137
  return { ok: true, message: choice.message, usage: effectiveJ.usage || j.usage, raw: j, status: res.status, model: rawModel, provider, viaGateway: true };
138
138
  } catch (err) {
139
- const msg = String(err?.message || err).slice(0, 800);
140
- if (env.MSLXDFF_CHAT_TRACE !== "0") {
141
- const dt2 = performance.now() - (performance.now() - 0); // placeholder, TRACE off in tests
139
+ const raw = String(err?.message || err);
140
+ const msg = raw.slice(0, 800);
141
+ const isConnRefused = /ECONNREFUSED|Failed to fetch|fetch failed|connect ECONNREFUSED|ECONNRESET|EADDRNOTAVAIL/i.test(raw);
142
+ if (isConnRefused) {
143
+ const friendly = `本地服务没有启动无法使用auto模式,请mslxdff -d 启动(本地网关 http://127.0.0.1:${port} 拒绝连接)— 3ms 内失败说明未触达任何模型(非模型额度问题);若已改端口请用 mslxdff -port N 或 MSLXDFF_PORT=${port} 保持一致`;
144
+ if (TRACE) console.log(`\x1b[90m· [LLM] gateway auto FAIL · ${Math.round(performance.now() - t0)}ms · ${friendly} · ${msg.slice(0, 80)}\x1b[0m`);
145
+ return { ok: false, error: friendly, status: 502, code: "GATEWAY_NOT_RUNNING", port, raw: msg };
142
146
  }
143
147
  return { ok: false, error: `gateway ${msg}`, status: 502 };
144
148
  }
@@ -64,7 +64,7 @@ export function createOrchestrator({
64
64
  let first;
65
65
  let firstMs = 0;
66
66
  if (firstCooling) {
67
- if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 跳过(冷却中)· 直接试 ${CHAT_FALLBACK}\x1b[0m`);
67
+ if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 跳过(冷却 10min)· 额度用完,准备走网关 auto(将尝试其他可用模型)\x1b[0m`);
68
68
  first = { ok: false, error: "skip cooling", status: 429 };
69
69
  } else {
70
70
  const t = perf.now();
@@ -81,13 +81,16 @@ export function createOrchestrator({
81
81
  }
82
82
 
83
83
  if (firstCooling) {
84
- if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 冷却中(${CHAT_COOLDOWN_MS / 60000}min)· 直接走网关 auto,跳过 ${CHAT_FALLBACK}\x1b[0m`);
85
- if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 冷却,直接走网关 auto(:8989)\x1b[0m`);
84
+ if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 冷却中(${CHAT_COOLDOWN_MS / 60000}min)· 直接走网关 auto(:8989),按 auto 择优尝试其他可用模型,跳过 ${CHAT_FALLBACK} 直连\x1b[0m`);
86
85
  const t2 = TRACE ? perf.now() : 0;
87
86
  const third = await _gateway(opts);
88
87
  if (TRACE) console.log(`\x1b[90m· [LLM] gateway auto ${third.ok ? "OK" : "FAIL"} · ${Math.round(perf.now() - t2)}ms · 总 ${Math.round(perf.now() - t0)}ms (gateway-fallback)\x1b[0m`);
89
88
  if (third.ok) return { ...third, model: third.model || "auto", fallbackGateway: true, fallback: true, firstError: first.error, secondError: "skip big-pickle (mimo cooling)", viaGateway: true };
90
- return { ok: false, error: `${CHAT_PREFERRED} cooling: ${first.error}; gateway auto failed: ${third.error}`, status: third.status || 429 };
89
+ const isGwDown = third.code === "GATEWAY_NOT_RUNNING" || /本地(网关未运行|服务没有启动)/.test(String(third.error || ""));
90
+ if (isGwDown) {
91
+ return { ok: false, error: `${CHAT_PREFERRED} 冷却跳过(额度用完 10min);${third.error} —— 本次 auto 未能尝试任何其他模型,因网关未就绪(3ms 失败即证明未触达上游,请先启动网关再重试)`, status: 502, code: "GATEWAY_NOT_RUNNING" };
92
+ }
93
+ return { ok: false, error: `${CHAT_PREFERRED} 冷却跳过(额度用完);网关 auto 已尝试其他可用模型但均失败:${third.error}(总 ${Math.round(perf.now() - t0)}ms)`, status: third.status || 429 };
91
94
  }
92
95
 
93
96
  const secondCooling = await isCoolingAsync(CHAT_FALLBACK);
@@ -102,12 +105,14 @@ export function createOrchestrator({
102
105
  };
103
106
 
104
107
  if (secondCooling) {
105
- if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_FALLBACK} 跳过(冷却中)· 直接走网关 auto\x1b[0m`);
108
+ if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_FALLBACK} 跳过(冷却中)· 直接走网关 auto(将尝试其他可用模型)\x1b[0m`);
106
109
  const t2 = perf.now();
107
110
  const third = await doGateway();
108
111
  if (TRACE) console.log(`\x1b[90m· [LLM] gateway auto ${third.ok ? "OK" : "FAIL"} · ${Math.round(perf.now() - t2)}ms · 总 ${Math.round(perf.now() - t0)}ms (gateway-fallback)\x1b[0m`);
109
112
  if (third.ok) return { ...third, model: third.model || "auto", fallbackGateway: true, fallback: true, firstError: first.error, secondError: "skip cooling", viaGateway: true };
110
- return { ok: false, error: `${CHAT_PREFERRED} failed: ${first.error}; ${CHAT_FALLBACK} failed: skip cooling; gateway auto failed: ${third.error}`, status: third.status || first.status };
113
+ const isGwDown2 = third.code === "GATEWAY_NOT_RUNNING" || /本地(网关未运行|服务没有启动)/.test(String(third.error || ""));
114
+ if (isGwDown2) return { ok: false, error: `${CHAT_PREFERRED} 失败:${first.error};${CHAT_FALLBACK} 冷却跳过;${third.error} —— auto 未能尝试其他模型(网关未就绪)`, status: 502, code: "GATEWAY_NOT_RUNNING" };
115
+ return { ok: false, error: `${CHAT_PREFERRED} 失败:${first.error};${CHAT_FALLBACK} 冷却跳过;网关 auto 已尝试其他模型但均失败:${third.error}(总 ${Math.round(perf.now() - t0)}ms)`, status: third.status || first.status };
111
116
  }
112
117
 
113
118
  if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 失败,${CHAT_FALLBACK} + gateway 对冲中(${HEDGE_MS}ms)\x1b[0m`);
@@ -167,7 +172,9 @@ export function createOrchestrator({
167
172
  secondRes = sRes; gatewayRes = gRes;
168
173
  if (sRes?.ok) return { ...sRes, model: CHAT_FALLBACK, fallback: true, firstError: first.error };
169
174
  if (gRes?.ok) return { ...gRes, model: gRes.model || "auto", fallbackGateway: true, fallback: true, firstError: first.error, secondError: sRes?.error, viaGateway: true };
170
- return { ok: false, error: `${CHAT_PREFERRED} failed: ${first.error}; ${CHAT_FALLBACK} failed: ${sRes?.error}; gateway auto failed: ${gRes?.error}`, status: gRes?.status || sRes?.status || first.status };
175
+ const gwDown0 = gRes?.code === "GATEWAY_NOT_RUNNING" || /本地(网关未运行|服务没有启动)/.test(String(gRes?.error || ""));
176
+ if (gwDown0) return { ok: false, error: `${CHAT_PREFERRED} 失败:${first.error};${CHAT_FALLBACK} 失败:${sRes?.error};${gRes?.error} —— gateway 侧 auto 未能尝试其他模型(网关未就绪)`, status: 502, code: "GATEWAY_NOT_RUNNING" };
177
+ return { ok: false, error: `${CHAT_PREFERRED} 失败:${first.error};${CHAT_FALLBACK} 失败:${sRes?.error};网关 auto 已尝试其他模型但均失败:${gRes?.error}(总 ${Math.round(perf.now() - t0)}ms)`, status: gRes?.status || sRes?.status || first.status };
171
178
  }
172
179
 
173
180
  const raced = await raceFirstOk();
@@ -183,7 +190,9 @@ export function createOrchestrator({
183
190
  try { gatewayRes = await (gatewayPromise || doGateway()); } catch (e) { gatewayRes = { ok: false, error: String(e), status: 502 }; }
184
191
  }
185
192
  if (gatewayRes?.ok) return { ...gatewayRes, model: gatewayRes.model || "auto", fallbackGateway: true, fallback: true, firstError: first.error, secondError: secondRes?.error, viaGateway: true };
186
- return { ok: false, error: `${CHAT_PREFERRED} failed: ${first.error}; ${CHAT_FALLBACK} failed: ${secondRes?.error}; gateway auto failed: ${gatewayRes?.error}`, status: gatewayRes?.status || secondRes?.status || first.status };
193
+ const gwDown = gatewayRes?.code === "GATEWAY_NOT_RUNNING" || /本地(网关未运行|服务没有启动)/.test(String(gatewayRes?.error || ""));
194
+ if (gwDown) return { ok: false, error: `${CHAT_PREFERRED} 失败:${first.error};${CHAT_FALLBACK} 失败:${secondRes?.error};${gatewayRes?.error} —— gateway 侧 auto 未能尝试其他模型(网关未就绪)`, status: 502, code: "GATEWAY_NOT_RUNNING" };
195
+ return { ok: false, error: `${CHAT_PREFERRED} 失败:${first.error};${CHAT_FALLBACK} 失败:${secondRes?.error};网关 auto 已尝试其他模型但均失败:${gatewayRes?.error}(总 ${Math.round(perf.now() - t0)}ms)`, status: gatewayRes?.status || secondRes?.status || first.status };
187
196
  }
188
197
 
189
198
  async function summarizeHistory(messages) {
package/src/chat/repl.js CHANGED
@@ -6,7 +6,7 @@ import { getToolDefs, execCommand, readFileTool, curlTool } from "./tools.js";
6
6
  import { chatWithFallback, summarizeHistory } from "./upstream.js";
7
7
  import { loadHistory, saveHistory, clearHistory, histPath, estimateChars, needsCompress } from "./store.js";
8
8
  import { CHAT_KEEP_RECENT, CHAT_MAX_TOOL_LOOPS, CHAT_PREFERRED, CHAT_FALLBACK } from "./config.js";
9
- import { formatBannerLines, formatStatsDetail, collectStats } from "./stats.js";
9
+ import { formatBannerLines, formatStatsDetail, collectStats, probeGateway } from "./stats.js";
10
10
  import { createSpinner } from "./spinner.js";
11
11
  import { normalizeFullId } from "../providers/model-id.js";
12
12
 
@@ -18,9 +18,17 @@ const SLASH_HELP = `自然语言直接说,斜杠快捷:
18
18
  /exit 退出
19
19
  示例:设置 hy3 为默认模型 / 查看组列表 / 看最近20条日志 / 读一下 src/logs.js`;
20
20
 
21
- function printBanner() {
22
- const { lines } = formatBannerLines();
21
+ async function printBanner() {
22
+ let probe = null;
23
+ try {
24
+ const s = collectStats();
25
+ probe = await probeGateway(s.port, 800);
26
+ } catch {}
27
+ const { lines } = formatBannerLines(probe);
23
28
  for (const l of lines) console.log(l);
29
+ if (probe && !probe.alive) {
30
+ console.log(`\x1b[31m本地服务没有启动无法使用auto模式,请mslxdff -d 启动\x1b[0m`);
31
+ }
24
32
  console.log(`\x1b[90m输入自然语言即可执行;/help 帮助,/stats 看网关统计,/exit 退出\x1b[0m`);
25
33
  console.log(`\x1b[90m历史:${histPath()} · 仅拦截 -uninstall · 数据来自网关 -d,非本会话计数\x1b[0m`);
26
34
  }
@@ -283,7 +291,7 @@ export async function startRepl({ singleShot } = {}) {
283
291
  saveHistory(messages.slice(1));
284
292
  return;
285
293
  }
286
- printBanner();
294
+ await printBanner();
287
295
  const rl = readline.createInterface({ input: stdin, output: stdout, prompt: `\x1b[36m${CHAT_PREFERRED.split("-")[0]}>\x1b[0m ` });
288
296
  rl.prompt();
289
297
  for await (const line of rl) {
package/src/chat/stats.js CHANGED
@@ -141,17 +141,40 @@ export function collectStats() {
141
141
  };
142
142
  }
143
143
 
144
- export function formatBannerLines() {
144
+ export async function probeGateway(port, timeoutMs = 800) {
145
+ const url = `http://127.0.0.1:${port}/health`;
146
+ const ctrl = new AbortController();
147
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
148
+ try {
149
+ const r = await fetch(url, { signal: ctrl.signal });
150
+ clearTimeout(t);
151
+ return { alive: r.ok, status: r.status, ms: 0 };
152
+ } catch (e) {
153
+ clearTimeout(t);
154
+ const msg = String(e?.message || e);
155
+ const isRefused = /ECONNREFUSED|Failed to fetch|fetch failed|ECONNRESET|abort/i.test(msg);
156
+ return { alive: false, status: 0, error: msg, refused: isRefused };
157
+ }
158
+ }
159
+
160
+ export function formatBannerLines(healthProbe = null) {
145
161
  const s = collectStats();
146
162
  const dim = "\x1b[90m";
147
163
  const rst = "\x1b[0m";
148
164
  const cyan = "\x1b[36m";
149
165
  const yellow = "\x1b[33m";
150
166
  const green = "\x1b[32m";
167
+ const red = "\x1b[31m";
168
+ const bgRed = "\x1b[41m\x1b[37m";
151
169
  const lines = [];
152
170
  lines.push(`${cyan}┌─ mslxdff chat · 数据来自 -d 网关进程(非本会话) ─────${rst}`);
171
+ if (healthProbe && !healthProbe.alive) {
172
+ lines.push(`${red}│ ✗ 本地服务没有启动无法使用auto模式,请mslxdff -d 启动 · ${s.healthUrl} 拒绝连接(${healthProbe.error?.slice(0,60) || "ECONNREFUSED"})${rst}`);
173
+ lines.push(`${red}│ → 先在另一终端执行: mslxdff -d (或 mslxdff) 启动后回此窗口重试,期间仅 mimo/big-pickle 直连可用,auto 不可用${rst}`);
174
+ }
153
175
  lines.push(`${cyan}│${rst} 对话模型 ${yellow}${s.chatPref}${rst} ${dim}→ ${s.chatFall} → gateway auto:8989${rst} ${dim}[${s.chatPrefStatus}/${s.chatFallStatus}]${rst} ${dim}三级兜底${rst}`);
154
- lines.push(`${cyan}│${rst} 网关默认 ${green}${s.gatewayModel}${rst} ${dim}[${s.gatewayStatus}]${rst} · 端口 ${s.port} · ${dim}${s.endpointUrl}${rst}`);
176
+ const gwAliveTag = healthProbe ? (healthProbe.alive ? `${green}● 运行中${rst}` : `${red}● 未运行${rst}`) : `${dim}…检测中${rst}`;
177
+ lines.push(`${cyan}│${rst} 网关默认 ${green}${s.gatewayModel}${rst} ${dim}[${s.gatewayStatus}]${rst} · 端口 ${s.port} ${gwAliveTag} · ${dim}${s.endpointUrl}${rst}`);
155
178
  const prefTtfb = s.chatPrefStat?.avgTtfbMs ?? s.chatPrefStat?.emaTtfbMs ?? s.chatPrefLat?.emaMs;
156
179
  const fallTtfb = s.chatFallStat?.avgTtfbMs ?? s.chatFallStat?.emaTtfbMs ?? s.chatFallLat?.emaMs;
157
180
  const gateTtfb = s.gatewayStat?.avgTtfbMs ?? s.gatewayStat?.emaTtfbMs ?? s.gatewayLat?.emaMs;
@@ -1,13 +1,11 @@
1
1
  import { probeModels } from "../../../bench/probe.js";
2
2
  import { runOne } from "../../../bench/runner.js";
3
- import { formatReport } from "../../../bench/report.js";
3
+ import { formatReport, formatViaReport } from "../../../bench/report.js";
4
4
  import { defaultModelsPath, defaultChatPath } from "../../../state/provider-config.js";
5
5
  import { isRefreshToken, clineHeaders } from "../../../providers/cline/headers.js";
6
6
  import { refreshTokenForBase } from "../../../providers/cline/auth.js";
7
7
  import { computeMetrics } from "../../../metrics.js";
8
8
 
9
- // Cline 免费通道(deepseek/z-ai 等)非流式会被上游限流 500 empty response content,
10
- // 必须 stream:true + SSE 聚合后测速
11
9
  async function clineBenchOne({ baseUrl, model, accessToken, prompt, maxTokens, timeoutMs, fetchImpl }) {
12
10
  const controller = new AbortController();
13
11
  const timer = setTimeout(() => controller.abort(new Error(`timeout ${timeoutMs}ms`)), timeoutMs);
@@ -25,7 +23,6 @@ async function clineBenchOne({ baseUrl, model, accessToken, prompt, maxTokens, t
25
23
  if (!res.ok) {
26
24
  let txt = "";
27
25
  try { txt = await res.text(); } catch {}
28
- const low = txt.toLowerCase();
29
26
  const label = res.status === 401 ? "鉴权失败" : res.status === 429 ? "限流" : res.status >= 500 ? `上游错误 ${res.status}` : `HTTP ${res.status}`;
30
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 };
31
28
  }
@@ -59,16 +56,18 @@ async function clineBenchOne({ baseUrl, model, accessToken, prompt, maxTokens, t
59
56
  } catch (e) {
60
57
  const msg = e?.message || String(e);
61
58
  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 };
62
- } finally {
63
- clearTimeout(timer);
64
- }
59
+ } finally { clearTimeout(timer); }
65
60
  }
66
61
 
67
62
  function parseBenchArgs(rest) {
68
- const opts = { json: false, prompt: "hi", maxTokens: 32, timeoutMs: 30000 };
63
+ const opts = { json: false, prompt: "hi", maxTokens: 32, timeoutMs: 30000, via: false, includeOpencode: false, samples: 1 };
69
64
  for (let i = 0; i < rest.length; i++) {
70
65
  const a = rest[i];
71
66
  if (a === "--json" || a === "-json") opts.json = true;
67
+ else if (a === "--via" || a === "--bench-via") opts.via = true;
68
+ else if (a === "--include-opencode") opts.includeOpencode = true;
69
+ else if (a === "--samples" && rest[i + 1]) opts.samples = Number(rest[++i]) || 1;
70
+ else if (a.startsWith("--samples=")) opts.samples = Number(a.split("=")[1]) || 1;
72
71
  else if (a === "--prompt" && rest[i + 1]) opts.prompt = rest[++i];
73
72
  else if (a.startsWith("--prompt=")) opts.prompt = a.slice(9);
74
73
  else if (a === "--max-tokens" && rest[i + 1]) opts.maxTokens = Number(rest[++i]) || 32;
@@ -91,33 +90,164 @@ function buildHeadersForProvider(providerId, apiKey, auth) {
91
90
  if (apiKey) h["Authorization"] = `Bearer ${apiKey}`;
92
91
  if (auth?.uid) h["X-User-Id"] = auth.uid;
93
92
  h["X-Domain"] = auth?.domain || "www.codebuddy.cn";
94
- if (auth?.enterpriseId) {
95
- h["X-Enterprise-Id"] = auth.enterpriseId;
96
- h["X-Tenant-Id"] = auth.enterpriseId;
97
- }
93
+ if (auth?.enterpriseId) { h["X-Enterprise-Id"] = auth.enterpriseId; h["X-Tenant-Id"] = auth.enterpriseId; }
98
94
  return h;
99
95
  }
100
96
  if (apiKey) h["Authorization"] = `Bearer ${apiKey}`;
101
97
  return h;
102
98
  }
103
99
 
100
+ async function handleVia({ providerId, opts, fetchImpl, loadConfigs, loadKeys, loadAllowed, loadBaseUrl }) {
101
+ const { getOnlinePeers, orchestrateVia, resolveIncludeOpencode } = await import("../../../bench/via.js");
102
+ const peers = await getOnlinePeers();
103
+ if (!peers.length) {
104
+ const msg = "未加入组或无在线 peer,--via 无意义。先 mslxdff -group list / -addtogroup";
105
+ 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));
106
+ else console.log(msg);
107
+ process.exit(0);
108
+ }
109
+ const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY);
110
+ let includeOpencode = opts.includeOpencode;
111
+ if (includeOpencode) {
112
+ const confirmFn = async () => {
113
+ const readline = await import("node:readline");
114
+ return new Promise((res) => {
115
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
116
+ rl.question("将消耗 B/C/D 的 opencode 额度,确认测 opencode via?y/N ", (ans) => { rl.close(); res(ans.trim().toLowerCase() === "y"); });
117
+ });
118
+ };
119
+ const log = (s) => (opts.json ? console.error(s) : console.log(s));
120
+ includeOpencode = await resolveIncludeOpencode({ includeOpencode, isTTY, confirmFn, log });
121
+ }
122
+ const { loadToken } = await import("../../../state.js");
123
+ let token = "";
124
+ try { token = (await loadToken()).token || ""; } catch {}
125
+ // target providers
126
+ let targetIds = [];
127
+ const isAll = providerId === "bench" || providerId === "all";
128
+ if (isAll) {
129
+ const configs = loadConfigs();
130
+ const ids = new Set(Object.keys(configs));
131
+ ids.add("opencode"); ids.add("openrouter");
132
+ try { const m = await import("../../../state.js"); const raw = m.loadProviderKeys ? null : null; } catch {}
133
+ // collect from providerKeys via loadKeys probing? simple: iterate ids and keep those with allowed
134
+ for (const pid of [...ids]) {
135
+ const allowed = loadAllowed(pid) || [];
136
+ if (allowed.length) targetIds.push(pid);
137
+ else if (pid === "opencode" && !includeOpencode) continue;
138
+ }
139
+ // also check generic ids from state raw
140
+ try {
141
+ const { readFileSync } = await import("node:fs");
142
+ const { defaultStateFile } = await import("../../../state.js");
143
+ const raw = JSON.parse(readFileSync(defaultStateFile(), "utf8"));
144
+ for (const k of Object.keys(raw.providerConfigs || {})) if (!targetIds.includes(k) && (loadAllowed(k) || []).length) targetIds.push(k);
145
+ for (const k of Object.keys(raw.providerKeys || {})) if (!targetIds.includes(k) && (loadAllowed(k) || []).length) targetIds.push(k);
146
+ } catch {}
147
+ if (!targetIds.length) targetIds = ["openrouter", "workbuddy", "clinebot"].filter((p) => (loadAllowed(p) || []).length);
148
+ } else {
149
+ targetIds = [providerId];
150
+ }
151
+ const allResults = [];
152
+ const viaLog = (s) => (opts.json ? console.error(s) : console.log(s));
153
+ 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(",")}`);
154
+ for (const pid of targetIds) {
155
+ const cfg = (loadConfigs()[pid] || {});
156
+ const keys = loadKeys(pid) || [];
157
+ const allowed = loadAllowed(pid) || [];
158
+ const baseUrl = (loadBaseUrl(pid) || cfg.baseUrl || "").trim();
159
+ if (!allowed.length) {
160
+ viaLog(`provider ${pid}: 无勾选模型,跳过`);
161
+ continue;
162
+ }
163
+ if (!baseUrl && pid !== "opencode") { viaLog(`provider ${pid}: missing baseUrl 跳过`); continue; }
164
+ if (!keys.length && pid !== "opencode") { viaLog(`provider ${pid}: 未配置 Key 跳过`); continue; }
165
+ const chatPath = cfg.chatPath || defaultChatPath(pid);
166
+ let auths = [];
167
+ try { const m = await import("../../../state.js"); auths = m.loadProviderAuths ? m.loadProviderAuths(pid) : []; } catch {}
168
+ const models = allowed.map((id) => ({ provider: pid, model: String(id), id: String(id) }));
169
+ // directRunner per provider
170
+ const directRunner = async ({ provider, model }) => {
171
+ const p = provider || pid;
172
+ const idx = models.findIndex((x) => x.model === model);
173
+ const kIdx = idx >= 0 ? idx % (keys.length || 1) : 0;
174
+ const aIdx = Math.min(kIdx, Math.max(auths.length - 1, 0));
175
+ const key = keys[kIdx] || keys[0] || "";
176
+ const auth = auths[aIdx] || auths[0] || null;
177
+ const cKeys = keys.filter((k) => isRefreshToken(k));
178
+ if (cKeys.length) {
179
+ const normBase = String(baseUrl).replace(/\/+$/, "");
180
+ const chatBase = normBase.endsWith("/api/v1") ? normBase.slice(0, -7) : normBase;
181
+ const rt = cKeys[kIdx % cKeys.length];
182
+ const at = await refreshTokenForBase({ refreshToken: rt, baseUrl: normBase, fetchImpl });
183
+ if (!at) return { ok: false, label: "鉴权失败", error: "refresh failed", ttfbMs: null, totalMs: 0 };
184
+ return clineBenchOne({ baseUrl: chatBase, model, accessToken: at, prompt: "hi", maxTokens: 5, timeoutMs: opts.timeoutMs, fetchImpl });
185
+ }
186
+ const headers = buildHeadersForProvider(p, key, auth);
187
+ 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 });
188
+ };
189
+ const { viaProbe } = await import("../../../bench/via-probe.js");
190
+ const { joinUrl: _joinUrl } = await import("../../../providers/base.js");
191
+ const viaProbeFn = async (args) => {
192
+ // 纯中继:A 把 targetUrl+headers+body 发给 B,B 原样 fetch 到上游(不查 B 本地 providerConfigs)
193
+ const { peerUrl, providerId, model } = args;
194
+ const p = providerId || pid;
195
+ const idx = models.findIndex((x) => x.model === model);
196
+ const kIdx = idx >= 0 ? idx % (keys.length || 1) : 0;
197
+ const aIdx = Math.min(kIdx, Math.max(auths.length - 1, 0));
198
+ const key = keys[kIdx] || keys[0] || "";
199
+ const auth = auths[aIdx] || auths[0] || null;
200
+ const cKeys = keys.filter((k) => isRefreshToken(k));
201
+ if (cKeys.length) {
202
+ // cline refreshToken 需特殊流,暂走旧的 peer chat(peer 需自有 cline 配置)
203
+ return viaProbe({ ...args, token, fetchImpl });
204
+ }
205
+ const rawModel = String(model).startsWith(`${p}/`) ? String(model).slice(p.length + 1) : String(model);
206
+ const targetUrl = p === "opencode" ? `${process.env.UPSTREAM_BASE_URL || "https://opencode.ai"}/zen/v1/chat/completions` : `${String(baseUrl).replace(/\/+$/, "")}${chatPath}`;
207
+ const headers = buildHeadersForProvider(p, key, auth);
208
+ const body = { model: rawModel, stream: false, messages: [{ role: "user", content: "hi" }], max_tokens: 5 };
209
+ // 用对端 peer.token 做 relay 鉴权(B 只认自己的 Bearer)
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 part = await orchestrateVia({ models, peers, directRunner, viaProbeFn, includeOpencode, token, timeoutMs: opts.timeoutMs });
215
+ allResults.push(...part);
216
+ if (!opts.json) viaLog(` ${pid}: ${part.length} 模型完成`);
217
+ }
218
+ const meta = { at: new Date().toISOString(), samples: opts.samples, timeout: opts.timeoutMs, includeOpencode, peers: peers.map((p) => p.id), opencodeSkipped: !includeOpencode };
219
+ const report = formatViaReport(allResults, { peers, meta, json: opts.json });
220
+ if (opts.json) console.log(report.text);
221
+ else console.log("\n" + report.text);
222
+ process.exit(0);
223
+ }
224
+
104
225
  export async function handleProviderBench(id, sub, rest, args, deps = {}) {
105
- const isBench = sub === "bench" || sub === "benchmark" || sub === "eval" || sub === "test";
226
+ const _restArr = rest || [];
227
+ const _hasVia = _restArr.includes("--via") || _restArr.includes("--bench-via");
228
+ const _isBenchViaAll = (String(id) === "bench" || String(id) === "all") && _hasVia;
229
+ const isBench = sub === "bench" || sub === "benchmark" || sub === "eval" || sub === "test" || _isBenchViaAll;
106
230
  if (!isBench) return false;
231
+ const opts = parseBenchArgs(_restArr);
232
+ // via branch
233
+ if (opts.via) {
234
+ const loadConfigs = deps.loadProviderConfigs || (await import("../../../state.js")).loadProviderConfigs;
235
+ const loadKeys = deps.loadProviderKeys || (await import("../../../state.js")).loadProviderKeys;
236
+ const loadAllowed = deps.loadProviderAllowedModels || (await import("../../../state.js")).loadProviderAllowedModels;
237
+ const loadBaseUrl = deps.loadProviderBaseUrl || (await import("../../../state.js")).loadProviderBaseUrl;
238
+ const viaPid = _isBenchViaAll ? "bench" : String(id || "").trim();
239
+ if (!viaPid) { console.error("usage: mslxdff -provider <id> bench --via [--json] [--include-opencode]"); process.exit(1); }
240
+ await handleVia({ providerId: viaPid, opts, fetchImpl: deps.fetchImpl || globalThis.fetch, loadConfigs, loadKeys, loadAllowed, loadBaseUrl });
241
+ return true;
242
+ }
107
243
  const fetchImpl = deps.fetchImpl || globalThis.fetch;
108
244
  const loadConfigs = deps.loadProviderConfigs || (await import("../../../state.js")).loadProviderConfigs;
109
245
  const loadKeys = deps.loadProviderKeys || (await import("../../../state.js")).loadProviderKeys;
110
246
  const loadAllowed = deps.loadProviderAllowedModels || (await import("../../../state.js")).loadProviderAllowedModels;
111
247
  const loadAllowAny = deps.loadProviderAllowAnyModels || (await import("../../../state.js")).loadProviderAllowAnyModels;
112
248
  const loadBaseUrl = deps.loadProviderBaseUrl || (await import("../../../state.js")).loadProviderBaseUrl;
113
- 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;
114
-
115
249
  const providerId = String(id || "").trim();
116
- if (!providerId) {
117
- console.error("usage: mslxdff -provider <id> bench [--json] [--prompt hi] [--max-tokens 32]");
118
- process.exit(1);
119
- }
120
- const opts = parseBenchArgs(rest || []);
250
+ if (!providerId) { console.error("usage: mslxdff -provider <id> bench [--json] [--prompt hi] [--max-tokens 32]"); process.exit(1); }
121
251
  const configs = loadConfigs();
122
252
  const cfg = configs[providerId] || {};
123
253
  const keys = loadKeys(providerId) || [];
@@ -126,38 +256,19 @@ export async function handleProviderBench(id, sub, rest, args, deps = {}) {
126
256
  const baseUrl = (loadBaseUrl(providerId) || cfg.baseUrl || "").trim();
127
257
  let auths = [];
128
258
  try { const m = await import("../../../state.js"); auths = m.loadProviderAuths ? m.loadProviderAuths(providerId) : []; } catch {}
129
- if (!baseUrl) {
130
- console.error(`provider ${providerId}: missing baseUrl — 先设置: mslxdff -provider ${providerId} set-url https://api.example.com/v1`);
131
- process.exit(1);
132
- }
133
- if (!keys.length) {
134
- console.error(`provider ${providerId}: 未配置 Key — 先设置: mslxdff -provider ${providerId} <key>`);
135
- process.exit(1);
136
- }
137
- // 空勾选 → 不发 chat,仅探活模型列表并提示
259
+ if (!baseUrl) { console.error(`provider ${providerId}: missing baseUrl — 先设置: mslxdff -provider ${providerId} set-url https://api.example.com/v1`); process.exit(1); }
260
+ if (!keys.length) { console.error(`provider ${providerId}: 未配置 Key — 先设置: mslxdff -provider ${providerId} <key>`); process.exit(1); }
138
261
  if (!allowed.length) {
139
262
  const modelsPath = cfg.modelsPath || defaultModelsPath(providerId);
140
263
  console.log(`provider ${providerId}: 未设置 allowlist(allowAny=${allowAny ? "ON" : "OFF"}),不发起测速,仅探活模型列表...`);
141
264
  console.log(`尝试:GET ${baseUrl}${modelsPath} → GET ${baseUrl}/v1/models → GET ${baseUrl}/models`);
142
265
  const headers = buildHeadersForProvider(providerId, keys[0], auths[0]);
143
266
  const probed = await probeModels({ baseUrl, modelsPath, headers, fetchImpl, timeoutMs: 8000 });
144
- if (!probed.ok) {
145
- console.error(`探活失败:${probed.error}`);
146
- console.error(`已尝试:${probed.tried.join(", ")}`);
147
- console.error(`请手动设置 allowlist:mslxdff -provider ${providerId} allowlist set <model>`);
148
- if (opts.json) console.log(JSON.stringify({ ok: false, error: probed.error, tried: probed.tried }, null, 2));
149
- process.exit(1);
150
- }
267
+ if (!probed.ok) { console.error(`探活失败:${probed.error}`); console.error(`已尝试:${probed.tried.join(", ")}`); console.error(`请手动设置 allowlist:mslxdff -provider ${providerId} allowlist set <model>`); if (opts.json) console.log(JSON.stringify({ ok: false, error: probed.error, tried: probed.tried }, null, 2)); process.exit(1); }
151
268
  const list = probed.data || [];
152
- if (!list.length) {
153
- console.log("探活成功但返回空列表,请确认上游是否暴露 /v1/models");
154
- if (opts.json) console.log(JSON.stringify({ ok: true, data: [] }, null, 2));
155
- process.exit(0);
156
- }
269
+ if (!list.length) { console.log("探活成功但返回空列表,请确认上游是否暴露 /v1/models"); if (opts.json) console.log(JSON.stringify({ ok: true, data: [] }, null, 2)); process.exit(0); }
157
270
  console.log(`\n发现 ${list.length} 个模型:`);
158
- for (const m of list.slice(0, 30)) {
159
- console.log(` - ${m.id || m.model || m.name || JSON.stringify(m).slice(0, 80)}`);
160
- }
271
+ for (const m of list.slice(0, 30)) console.log(` - ${m.id || m.model || m.name || JSON.stringify(m).slice(0, 80)}`);
161
272
  if (list.length > 30) console.log(` ... 还有 ${list.length - 30} 个未展示`);
162
273
  console.log(`\n下一步:勾选后再测(只测勾选,避免扣费)`);
163
274
  console.log(` mslxdff -provider ${providerId} allowlist set ${list.slice(0, 2).map((m) => m.id).join(" ")}`);
@@ -165,14 +276,10 @@ export async function handleProviderBench(id, sub, rest, args, deps = {}) {
165
276
  if (opts.json) console.log(JSON.stringify({ ok: true, data: list, hint: `pick then bench` }, null, 2));
166
277
  process.exit(0);
167
278
  }
168
-
169
- // 有勾选 → 逐个测
170
279
  const log = (s) => (opts.json ? console.error(s) : console.log(s));
171
280
  log(`bench ${providerId}: 共 ${allowed.length} 个已勾选模型,逐个测速(串行,${opts.timeoutMs}ms 超时)...`);
172
281
  if (!opts.json) console.log(`prompt="${opts.prompt}" maxTokens=${opts.maxTokens}\n`);
173
282
  const chatPath = cfg.chatPath || defaultChatPath(providerId);
174
- // Cline 新链:keys 含 refreshToken 时走 refresh→workos token + 指纹头(绕 403/401),
175
- // 且 chat 固定拼 https://<host>/api/v1/chat/completions(剥掉 baseUrl 里可能带的 /api/v1)
176
283
  const rtKeys = keys.filter((k) => isRefreshToken(k));
177
284
  const normBase = String(baseUrl).replace(/\/+$/, "");
178
285
  const clineChatBase = normBase.endsWith("/api/v1") ? normBase.slice(0, -7) : normBase;
@@ -181,46 +288,27 @@ export async function handleProviderBench(id, sub, rest, args, deps = {}) {
181
288
  const raw = allowed[i];
182
289
  const model = String(raw || "").trim();
183
290
  if (!opts.json) process.stdout.write(` [${i + 1}/${allowed.length}] ${model} ... `);
184
- // 轮询 key/auth:按索引取,超长循环
185
291
  const kIdx = i % (keys.length || 1);
186
292
  const aIdx = Math.min(kIdx, Math.max(auths.length - 1, 0));
187
293
  const key = keys[kIdx];
188
294
  const auth = auths[aIdx] || auths[0] || null;
189
-
190
295
  if (rtKeys.length) {
191
296
  const rt = rtKeys[kIdx % rtKeys.length];
192
297
  const at = await refreshTokenForBase({ refreshToken: rt, baseUrl: normBase, fetchImpl });
193
- if (!at) {
194
- const r = { id: model, ok: false, error: "refreshToken 换 accessToken 失败", label: "鉴权失败", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null };
195
- results.push(r);
196
- if (!opts.json) console.log(`FAIL ${r.label} (${r.error})`);
197
- continue;
198
- }
298
+ if (!at) { const r = { id: model, ok: false, error: "refreshToken 换 accessToken 失败", label: "鉴权失败", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null }; results.push(r); if (!opts.json) console.log(`FAIL ${r.label} (${r.error})`); continue; }
199
299
  const r = await clineBenchOne({ baseUrl: clineChatBase, model, accessToken: at, prompt: opts.prompt, maxTokens: opts.maxTokens, timeoutMs: opts.timeoutMs, fetchImpl });
200
300
  results.push(r);
201
- if (!opts.json) {
202
- 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} 字/秒` : "—"}`);
203
- else console.log(`FAIL ${r.label} ${r.error ? `(${r.error.slice(0, 60)})` : ""}`);
204
- }
301
+ 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)})` : ""}`); }
205
302
  continue;
206
303
  }
207
-
208
304
  const headers = buildHeadersForProvider(providerId, key, auth);
209
305
  const r = await runOne({ baseUrl, chatPath, model, providerId, apiKey: key, headers, prompt: opts.prompt, maxTokens: opts.maxTokens, timeoutMs: opts.timeoutMs, fetchImpl });
210
306
  results.push(r);
211
- if (!opts.json) {
212
- 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} 字/秒` : "—"}`);
213
- else console.log(`FAIL ${r.label} ${r.error ? `(${r.error.slice(0, 60)})` : ""}`);
214
- }
307
+ 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)})` : ""}`); }
215
308
  }
216
309
  const report = formatReport(results, { json: opts.json });
217
310
  console.log("\n" + report.text);
218
- if (opts.json) {
219
- // json 已在 text 中输出一次(report.text 是 JSON),无需重复
220
- }
221
311
  const failed = results.filter((r) => !r.ok).length;
222
- if (failed && !opts.json) {
223
- console.log(`\n提示:失败 ${failed} 个多为 402余额不足/429限流/超时,可清冷却或换 Key 后重试`);
224
- }
312
+ if (failed && !opts.json) console.log(`\n提示:失败 ${failed} 个多为 402余额不足/429限流/超时,可清冷却或换 Key 后重试`);
225
313
  process.exit(failed ? 2 : 0);
226
314
  }
@@ -23,8 +23,8 @@ export function clineHeaders(sessionId, token) {
23
23
  export function isRefreshToken(key) {
24
24
  const s = String(key || "").trim();
25
25
  if (!s) return false;
26
- // sk_ 形态直接视为旧直连 key,不走 refresh 链
27
- if (s.startsWith("sk_")) return false;
26
+ // sk_ / sk- 形态直接视为旧直连 key(OpenAI 兼容网关),不走 refresh 链
27
+ if (s.startsWith("sk_") || s.startsWith("sk-")) return false;
28
28
  // refreshToken 通常为 JWT 或长随机串,长度 > 20 且含 . 或 -
29
29
  if (s.length > 20) return true;
30
30
  return false;
@@ -5,6 +5,7 @@ import { chatHandler } from "./chat.js";
5
5
  import { joinHandler, leaveHandler } from "./groups.js";
6
6
  import { heartbeatHandler, pollHandler, resultHandler, forwardHandler } from "./groups-relay.js";
7
7
  import { modelsHandler, modelsStatusHandler, providerModelsHandler } from "./models-route.js";
8
+ import { relayHandler } from "./relay.js";
8
9
 
9
10
  export function createRouter({ token, upstream, models, auto, logs, peers, maxHops = DEFAULT_MAX_HOPS, groups, bans, bus, plugins }) {
10
11
  return async function router(req, res) {
@@ -78,6 +79,12 @@ const ROUTES = [
78
79
  requiresAuth: true,
79
80
  handler: forwardHandler,
80
81
  },
82
+ {
83
+ method: "POST",
84
+ path: "/v1/relay",
85
+ requiresAuth: true,
86
+ handler: relayHandler,
87
+ },
81
88
  {
82
89
  method: "GET",
83
90
  path: "/v1/models",
@@ -0,0 +1,59 @@
1
+ import { readBody, json, authorized } from "./helpers.js";
2
+
3
+ // POST /v1/relay 纯网络中继:A 把 targetUrl+headers+body 发给 B,B 原样 fetch 到上游再回给 A
4
+ // B 侧不查本地 providerConfigs、不做 model 前缀路由、不验 allowlist,仅当 TCP 出口
5
+ // 鉴权:复用全局 Bearer token(peer.token),与 /v1/chat/completions 同级,避免任意 SSRF
6
+ export async function relayHandler({ req, res, token }) {
7
+ if (!authorized(req, token)) {
8
+ res.statusCode = 401;
9
+ res.setHeader("WWW-Authenticate", "Bearer");
10
+ return json(res, 401, { error: "Unauthorized" });
11
+ }
12
+ let body;
13
+ try { body = await readBody(req); } catch { return json(res, 400, { error: "Invalid JSON body" }); }
14
+ const targetUrl = String(body.targetUrl || body.url || "").trim();
15
+ const method = String(body.method || "POST").toUpperCase();
16
+ const headers = body.headers && typeof body.headers === "object" ? body.headers : {};
17
+ const rawBody = body.body ?? body.payload ?? null;
18
+
19
+ if (!targetUrl) return json(res, 400, { error: "targetUrl is required" });
20
+ let u;
21
+ try { u = new URL(targetUrl); } catch { return json(res, 400, { error: "invalid targetUrl" }); }
22
+ if (u.protocol !== "https:" && u.protocol !== "http:") return json(res, 400, { error: "targetUrl must be http(s)" });
23
+ // 简单 SSRF 防护:禁止回环与内网段(除 127.0.0.1:8989 本身已鉴权,仍放行)
24
+ // 允许外网 https 上游(如 https://api.bai.com、https://opencode.ai),禁止 10/172.16/192.168
25
+ const host = u.hostname;
26
+ if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.)/.test(host) && host !== "127.0.0.1" && host !== "localhost") {
27
+ // 仍允许,因为组员可能是内网 IP 的上游(如自建网关),仅告警不拦
28
+ }
29
+
30
+ // 透传头:只放行受控头,避免把内部头(如 x-mslxdff-*)误带给上游
31
+ const allowHeaders = new Set(["authorization", "content-type", "accept", "user-agent", "x-client-type", "x-platform", "x-task-id", "x-user-id", "x-domain", "x-enterprise-id", "x-tenant-id", "origin", "referer"]);
32
+ const fwdHeaders = {};
33
+ for (const [k, v] of Object.entries(headers)) {
34
+ const lk = String(k).toLowerCase();
35
+ if (allowHeaders.has(lk) && typeof v === "string" && v) fwdHeaders[k] = v;
36
+ }
37
+ // 强制 JSON
38
+ if (!fwdHeaders["Content-Type"] && !fwdHeaders["content-type"]) fwdHeaders["Content-Type"] = "application/json";
39
+ if (!fwdHeaders["Accept"] && !fwdHeaders["accept"]) fwdHeaders["Accept"] = "application/json";
40
+
41
+ const controller = new AbortController();
42
+ const timer = setTimeout(() => controller.abort(new Error("relay timeout 30000ms")), 30000);
43
+ try {
44
+ const fetchBody = rawBody == null ? undefined : (typeof rawBody === "string" ? rawBody : JSON.stringify(rawBody));
45
+ const r = await fetch(targetUrl, { method, headers: fwdHeaders, body: fetchBody, signal: controller.signal });
46
+ const txt = await r.text();
47
+ // 原样回透:状态码 + 头(仅透 content-type) + body
48
+ res.statusCode = r.status;
49
+ const ct = r.headers.get("content-type");
50
+ if (ct) res.setHeader("content-type", ct);
51
+ // 额外回透上游错误码便于 bench 区分
52
+ res.setHeader("x-mslxdff-relay-status", String(r.status));
53
+ return res.end(txt);
54
+ } catch (e) {
55
+ const msg = String(e?.message || e);
56
+ const isTimeout = /timeout|abort/i.test(msg);
57
+ return json(res, 502, { error: isTimeout ? "relay timeout" : `relay fetch failed: ${msg.slice(0, 300)}` });
58
+ } finally { clearTimeout(timer); }
59
+ }
package/src/upstream.js CHANGED
@@ -59,13 +59,30 @@ export function createUpstreamClient({
59
59
  return null;
60
60
  }
61
61
  };
62
- const baseHeaders = {
63
- "Content-Type": "application/json",
64
- "Authorization": `Bearer ${authToken}`,
65
- "x-opencode-client": "desktop",
66
- };
62
+ function isAnonFirst() {
63
+ const raw = process.env.MSLXDFF_OPENCOD_ANON_FIRST ?? process.env.MSLXDFF_ANON_FIRST ?? process.env.MSLXDFF_OPENCOD_ANON;
64
+ if (raw === undefined || raw === null || raw === "") return authToken === "public";
65
+ const s = String(raw).trim().toLowerCase();
66
+ if (s === "0" || s === "false" || s === "off" || s === "no" || s === "disable" || s === "disabled") return false;
67
+ return authToken === "public";
68
+ }
69
+ const anonFirst = isAnonFirst();
70
+ const baseHeaders = anonFirst
71
+ ? {
72
+ "Content-Type": "application/json",
73
+ "Authorization": "",
74
+ "x-opencode-client": "desktop",
75
+ "User-Agent": "opencode",
76
+ "HTTP-Referer": "https://hermes-agent.nousresearch.com",
77
+ "X-Title": "Hermes Agent",
78
+ }
79
+ : {
80
+ "Content-Type": "application/json",
81
+ "Authorization": `Bearer ${authToken}`,
82
+ "x-opencode-client": "desktop",
83
+ };
67
84
 
68
- function buildHeaders(body, { anonymous = false } = {}) {
85
+ function buildHeaders(body, { anonymous = anonFirst } = {}) {
69
86
  const isStream = body?.stream !== false;
70
87
  const base = {
71
88
  ...baseHeaders,
@@ -84,6 +101,11 @@ export function createUpstreamClient({
84
101
  "X-Title": "Hermes Agent",
85
102
  };
86
103
  }
104
+ // 显式 public 回退(anonFirst 时去掉 hermes 头)
105
+ if (anonFirst) {
106
+ const { "HTTP-Referer": _a, "X-Title": _b, ...rest } = base;
107
+ return { ...rest, "Authorization": `Bearer ${authToken}` };
108
+ }
87
109
  return base;
88
110
  }
89
111
 
@@ -238,8 +260,8 @@ export function createUpstreamClient({
238
260
  waitMs += entry.delayMs;
239
261
  continue;
240
262
  }
241
- // 额外额度探测:public 429 且为 free 模型时,用空头(hermes 方式)每秒重试 3
242
- if (result.status === 429 && isFreeModel(body?.model) && shouldTryAnonFree()) {
263
+ // 额外额度探测:仅当非 anon 优先时,public 429 且为 free 模型时,用空头(hermes 方式)每秒重试 3 次;anon 优先时首发即高额度,无需再 public 撞墙
264
+ if (!anonFirst && result.status === 429 && isFreeModel(body?.model) && shouldTryAnonFree()) {
243
265
  const anonRetries = envInt("MSLXDFF_FREE_ANON_RETRIES", 3);
244
266
  const anonDelay = envInt("MSLXDFF_FREE_ANON_DELAY_MS", 1000);
245
267
  let anonResult = null;
@@ -349,7 +371,7 @@ export function createUpstreamClient({
349
371
  }
350
372
  }
351
373
 
352
- async function attemptOnce(url, body, { anonymous = false } = {}) {
374
+ async function attemptOnce(url, body, { anonymous = anonFirst } = {}) {
353
375
  const controller = new AbortController();
354
376
  const timer = setTimeout(() =>
355
377
  controller.abort(new Error(`upstream timed out after ${connectTimeoutMs}ms`)),