mslxdff 0.1.235 → 0.1.237

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.
Files changed (104) hide show
  1. package/bin/mslxdff.js +3 -3
  2. package/package.json +1 -1
  3. package/src/auto.js +264 -264
  4. package/src/autostart.js +62 -15
  5. package/src/bench/cline-bench.js +42 -42
  6. package/src/bench/probe.js +70 -70
  7. package/src/bench/report.js +162 -162
  8. package/src/bench/runner.js +77 -77
  9. package/src/bench/via-probe.js +124 -124
  10. package/src/bench/via-routes.js +87 -87
  11. package/src/bench/workbuddy-bench.js +54 -54
  12. package/src/chat/engine.js +160 -160
  13. package/src/chat/gateway.js +163 -163
  14. package/src/chat/orchestrator.js +234 -234
  15. package/src/chat/prompt.js +70 -70
  16. package/src/chat/repl.js +88 -88
  17. package/src/chat/terminal.js +135 -135
  18. package/src/chat/tools.js +306 -306
  19. package/src/chat-pipeline/index.js +155 -155
  20. package/src/chat-pipeline/policy.js +84 -84
  21. package/src/cli/commands/group.js +266 -266
  22. package/src/cli/commands/model/picks.js +50 -50
  23. package/src/cli/commands/provider/bench-via.js +247 -247
  24. package/src/cli/commands/provider/bench.js +141 -141
  25. package/src/cli/commands/provider/index.js +139 -139
  26. package/src/cli/commands/provider/models.js +149 -149
  27. package/src/cli/commands/provider/qwenwork-login.js +119 -119
  28. package/src/cli/commands/provider/raccoon-quota.js +156 -156
  29. package/src/cli/commands/sync.js +310 -310
  30. package/src/cli/status.js +282 -279
  31. package/src/daemon.js +246 -246
  32. package/src/model-capabilities/enrich.js +86 -86
  33. package/src/model-capabilities/index.js +183 -183
  34. package/src/model-capabilities/parse.js +70 -70
  35. package/src/models.js +293 -293
  36. package/src/peers.js +389 -345
  37. package/src/providers/agentrouter.js +238 -238
  38. package/src/providers/base.js +164 -164
  39. package/src/providers/cline/auth.js +228 -228
  40. package/src/providers/cline/chat.js +307 -307
  41. package/src/providers/cline.js +2 -2
  42. package/src/providers/codearts/chat.js +207 -207
  43. package/src/providers/dispatcher.js +147 -147
  44. package/src/providers/globalqwenwork/cosy.js +123 -123
  45. package/src/providers/globalqwenwork/crypto.js +218 -218
  46. package/src/providers/globalqwenwork/http.js +20 -20
  47. package/src/providers/globalqwenwork/rsa.js +54 -54
  48. package/src/providers/globalqwenwork/sse.js +268 -268
  49. package/src/providers/globalqwenwork/stream.js +130 -130
  50. package/src/providers/globalqwenwork/upstream.js +122 -122
  51. package/src/providers/keyring.js +60 -60
  52. package/src/providers/qoder/chat.js +292 -292
  53. package/src/providers/qoder/index.js +311 -311
  54. package/src/providers/qoder/sse.js +233 -233
  55. package/src/providers/qwenwork/account-store.js +133 -133
  56. package/src/providers/qwenwork/constants.js +67 -67
  57. package/src/providers/qwenwork/cosy.js +120 -120
  58. package/src/providers/qwenwork/crypto.js +218 -218
  59. package/src/providers/qwenwork/http.js +20 -20
  60. package/src/providers/qwenwork/index.js +337 -337
  61. package/src/providers/qwenwork/payload.js +144 -144
  62. package/src/providers/qwenwork/rsa.js +54 -54
  63. package/src/providers/qwenwork/sse.js +268 -268
  64. package/src/providers/qwenwork/stream.js +130 -130
  65. package/src/providers/qwenwork/upstream.js +120 -120
  66. package/src/providers/qwenwork.js +1 -1
  67. package/src/providers/raccoon/const.js +219 -219
  68. package/src/providers/raccoon/credits.js +285 -285
  69. package/src/providers/registry.js +89 -89
  70. package/src/providers/workbuddy/chat.js +298 -298
  71. package/src/providers/workbuddy/reshape.js +152 -152
  72. package/src/providers/workbuddy.js +2 -2
  73. package/src/reasoning.js +32 -32
  74. package/src/routes/chat/broadband-handler.js +80 -80
  75. package/src/routes/chat/gateway.js +46 -46
  76. package/src/routes/chat/hedge-handler.js +4 -1
  77. package/src/routes/chat/peer-handler.js +5 -1
  78. package/src/routes/chat/relay-pipeline.js +282 -282
  79. package/src/routes/chat/via-route-handler.js +158 -156
  80. package/src/routes/hedge.js +260 -260
  81. package/src/routes/models-route.js +167 -167
  82. package/src/routes/peers.js +373 -373
  83. package/src/routes/stream.js +364 -364
  84. package/src/runtime/bootstrap.js +75 -75
  85. package/src/runtime/keep-warm.js +135 -135
  86. package/src/runtime/provider-gate.js +75 -75
  87. package/src/runtime/providers-setup.js +187 -187
  88. package/src/runtime/raccoon-checkin.js +175 -175
  89. package/src/runtime/server-lifecycle.js +34 -10
  90. package/src/server.js +85 -85
  91. package/src/state/schemas/allowlist.js +92 -92
  92. package/src/sync-opencode.js +280 -280
  93. package/src/transport/index.js +244 -244
  94. package/src/transport/pool.js +56 -56
  95. package/src/transport/retry.js +24 -24
  96. package/src/transport/sse.js +93 -93
  97. package/src/upstream-probe/display.js +52 -52
  98. package/src/upstream-probe/probe.js +49 -49
  99. package/src/upstream-probe/rotate.js +110 -110
  100. package/src/upstream-probe/start.js +45 -45
  101. package/src/upstream.js +355 -355
  102. package/docs/cli_help_mini.md +0 -149
  103. package/src/providers/AGENTS.md +0 -49
  104. package/src/routes/AGENTS.md +0 -37
package/src/autostart.js CHANGED
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node
3
3
  import { homedir, userInfo } from "node:os";
4
4
  import { join, dirname } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { daemonDir, readPid } from "./daemon.js";
6
+ import { daemonDir, readPid, isPidAlive } from "./daemon.js";
7
7
  import { writeExitMarker } from "./runtime/lifecycle-forensics.js";
8
8
 
9
9
  const TASK_NAME = "mslxdff";
@@ -139,7 +139,47 @@ WantedBy=default.target
139
139
  `;
140
140
  }
141
141
 
142
+
143
+ /**
144
+ * 从 pid 文件原文算出「要不要等某个裸 daemon 退出」;null = 不等。
145
+ * 自杀护栏①:pid 文件写的就是**本进程**时(daemon 自己 `writePid(process.pid)`),
146
+ * 等它退出、再补一发 SIGKILL,就是等自己死 + 补自己一刀 —— 0.1.236 真机的死法之一。
147
+ */
148
+ export function pickBareDaemonPid(raw, self = process.pid, isAlive = isPidAlive) {
149
+ const n = Number(String(raw ?? "").trim().split("\n")[0]);
150
+ if (!Number.isInteger(n) || n <= 0) return null;
151
+ if (n === self) return null;
152
+ return isAlive(n) ? n : null;
153
+ }
154
+
155
+ /**
156
+ * 把「端口持有者集合」拆成「可以杀的」+「自己也是持有者吗」—— 自杀护栏②的判据。
157
+ * 为什么需要:`ss -lptn 'sport = :<port>'` 对本用户自己的进程会显示 pid,集合里必然有 self。
158
+ * 旧代码第一圈 `continue` 跳过自己、第二圈**无差别 SIGKILL** ⇒ daemon 启动 4~5 秒后杀掉自己
159
+ * (0.1.236 真机:errors.log 记 unexplained-kill、daemon.alive 停在 uptimeMs:0、
160
+ * systemd unit 永远停在 disabled,下次裸跑继续循环)。
161
+ * 返回的 selfWasHolder 供调用方决定要不要跑 `fuser -k <port>/tcp`:它按端口无差别杀,绕不过 self。
162
+ */
163
+ export function partitionKillTargets(pids, self = process.pid) {
164
+ const set = new Set();
165
+ for (const p of pids || []) {
166
+ const n = Number(p);
167
+ if (Number.isInteger(n) && n > 0) set.add(n);
168
+ }
169
+ const selfWasHolder = set.has(self);
170
+ set.delete(self);
171
+ return { targets: [...set], selfWasHolder };
172
+ }
142
173
  async function linuxEnable() {
174
+ // systemd --user 不可用时**什么都不清理**:先杀掉还能用的裸 daemon、再发现 systemd 起不来,
175
+ // 等于用一次失败把服务弄死。只在 Linux 探测(macOS 也走本函数但没有 systemd,行为保持原样)。
176
+ if (process.platform === "linux") {
177
+ const probe = await execAsync("systemctl", ["--user", "show-environment"]);
178
+ if (probe.err) {
179
+ const why = (probe.stderr || probe.stdout || probe.err.message || "").slice(0, 200);
180
+ return { ok: false, error: `systemd --user 不可用(${why || "systemctl 失败"}):未做任何清理,裸 daemon 继续运行` };
181
+ }
182
+ }
143
183
  const unit = systemdUnitPath();
144
184
  try {
145
185
  mkdirSync(dirname(unit), { recursive: true });
@@ -158,35 +198,43 @@ async function linuxEnable() {
158
198
  lingerRes = lr.err ? (lr.stderr || lr.stdout || lr.err.message || "").slice(0, 200) : "ok";
159
199
  }
160
200
  } catch {}
201
+ // 先 daemon-reload + enable,再清理裸 daemon:顺序反过来,一旦清理途中进程没了(旧版就是「杀自己」),
202
+ // unit 会永远停在 disabled,下一次裸跑又走进来 —— 这正是 0.1.236 的自我循环。
203
+ let r = await execAsync("systemctl", ["--user", "daemon-reload"]);
204
+ if (r.err) return { ok: false, error: (r.stderr || r.stdout).slice(0, 500) };
205
+ r = await execAsync("systemctl", ["--user", "enable", SERVICE_NAME]);
206
+ if (r.err) return { ok: false, error: (r.stderr || r.stdout).slice(0, 500) };
161
207
  // stop bare detached daemon that may hold the port, let systemd take over (best-effort, wait for port free)
162
208
  try {
163
209
  // 路径相对**本文件**解析:本文件在 src/ 下,daemon.js 与 server.js 都在 src/ 内。
164
210
  // 旧写的 `../daemon.js` / `../server.js` 从 src/ 出发指向仓库根 —— 两处都不存在,
165
211
  // 整个「停裸 daemon → 等端口释放 → 让 systemd 接管」的分支被后面的 catch {} 静默吞掉、从未执行过。
166
- const { isPidAlive, stopDaemon } = await import("./daemon.js");
212
+ const { stopDaemon } = await import("./daemon.js");
167
213
  const { resolvePort } = await import("./server.js");
168
214
  const port = resolvePort();
169
- const pidFile = join(homedir(), ".config", "mslxdff", "daemon.pid");
215
+ const pidFile = join(daemonDir(), "daemon.pid"); // 权威路径,尊重 MSLXDFF_DAEMON_DIR(旧代码硬编码 ~/.config ⇒ 清错 pid 文件)
170
216
  const { existsSync: exists2, readFileSync: read2 } = await import("node:fs");
171
217
  let pidToWait = null;
172
218
  try {
173
219
  if (exists2(pidFile)) {
174
- const raw = read2(pidFile, "utf8").trim().split("\n")[0];
175
- const n = Number(raw);
176
- if (Number.isInteger(n) && n > 0 && isPidAlive(n)) pidToWait = n;
220
+ const raw = read2(pidFile, "utf8");
221
+ // 自杀护栏①:pid 文件写的就是本进程时(daemon 自己 writePid)既不能等、更不能杀
222
+ pidToWait = pickBareDaemonPid(raw);
177
223
  }
178
224
  } catch {}
179
225
  try { stopDaemon(); } catch {}
180
226
  // ensure bare daemon really exits (SIGTERM -> SIGKILL)
181
227
  for (let i = 0; i < 15; i++) {
182
- if (pidToWait && isPidAlive(pidToWait)) {
183
- if (i === 7) try { process.kill(pidToWait, "SIGKILL"); } catch {}
228
+ if (pidToWait && pidToWait !== process.pid && isPidAlive(pidToWait)) {
229
+ if (i === 7 && pidToWait !== process.pid) try { process.kill(pidToWait, "SIGKILL"); } catch {}
184
230
  await new Promise((r2) => setTimeout(r2, 200));
185
231
  } else break;
186
232
  }
187
233
  // robust: scan ss for any holder of :port (covers stale pidFile, fuser not installed)
188
234
  const killHolders = async () => {
189
235
  let killed = 0;
236
+ // 自己是不是端口持有者(决定要不要跑 fuser -k);探测失败时按「是」处理,宁可不杀
237
+ let selfWasHolder = false;
190
238
  try {
191
239
  const r = await execAsync("ss", ["-lptn", `sport = :${port}`]);
192
240
  const out = r.stdout || "";
@@ -206,18 +254,21 @@ async function linuxEnable() {
206
254
  while ((m2 = re2.exec(line))) pids.add(Number(m2[1]));
207
255
  }
208
256
  }
257
+ // 自杀护栏②:ss 会把自己报成端口持有者 ⇒ 剔除 self,SIGKILL/fuser 一律不打自己
258
+ const part = partitionKillTargets(pids, process.pid);
259
+ selfWasHolder = part.selfWasHolder;
209
260
  for (const p of pids) {
210
261
  if (p === process.pid) continue;
211
262
  if (p === readPid()) { try { writeExitMarker(daemonDir(), { reason: "autostart-cleanup", prevPid: p, prevVersion: null, byPid: process.pid }); } catch {} }
212
263
  try { process.kill(p, "SIGTERM"); killed++; } catch {}
213
264
  }
214
265
  if (killed) await new Promise((r2) => setTimeout(r2, 400));
215
- for (const p of pids) {
266
+ for (const p of part.targets) {
216
267
  try { if (isPidAlive(p)) process.kill(p, "SIGKILL"); } catch {}
217
268
  }
218
- } catch {}
269
+ } catch { selfWasHolder = true; } // ss 探测失败 ⇒ 谁占着端口不清楚,fuser 一律不开火
219
270
  // fuser as extra best-effort (may not exist)
220
- try { await execAsync("fuser", ["-k", `${port}/tcp`]); } catch {}
271
+ if (!selfWasHolder) { try { await execAsync("fuser", ["-k", `${port}/tcp`]); } catch {} }
221
272
  return killed;
222
273
  };
223
274
  await killHolders();
@@ -231,10 +282,6 @@ async function linuxEnable() {
231
282
  await new Promise((r2) => setTimeout(r2, 250));
232
283
  }
233
284
  } catch {}
234
- let r = await execAsync("systemctl", ["--user", "daemon-reload"]);
235
- if (r.err) return { ok: false, error: (r.stderr || r.stdout).slice(0, 500) };
236
- r = await execAsync("systemctl", ["--user", "enable", SERVICE_NAME]);
237
- if (r.err) return { ok: false, error: (r.stderr || r.stdout).slice(0, 500) };
238
285
  r = await execAsync("systemctl", ["--user", "start", SERVICE_NAME]);
239
286
  // Verify actually active; if not, surface journal hint
240
287
  const chk = await execAsync("systemctl", ["--user", "is-active", SERVICE_NAME]);
@@ -1,42 +1,42 @@
1
- import { clineHeaders } from "../providers/cline/headers.js";
2
- import { computeMetrics } from "../metrics.js";
3
- import { createTransport } from "../transport/index.js";
4
-
5
- function sseContent(obj) {
6
- const c = obj?.choices?.[0]?.delta?.content || obj?.choices?.[0]?.message?.content || "";
7
- return typeof c === "string" ? c : "";
8
- }
9
-
10
- export async function clineBenchOne({ baseUrl, model, accessToken, prompt, maxTokens, timeoutMs, fetchImpl }) {
11
- const tr = createTransport({ fetchImpl, keepAlive: false, retry: {}, timeoutMs });
12
- let ttfbMs = null;
13
- let totalMs = null;
14
- let content = "";
15
- try {
16
- const sid = `sess_bench_${Date.now()}`;
17
- const res = await tr.request({
18
- url: `${baseUrl}/api/v1/chat/completions`,
19
- method: "POST",
20
- headers: { ...clineHeaders(sid, accessToken), Accept: "text/event-stream" },
21
- body: { model, messages: [{ role: "user", content: prompt }], stream: true, max_tokens: maxTokens, session_id: sid, reasoning_effort: "high" },
22
- stream: true,
23
- });
24
- if (!res.ok) {
25
- let txt = "";
26
- try { txt = await res.text(); } catch {}
27
- const label = res.status === 401 ? "鉴权失败" : res.status === 429 ? "限流" : res.status >= 500 ? `上游错误 ${res.status}` : `HTTP ${res.status}`;
28
- return { id: model, ok: false, status: res.status, label, error: txt.slice(0, 300), ttfbMs, totalMs: res.totalMs, tps: null, charsPerSec: null, tokens: null };
29
- }
30
- for await (const ev of res.stream()) {
31
- try { content += sseContent(JSON.parse(ev)); } catch {}
32
- }
33
- ttfbMs = res.ttfbMs;
34
- totalMs = res.totalMs;
35
- const chars = content.length;
36
- const { tps, charsPerSec } = computeMetrics({ ttfbMs, totalMs, promptTokens: null, completionTokens: null, chars });
37
- return { id: model, ok: true, status: 200, label: "成功", ttfbMs, totalMs, tps, charsPerSec, tokens: null, chars };
38
- } catch (e) {
39
- const msg = e?.message || String(e);
40
- return { id: model, ok: false, label: /timeout|abort/i.test(msg) ? "超时" : "网络错误", error: msg.slice(0, 300), ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
41
- }
42
- }
1
+ import { clineHeaders } from "../providers/cline/headers.js";
2
+ import { computeMetrics } from "../metrics.js";
3
+ import { createTransport } from "../transport/index.js";
4
+
5
+ function sseContent(obj) {
6
+ const c = obj?.choices?.[0]?.delta?.content || obj?.choices?.[0]?.message?.content || "";
7
+ return typeof c === "string" ? c : "";
8
+ }
9
+
10
+ export async function clineBenchOne({ baseUrl, model, accessToken, prompt, maxTokens, timeoutMs, fetchImpl }) {
11
+ const tr = createTransport({ fetchImpl, keepAlive: false, retry: {}, timeoutMs });
12
+ let ttfbMs = null;
13
+ let totalMs = null;
14
+ let content = "";
15
+ try {
16
+ const sid = `sess_bench_${Date.now()}`;
17
+ const res = await tr.request({
18
+ url: `${baseUrl}/api/v1/chat/completions`,
19
+ method: "POST",
20
+ headers: { ...clineHeaders(sid, accessToken), Accept: "text/event-stream" },
21
+ body: { model, messages: [{ role: "user", content: prompt }], stream: true, max_tokens: maxTokens, session_id: sid, reasoning_effort: "high" },
22
+ stream: true,
23
+ });
24
+ if (!res.ok) {
25
+ let txt = "";
26
+ try { txt = await res.text(); } catch {}
27
+ const label = res.status === 401 ? "鉴权失败" : res.status === 429 ? "限流" : res.status >= 500 ? `上游错误 ${res.status}` : `HTTP ${res.status}`;
28
+ return { id: model, ok: false, status: res.status, label, error: txt.slice(0, 300), ttfbMs, totalMs: res.totalMs, tps: null, charsPerSec: null, tokens: null };
29
+ }
30
+ for await (const ev of res.stream()) {
31
+ try { content += sseContent(JSON.parse(ev)); } catch {}
32
+ }
33
+ ttfbMs = res.ttfbMs;
34
+ totalMs = res.totalMs;
35
+ const chars = content.length;
36
+ const { tps, charsPerSec } = computeMetrics({ ttfbMs, totalMs, promptTokens: null, completionTokens: null, chars });
37
+ return { id: model, ok: true, status: 200, label: "成功", ttfbMs, totalMs, tps, charsPerSec, tokens: null, chars };
38
+ } catch (e) {
39
+ const msg = e?.message || String(e);
40
+ return { id: model, ok: false, label: /timeout|abort/i.test(msg) ? "超时" : "网络错误", error: msg.slice(0, 300), ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
41
+ }
42
+ }
@@ -1,71 +1,71 @@
1
- import { joinUrl } from "../providers/base.js";
2
- import { getCustomNormalizer } from "../providers/registry.js";
3
- import { createTransport } from "../transport/index.js";
1
+ import { joinUrl } from "../providers/base.js";
2
+ import { getCustomNormalizer } from "../providers/registry.js";
3
+ import { createTransport } from "../transport/index.js";
4
4
  import { compatFetch } from "../compat.js";
5
-
6
- function normalizeModelsPayload(json, baseUrl = "") {
7
- if (!json) return [];
8
- try {
9
- const custom = getCustomNormalizer(baseUrl);
10
- if (custom) {
11
- const v = custom(json);
12
- if (Array.isArray(v)) return v;
13
- }
14
- } catch {}
15
- if (Array.isArray(json)) return json;
16
- if (Array.isArray(json.data)) return json.data;
17
- if (Array.isArray(json.models)) return json.models;
18
- if (json.data && typeof json.data === "object" && Array.isArray(json.data.data)) return json.data.data;
19
- return [];
20
- }
21
-
22
- function toModelId(m) {
23
- if (!m) return "";
24
- if (typeof m === "string") return m;
25
- if (typeof m.id === "string") return m.id;
26
- if (typeof m.model === "string") return m.model;
27
- if (typeof m.name === "string") return m.name;
28
- return "";
29
- }
30
-
31
- export async function probeModels({
32
- baseUrl,
33
- modelsPath,
34
- chatPath,
35
- headers = {},
36
- fetchImpl = compatFetch,
37
- timeoutMs = 8000,
38
- } = {}) {
39
- const base = String(baseUrl || "").replace(/\/+$/, "");
40
- if (!base) return { ok: false, error: "missing baseUrl", tried: [], data: [] };
41
- const candidates = [];
42
- const seen = new Set();
43
- const push = (p) => {
44
- const s = String(p || "").trim();
45
- if (!s) return;
46
- const norm = s.startsWith("/") ? s : `/${s}`;
47
- if (!seen.has(norm)) { seen.add(norm); candidates.push(norm); }
48
- };
49
- if (modelsPath) push(modelsPath);
50
- push("/v1/models");
51
- push("/models");
52
- const tr = createTransport({ fetchImpl, keepAlive: false, retry: {}, timeoutMs });
53
- const tried = [];
54
- let lastError = "";
55
- for (const p of candidates) {
56
- const url = joinUrl(base, p);
57
- tried.push(url);
58
- try {
59
- const res = await tr.request({ url, method: "GET", headers, stream: false });
60
- if (!res.ok) { lastError = `HTTP ${res.status}`; continue; }
61
- let json = {};
62
- try { json = await res.json(); } catch { json = {}; }
63
- const raw = normalizeModelsPayload(json, baseUrl);
64
- const data = raw.map((m) => ({ id: toModelId(m), raw: m })).filter((x) => x.id).map((x) => ({ id: x.id, ...x.raw }));
65
- return { ok: true, data, tried, url, rawCount: raw.length };
66
- } catch (e) {
67
- lastError = e?.message || String(e);
68
- }
69
- }
70
- return { ok: false, error: lastError || "all probes failed", tried, data: [] };
71
- }
5
+
6
+ function normalizeModelsPayload(json, baseUrl = "") {
7
+ if (!json) return [];
8
+ try {
9
+ const custom = getCustomNormalizer(baseUrl);
10
+ if (custom) {
11
+ const v = custom(json);
12
+ if (Array.isArray(v)) return v;
13
+ }
14
+ } catch {}
15
+ if (Array.isArray(json)) return json;
16
+ if (Array.isArray(json.data)) return json.data;
17
+ if (Array.isArray(json.models)) return json.models;
18
+ if (json.data && typeof json.data === "object" && Array.isArray(json.data.data)) return json.data.data;
19
+ return [];
20
+ }
21
+
22
+ function toModelId(m) {
23
+ if (!m) return "";
24
+ if (typeof m === "string") return m;
25
+ if (typeof m.id === "string") return m.id;
26
+ if (typeof m.model === "string") return m.model;
27
+ if (typeof m.name === "string") return m.name;
28
+ return "";
29
+ }
30
+
31
+ export async function probeModels({
32
+ baseUrl,
33
+ modelsPath,
34
+ chatPath,
35
+ headers = {},
36
+ fetchImpl = compatFetch,
37
+ timeoutMs = 8000,
38
+ } = {}) {
39
+ const base = String(baseUrl || "").replace(/\/+$/, "");
40
+ if (!base) return { ok: false, error: "missing baseUrl", tried: [], data: [] };
41
+ const candidates = [];
42
+ const seen = new Set();
43
+ const push = (p) => {
44
+ const s = String(p || "").trim();
45
+ if (!s) return;
46
+ const norm = s.startsWith("/") ? s : `/${s}`;
47
+ if (!seen.has(norm)) { seen.add(norm); candidates.push(norm); }
48
+ };
49
+ if (modelsPath) push(modelsPath);
50
+ push("/v1/models");
51
+ push("/models");
52
+ const tr = createTransport({ fetchImpl, keepAlive: false, retry: {}, timeoutMs });
53
+ const tried = [];
54
+ let lastError = "";
55
+ for (const p of candidates) {
56
+ const url = joinUrl(base, p);
57
+ tried.push(url);
58
+ try {
59
+ const res = await tr.request({ url, method: "GET", headers, stream: false });
60
+ if (!res.ok) { lastError = `HTTP ${res.status}`; continue; }
61
+ let json = {};
62
+ try { json = await res.json(); } catch { json = {}; }
63
+ const raw = normalizeModelsPayload(json, baseUrl);
64
+ const data = raw.map((m) => ({ id: toModelId(m), raw: m })).filter((x) => x.id).map((x) => ({ id: x.id, ...x.raw }));
65
+ return { ok: true, data, tried, url, rawCount: raw.length };
66
+ } catch (e) {
67
+ lastError = e?.message || String(e);
68
+ }
69
+ }
70
+ return { ok: false, error: lastError || "all probes failed", tried, data: [] };
71
+ }