dsh-api-probe 0.0.0-stage → 1.0.1
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/LICENSE +21 -0
- package/README.md +288 -2
- package/cordis.patch.yml +6 -0
- package/icon.svg +5 -0
- package/lib/client.js +1240 -0
- package/lib/index.js +1612 -0
- package/package.json +75 -4
- package/screenshots.json +6 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1612 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-api-probe — host half.
|
|
3
|
+
*
|
|
4
|
+
* Serves the API health-check as a small NDJSON stream over one POST route:
|
|
5
|
+
* the browser posts a target (base url / key / model), the host runs the four
|
|
6
|
+
* probe stages and emits one event per check as it happens, so the panel can
|
|
7
|
+
* show live progress instead of a spinner for the whole run.
|
|
8
|
+
*
|
|
9
|
+
* Why raw sockets instead of `fetch`: the headline number this tool exists to
|
|
10
|
+
* produce is time-to-first-token, and `fetch` gives no guarantee about when a
|
|
11
|
+
* chunk actually lands (its reader surfaces whatever the decompressor has
|
|
12
|
+
* flushed). Driving `node:http`/`node:tls` directly means every SSE chunk is
|
|
13
|
+
* timestamped on arrival, and it makes HTTP proxy support a two-line add: open
|
|
14
|
+
* a CONNECT tunnel, then speak plain HTTP over the raw socket.
|
|
15
|
+
*
|
|
16
|
+
* The key is read from the request body, lives only in this function's stack,
|
|
17
|
+
* and is never written to a log line or a response body.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
21
|
+
import net from "node:net";
|
|
22
|
+
import tls from "node:tls";
|
|
23
|
+
import http from "node:http";
|
|
24
|
+
import https from "node:https";
|
|
25
|
+
|
|
26
|
+
/** Route family. The client bundle mirrors these literals. */
|
|
27
|
+
export const ROUTES = {
|
|
28
|
+
probe: "/api/dsh-api-probe/probe",
|
|
29
|
+
health: "/api/dsh-api-probe/health",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Stable cordis plugin name. */
|
|
33
|
+
export const name = "api-probe";
|
|
34
|
+
|
|
35
|
+
/** Services required before the probe routes can mount. */
|
|
36
|
+
export const inject = ["webServer"];
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// presets
|
|
40
|
+
//
|
|
41
|
+
// The provider list lives in lib/client.js and ONLY there — it drives the
|
|
42
|
+
// dropdown, so a second copy on the host side could never stay in sync with it.
|
|
43
|
+
//
|
|
44
|
+
// There used to be one here: 10 entries with hand-written model IDs
|
|
45
|
+
// (`claude-sonnet-4-20250514`, `gemini-2.5-flash`, `qwen-plus`, …). Nothing read
|
|
46
|
+
// it except a `presets: 10` count on the health route, which contradicted the
|
|
47
|
+
// 38 the panel actually shows, and it carried exactly the failure mode the
|
|
48
|
+
// client list was rebuilt to avoid: a dated model snapshot that silently rots
|
|
49
|
+
// and then sends the user into a 400. Model IDs are the vendor's to change, so
|
|
50
|
+
// the only trustworthy source is that endpoint's own /models response.
|
|
51
|
+
//
|
|
52
|
+
// If you need the list on the host, read it from the client module rather than
|
|
53
|
+
// copying it here.
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
const DEFAULT_PROMPT = "用一句话介绍你自己。";
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// trust fence
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
function isIPv4Loopback(v4) {
|
|
63
|
+
const parts = v4.split(".");
|
|
64
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isLoopbackAddress(address) {
|
|
68
|
+
if (typeof address !== "string") return false;
|
|
69
|
+
const v = address.toLowerCase();
|
|
70
|
+
if (v === "::1") return true;
|
|
71
|
+
if (v.startsWith("::ffff:")) return isIPv4Loopback(v.slice(7));
|
|
72
|
+
return isIPv4Loopback(v);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isLoopbackHostname(hostname) {
|
|
76
|
+
return hostname === "localhost" || hostname === "[::1]" || isIPv4Loopback(hostname);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Request-level trust fence.
|
|
81
|
+
*
|
|
82
|
+
* The probe route takes an API key and dials an arbitrary outbound URL, so it
|
|
83
|
+
* is loopback-only unless remoteWebUiPairing is loaded and the request carries
|
|
84
|
+
* a live paired-device cookie. The socket address is authoritative;
|
|
85
|
+
* X-Forwarded-For is never trusted.
|
|
86
|
+
* @param request - the incoming request.
|
|
87
|
+
* @returns whether the socket address, Host header and browser same-origin
|
|
88
|
+
* markers all agree this came from this machine's own GUI.
|
|
89
|
+
*/
|
|
90
|
+
function isLoopbackRequest(request) {
|
|
91
|
+
if (!isLoopbackAddress(request.socket?.remoteAddress)) return false;
|
|
92
|
+
const host = request.headers.host;
|
|
93
|
+
if (typeof host !== "string") return false;
|
|
94
|
+
let hostUrl;
|
|
95
|
+
try {
|
|
96
|
+
hostUrl = new URL("http://" + host);
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false;
|
|
101
|
+
if (request.headers["sec-fetch-site"] === "cross-site") return false;
|
|
102
|
+
const origin = request.headers.origin;
|
|
103
|
+
if (origin === undefined) return true;
|
|
104
|
+
try {
|
|
105
|
+
return new URL(origin).host === hostUrl.host;
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isPairingAccess(value) {
|
|
112
|
+
return value !== undefined && value !== null && typeof value.isPairedDevice === "function";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Loopback, or a live paired-device cookie when remote-web-ui is loaded.
|
|
117
|
+
*
|
|
118
|
+
* This is the single trust-fence gate for both routes — deliberately one
|
|
119
|
+
* function, not two near-identical copies that can drift apart.
|
|
120
|
+
* @param ctx - host plugin context, used to look up remoteWebUiPairing.
|
|
121
|
+
* @param request - the incoming request.
|
|
122
|
+
*/
|
|
123
|
+
function isPairedOrLoopbackAllowed(ctx, request) {
|
|
124
|
+
if (isLoopbackRequest(request)) return true;
|
|
125
|
+
let fromGet;
|
|
126
|
+
try {
|
|
127
|
+
fromGet = typeof ctx.get === "function" ? ctx.get("remoteWebUiPairing", false) : undefined;
|
|
128
|
+
} catch {
|
|
129
|
+
fromGet = undefined;
|
|
130
|
+
}
|
|
131
|
+
const pairing = isPairingAccess(fromGet) ? fromGet : ctx.remoteWebUiPairing;
|
|
132
|
+
return pairing?.isPairedDevice(request) === true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// http plumbing
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
function withTimeout(promise, ms, message, onTimeout) {
|
|
140
|
+
let timer;
|
|
141
|
+
const guard = new Promise((_resolve, reject) => {
|
|
142
|
+
timer = setTimeout(() => {
|
|
143
|
+
if (onTimeout) onTimeout();
|
|
144
|
+
reject(new Error(message));
|
|
145
|
+
}, ms);
|
|
146
|
+
});
|
|
147
|
+
return Promise.race([promise, guard]).finally(() => clearTimeout(timer));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function tcpConnect({ host, port, timeoutMs }) {
|
|
151
|
+
return new Promise((resolve, reject) => {
|
|
152
|
+
const socket = net.connect({ host, port });
|
|
153
|
+
const fail = (error) => {
|
|
154
|
+
socket.destroy();
|
|
155
|
+
reject(error);
|
|
156
|
+
};
|
|
157
|
+
socket.setTimeout(timeoutMs, () => fail(new Error(`连接 ${host}:${port} 超时(${timeoutMs}ms 内无响应)`)));
|
|
158
|
+
socket.once("error", fail);
|
|
159
|
+
socket.once("connect", () => {
|
|
160
|
+
socket.setTimeout(0);
|
|
161
|
+
socket.off("error", fail);
|
|
162
|
+
resolve(socket);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Open a CONNECT tunnel through an HTTP proxy and hand back the raw socket. */
|
|
168
|
+
function connectTunnel({ proxyUrl, host, port, timeoutMs }) {
|
|
169
|
+
const target = `${host}:${port}`;
|
|
170
|
+
return new Promise((resolve, reject) => {
|
|
171
|
+
const proxy = new URL(proxyUrl);
|
|
172
|
+
const request = http.request({
|
|
173
|
+
host: proxy.hostname,
|
|
174
|
+
port: Number(proxy.port) || 80,
|
|
175
|
+
method: "CONNECT",
|
|
176
|
+
path: target,
|
|
177
|
+
headers: { Host: target },
|
|
178
|
+
agent: false,
|
|
179
|
+
});
|
|
180
|
+
request.setTimeout(timeoutMs, () => {
|
|
181
|
+
request.destroy(new Error(`代理 ${proxy.host} 连接超时`));
|
|
182
|
+
});
|
|
183
|
+
request.once("error", (error) => {
|
|
184
|
+
reject(new Error(`代理连接失败:${error.message}`));
|
|
185
|
+
});
|
|
186
|
+
request.once("connect", (response, socket) => {
|
|
187
|
+
if (response.statusCode !== 200) {
|
|
188
|
+
socket.destroy();
|
|
189
|
+
reject(new Error(`代理返回 ${response.statusCode},拒绝 CONNECT ${target}`));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
socket.setTimeout(0);
|
|
193
|
+
resolve(socket);
|
|
194
|
+
});
|
|
195
|
+
request.end();
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function tlsUpgrade(socket, servername, insecure, timeoutMs) {
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
const secure = tls.connect({
|
|
202
|
+
socket,
|
|
203
|
+
servername,
|
|
204
|
+
rejectUnauthorized: insecure !== true,
|
|
205
|
+
});
|
|
206
|
+
secure.setTimeout(timeoutMs, () => {
|
|
207
|
+
secure.destroy(new Error(`TLS 握手超时(${timeoutMs}ms)`));
|
|
208
|
+
});
|
|
209
|
+
secure.once("error", (error) => reject(error));
|
|
210
|
+
secure.once("secureConnect", () => {
|
|
211
|
+
secure.setTimeout(0);
|
|
212
|
+
resolve(secure);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Establish the byte pipe for one request: direct TCP (+TLS), or a CONNECT
|
|
219
|
+
* tunnel followed by TLS when a proxy is configured.
|
|
220
|
+
*/
|
|
221
|
+
async function establish(url, cfg, timeoutMs) {
|
|
222
|
+
const secure = url.protocol === "https:";
|
|
223
|
+
const port = url.port ? Number(url.port) : secure ? 443 : 80;
|
|
224
|
+
const host = url.hostname;
|
|
225
|
+
let socket;
|
|
226
|
+
try {
|
|
227
|
+
if (cfg.proxy) {
|
|
228
|
+
socket = await connectTunnel({ proxyUrl: cfg.proxy, host, port, timeoutMs });
|
|
229
|
+
} else {
|
|
230
|
+
socket = await tcpConnect({ host, port, timeoutMs });
|
|
231
|
+
}
|
|
232
|
+
} catch (error) {
|
|
233
|
+
error.probePhase = cfg.proxy ? "proxy" : "connect";
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
if (!secure) return socket;
|
|
237
|
+
try {
|
|
238
|
+
return await tlsUpgrade(socket, host, cfg.insecure === true, timeoutMs);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
socket.destroy();
|
|
241
|
+
error.probePhase = "tls";
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Send one request over an established socket and resolve on response headers.
|
|
248
|
+
*
|
|
249
|
+
* Everything that can go wrong here collapses into one identical message if you
|
|
250
|
+
* only look at "no response arrived" — which is exactly how a tool ends up
|
|
251
|
+
* claiming an API is dead when all it knows is that its own request stalled.
|
|
252
|
+
* So every failure carries a `probePhase` saying where it died, and the trace
|
|
253
|
+
* records whether the request bytes were actually handed to the socket and
|
|
254
|
+
* whether the peer closed before saying anything.
|
|
255
|
+
*/
|
|
256
|
+
function sendRequest({ url, socket, cfg, method, headers, body, timeoutMs }) {
|
|
257
|
+
return new Promise((resolve, reject) => {
|
|
258
|
+
const secure = url.protocol === "https:";
|
|
259
|
+
const mod = secure ? https : http;
|
|
260
|
+
const trace = { phase: "connect", sentBytes: 0, closedBeforeStatus: false, status: null };
|
|
261
|
+
let sawResponse = false;
|
|
262
|
+
const request = mod.request({
|
|
263
|
+
host: url.hostname,
|
|
264
|
+
port: url.port ? Number(url.port) : secure ? 443 : 80,
|
|
265
|
+
path: (url.pathname || "/") + (url.search || ""),
|
|
266
|
+
method,
|
|
267
|
+
headers,
|
|
268
|
+
createConnection: () => socket,
|
|
269
|
+
agent: false,
|
|
270
|
+
});
|
|
271
|
+
const kill = (message, phase) => {
|
|
272
|
+
const error = new Error(message);
|
|
273
|
+
error.probePhase = phase ?? trace.phase;
|
|
274
|
+
error.probeTrace = { ...trace, phase: error.probePhase };
|
|
275
|
+
try {
|
|
276
|
+
request.destroy();
|
|
277
|
+
} catch {
|
|
278
|
+
/* already gone */
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
socket.destroy();
|
|
282
|
+
} catch {
|
|
283
|
+
/* already gone */
|
|
284
|
+
}
|
|
285
|
+
reject(error);
|
|
286
|
+
};
|
|
287
|
+
// The peer hanging up before it says anything is a different failure from
|
|
288
|
+
// "no reply yet" — only this listener can tell the two apart.
|
|
289
|
+
socket.once("close", () => {
|
|
290
|
+
trace.closedBeforeStatus = !sawResponse;
|
|
291
|
+
if (!sawResponse) kill("对端在给出任何回应之前就把连接关断了", "closed");
|
|
292
|
+
});
|
|
293
|
+
trace.phase = "write";
|
|
294
|
+
trace.sentBytes = body === undefined ? 0 : Buffer.byteLength(String(body));
|
|
295
|
+
request.setTimeout(timeoutMs, () => kill(`请求超时(${timeoutMs}ms 内服务端没有完整响应)`, "wait-status"));
|
|
296
|
+
request.once("error", (error) => kill(error.message, "write"));
|
|
297
|
+
request.once("response", (response) => {
|
|
298
|
+
sawResponse = true;
|
|
299
|
+
trace.phase = "status";
|
|
300
|
+
trace.status = response.statusCode ?? null;
|
|
301
|
+
resolve({ request, response, trace });
|
|
302
|
+
});
|
|
303
|
+
if (body === undefined) request.end();
|
|
304
|
+
else request.end(body);
|
|
305
|
+
void cfg;
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Read an entire response body as text (non-streaming endpoints). */
|
|
310
|
+
async function readAll(response, limitBytes = 4 * 1024 * 1024) {
|
|
311
|
+
const chunks = [];
|
|
312
|
+
let size = 0;
|
|
313
|
+
for await (const chunk of response) {
|
|
314
|
+
size += chunk.length;
|
|
315
|
+
if (size > limitBytes) break;
|
|
316
|
+
chunks.push(chunk);
|
|
317
|
+
}
|
|
318
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Yield SSE `data:` payloads as they arrive, one per line.
|
|
323
|
+
* `[DONE]` is yielded like any other payload so the caller decides what ends
|
|
324
|
+
* the stream (Gemini never sends it).
|
|
325
|
+
*/
|
|
326
|
+
async function* ssePayloads(response) {
|
|
327
|
+
let buffer = "";
|
|
328
|
+
for await (const chunk of response) {
|
|
329
|
+
buffer += chunk.toString("utf8");
|
|
330
|
+
let index;
|
|
331
|
+
while ((index = buffer.indexOf("\n")) >= 0) {
|
|
332
|
+
const line = buffer.slice(0, index).replace(/\r$/, "");
|
|
333
|
+
buffer = buffer.slice(index + 1);
|
|
334
|
+
if (line === "" || !line.startsWith("data:")) continue;
|
|
335
|
+
const payload = line.slice(5).trim();
|
|
336
|
+
if (payload !== "") yield payload;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const tail = buffer.trim();
|
|
340
|
+
if (tail.startsWith("data:")) {
|
|
341
|
+
const payload = tail.slice(5).trim();
|
|
342
|
+
if (payload !== "") yield payload;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// url + auth helpers
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Normalize a user-typed base url: drop trailing slashes, and supply the
|
|
352
|
+
* `/v1` that OpenAI-compatible providers expect when the user pasted only a
|
|
353
|
+
* host. Anything with an explicit path is left untouched so a relay station's
|
|
354
|
+
* custom prefix survives.
|
|
355
|
+
*/
|
|
356
|
+
export function normBase(raw) {
|
|
357
|
+
let text = String(raw ?? "").trim();
|
|
358
|
+
if (text === "") return "";
|
|
359
|
+
if (!/^https?:\/\//i.test(text)) text = "https://" + text;
|
|
360
|
+
text = text.replace(/\/+$/, "");
|
|
361
|
+
let parsed;
|
|
362
|
+
try {
|
|
363
|
+
parsed = new URL(text);
|
|
364
|
+
} catch {
|
|
365
|
+
return text;
|
|
366
|
+
}
|
|
367
|
+
// Always clear search and hash - they're not part of the base URL for our purposes
|
|
368
|
+
parsed.search = "";
|
|
369
|
+
parsed.hash = "";
|
|
370
|
+
|
|
371
|
+
const path = parsed.pathname.replace(/\/+$/, "");
|
|
372
|
+
if (path === "" || path === "/") {
|
|
373
|
+
parsed.pathname = "/v1";
|
|
374
|
+
}
|
|
375
|
+
return parsed.toString().replace(/\/+$/, "");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function joinUrl(base, suffix) {
|
|
379
|
+
const trimmed = base.replace(/\/+$/, "");
|
|
380
|
+
return trimmed + (suffix.startsWith("/") ? suffix : "/" + suffix);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function maskKey(key) {
|
|
384
|
+
if (!key) return "(未填)";
|
|
385
|
+
const text = String(key);
|
|
386
|
+
if (text.length <= 10) return text.slice(0, 2) + "****";
|
|
387
|
+
return `${text.slice(0, 6)}****${text.slice(-4)}`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Auth carriers, tried in order. `query` exists for Google's native API. */
|
|
391
|
+
function authHeaders(key, scheme, api) {
|
|
392
|
+
const headers = { accept: "application/json" };
|
|
393
|
+
if (!key) return headers;
|
|
394
|
+
if (scheme === "x-api-key") {
|
|
395
|
+
headers["x-api-key"] = key;
|
|
396
|
+
if (api === "anthropic") headers["anthropic-version"] = "2023-06-01";
|
|
397
|
+
} else if (scheme === "query") {
|
|
398
|
+
// carried in the URL, not the headers
|
|
399
|
+
} else {
|
|
400
|
+
headers.authorization = `Bearer ${key}`;
|
|
401
|
+
if (api === "anthropic") headers["anthropic-version"] = "2023-06-01";
|
|
402
|
+
}
|
|
403
|
+
return headers;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function applyQueryKey(urlStr, key, scheme) {
|
|
407
|
+
if (scheme !== "query" || !key) return urlStr;
|
|
408
|
+
const url = new URL(urlStr);
|
|
409
|
+
url.searchParams.set("key", key);
|
|
410
|
+
return url.toString();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Non-chat model families that cannot answer a chat request. */
|
|
414
|
+
function isNonChatModel(id) {
|
|
415
|
+
return /(^|[-_/])(embedding|embeddings|rerank|whisper|tts|moderation|image|audio|whisper-1|guard|reranker)([-_/]|$)/i.test(id);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const PREFERRED_MODEL_PATTERNS = [
|
|
419
|
+
/^gpt-4o-mini$/i,
|
|
420
|
+
/^gpt-4\.1-mini$/i,
|
|
421
|
+
/^gpt-4o$/i,
|
|
422
|
+
/^gpt-4-mini$/i,
|
|
423
|
+
/^deepseek-chat$/i,
|
|
424
|
+
/^claude-3-5-haiku/i,
|
|
425
|
+
/^gemini-.*-flash$/i,
|
|
426
|
+
/^qwen.*$/i,
|
|
427
|
+
];
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Choose a model to talk to. An explicit choice always wins; otherwise prefer a
|
|
431
|
+
* small/fast family (probe cost should stay low) and skip embeddings-class ids
|
|
432
|
+
* that would 400 on a chat request.
|
|
433
|
+
*/
|
|
434
|
+
export function pickModel(configured, names) {
|
|
435
|
+
if (configured && configured.trim() !== "") return { model: configured.trim(), auto: false };
|
|
436
|
+
const usable = names.filter((name) => typeof name === "string" && name.trim() !== "" && !isNonChatModel(name));
|
|
437
|
+
if (usable.length === 0) return { model: "", auto: false };
|
|
438
|
+
for (const pattern of PREFERRED_MODEL_PATTERNS) {
|
|
439
|
+
const hit = usable.find((name) => pattern.test(name));
|
|
440
|
+
if (hit) return { model: hit, auto: true };
|
|
441
|
+
}
|
|
442
|
+
return { model: [...usable].sort()[0], auto: true };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Levenshtein distance, abandoned early once it exceeds `max`. */
|
|
446
|
+
function editDistance(a, b, max) {
|
|
447
|
+
if (Math.abs(a.length - b.length) > max) return max + 1;
|
|
448
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
449
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
450
|
+
const row = [i];
|
|
451
|
+
let rowMin = i;
|
|
452
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
453
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
454
|
+
row[j] = Math.min(row[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
455
|
+
if (row[j] < rowMin) rowMin = row[j];
|
|
456
|
+
}
|
|
457
|
+
if (rowMin > max) return max + 1;
|
|
458
|
+
prev = row;
|
|
459
|
+
}
|
|
460
|
+
return prev[b.length];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Closest entry in `names` to what the user typed, for a "did you mean" hint.
|
|
465
|
+
*
|
|
466
|
+
* Case-insensitive equality is checked first because providers are wildly
|
|
467
|
+
* inconsistent about capitalisation (SenseNova-Lite vs sensenova-lite), and that
|
|
468
|
+
* alone already fixes most real typos. Only then fall back to edit distance.
|
|
469
|
+
*/
|
|
470
|
+
export function nearestModelName(input, names) {
|
|
471
|
+
const want = String(input ?? "").trim().toLowerCase();
|
|
472
|
+
if (want === "" || !Array.isArray(names) || names.length === 0) return null;
|
|
473
|
+
const lowered = names.filter((n) => typeof n === "string" && n.trim() !== "").map((n) => [n, n.toLowerCase()]);
|
|
474
|
+
const exact = lowered.find(([, low]) => low === want);
|
|
475
|
+
if (exact) return exact[0];
|
|
476
|
+
let best = null;
|
|
477
|
+
let bestScore = Infinity;
|
|
478
|
+
for (const [original, low] of lowered) {
|
|
479
|
+
const score = editDistance(want, low, 4);
|
|
480
|
+
if (score < bestScore) {
|
|
481
|
+
bestScore = score;
|
|
482
|
+
best = original;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
// A long shared name with a few edits apart is a coincidence, not a near miss.
|
|
486
|
+
return bestScore <= 4 ? best : null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** CJK runs cost about one token per char; latin about four chars per token. */
|
|
490
|
+
export function estimateTokens(text) {
|
|
491
|
+
if (!text) return 0;
|
|
492
|
+
const cjk = (text.match(/[㐀-鿿豈--ヿ가-]/g) || []).length;
|
|
493
|
+
const rest = text.length - cjk;
|
|
494
|
+
return Math.max(1, Math.round(cjk + rest / 4));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
// payload / content extraction
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
function buildChatRequest(cfg, model, stream) {
|
|
502
|
+
if (cfg.api === "anthropic") {
|
|
503
|
+
return {
|
|
504
|
+
url: joinUrl(cfg.base, "/messages"),
|
|
505
|
+
body: {
|
|
506
|
+
model,
|
|
507
|
+
max_tokens: cfg.maxTokens,
|
|
508
|
+
stream,
|
|
509
|
+
messages: [{ role: "user", content: cfg.prompt }],
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
if (cfg.api === "gemini") {
|
|
514
|
+
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
|
|
515
|
+
return {
|
|
516
|
+
url: joinUrl(cfg.base, `/models/${encodeURIComponent(model)}:${action}`),
|
|
517
|
+
body: {
|
|
518
|
+
contents: [{ role: "user", parts: [{ text: cfg.prompt }] }],
|
|
519
|
+
generationConfig: { maxOutputTokens: cfg.maxTokens },
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
return {
|
|
524
|
+
url: joinUrl(cfg.base, "/chat/completions"),
|
|
525
|
+
body: {
|
|
526
|
+
model,
|
|
527
|
+
max_tokens: cfg.maxTokens,
|
|
528
|
+
stream,
|
|
529
|
+
messages: [{ role: "user", content: cfg.prompt }],
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Visible text carried by one streamed chunk, per provider dialect. */
|
|
535
|
+
function extractDelta(obj, api) {
|
|
536
|
+
if (api === "anthropic") {
|
|
537
|
+
if (obj.type !== "content_block_delta") return "";
|
|
538
|
+
return typeof obj.delta?.text === "string" ? obj.delta.text : "";
|
|
539
|
+
}
|
|
540
|
+
if (api === "gemini") {
|
|
541
|
+
const parts = obj.candidates?.[0]?.content?.parts;
|
|
542
|
+
if (!Array.isArray(parts)) return "";
|
|
543
|
+
let text = "";
|
|
544
|
+
for (const part of parts) if (typeof part.text === "string") text += part.text;
|
|
545
|
+
return text;
|
|
546
|
+
}
|
|
547
|
+
const choice = obj.choices?.[0];
|
|
548
|
+
if (!choice) return "";
|
|
549
|
+
if (typeof choice.delta?.content === "string") return choice.delta.content;
|
|
550
|
+
if (typeof choice.message?.content === "string") return choice.message.content;
|
|
551
|
+
if (typeof choice.text === "string") return choice.text;
|
|
552
|
+
return "";
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function extractText(obj, api) {
|
|
556
|
+
if (api === "anthropic") {
|
|
557
|
+
const parts = obj.content;
|
|
558
|
+
if (!Array.isArray(parts)) return "";
|
|
559
|
+
let text = "";
|
|
560
|
+
for (const part of parts) if (typeof part.text === "string") text += part.text;
|
|
561
|
+
return text;
|
|
562
|
+
}
|
|
563
|
+
if (api === "gemini") {
|
|
564
|
+
const parts = obj.candidates?.[0]?.content?.parts;
|
|
565
|
+
if (!Array.isArray(parts)) return "";
|
|
566
|
+
let text = "";
|
|
567
|
+
for (const part of parts) if (typeof part.text === "string") text += part.text;
|
|
568
|
+
return text;
|
|
569
|
+
}
|
|
570
|
+
const content = obj.choices?.[0]?.message?.content;
|
|
571
|
+
return typeof content === "string" ? content : "";
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function extractUsage(obj) {
|
|
575
|
+
const usage = obj.usage;
|
|
576
|
+
if (usage && typeof usage.completion_tokens === "number") return usage.completion_tokens;
|
|
577
|
+
if (usage && typeof usage.output_tokens === "number") return usage.output_tokens;
|
|
578
|
+
const meta = obj.usageMetadata;
|
|
579
|
+
if (meta && typeof meta.candidatesTokenCount === "number") return meta.candidatesTokenCount;
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ---------------------------------------------------------------------------
|
|
584
|
+
// error translation
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Turn a transport error or an HTTP status into something a non-engineer can
|
|
589
|
+
* act on. `status === 0` means the request never produced a response.
|
|
590
|
+
*/
|
|
591
|
+
export function diagnose({ status, errorText, cfg, model, phase, trace }) {
|
|
592
|
+
const detail = String(errorText ?? "");
|
|
593
|
+
// No HTTP status at all. The phase says exactly which step died, which is
|
|
594
|
+
// strictly more informative than "no response" — and it is the difference
|
|
595
|
+
// between "the request never left the machine" and "the request was sent and
|
|
596
|
+
// the provider went quiet", two things a user must never be told as one.
|
|
597
|
+
if (status <= 0 && phase) {
|
|
598
|
+
const bytes = trace && Number.isFinite(trace.sentBytes) ? trace.sentBytes : null;
|
|
599
|
+
const sentLine = bytes === null ? "请求正文" : `${bytes} 字节的请求正文`;
|
|
600
|
+
const map = {
|
|
601
|
+
connect: {
|
|
602
|
+
level: "bad",
|
|
603
|
+
title: "连不上服务器,请求根本没发出去",
|
|
604
|
+
detail: "TCP 连接都没建起来,因此没有任何请求发到服务商。",
|
|
605
|
+
hint: "这跟 Key 无关。检查网络/防火墙/公司网络限制;确认域名和端口没写错。",
|
|
606
|
+
},
|
|
607
|
+
proxy: {
|
|
608
|
+
level: "bad",
|
|
609
|
+
title: "连不上代理服务器,请求根本没发出去",
|
|
610
|
+
detail: "没能通过你填的代理建立连接。",
|
|
611
|
+
hint: "检查「高级设置」里的代理地址是否正确、代理软件是否在运行;不需要代理就把它清空。",
|
|
612
|
+
},
|
|
613
|
+
tls: {
|
|
614
|
+
level: "bad",
|
|
615
|
+
title: "TLS 加密握手失败,请求根本没发出去",
|
|
616
|
+
detail: "TCP 通了,但加密握手没能完成,所以请求没有送出。",
|
|
617
|
+
hint: "常见于证书过期、被中间设备拦截、或线路要求走代理。可勾选「忽略证书错误」再试一次确认。",
|
|
618
|
+
},
|
|
619
|
+
write: {
|
|
620
|
+
level: "bad",
|
|
621
|
+
title: "连接通了,但请求没能写出去",
|
|
622
|
+
detail: "连接是活的,往这条连接里写请求时失败了。",
|
|
623
|
+
hint: "多半是连接被中途掐断。换个网络(手机热点)重试一次,或在「高级设置」填上代理。",
|
|
624
|
+
},
|
|
625
|
+
closed: {
|
|
626
|
+
level: "bad",
|
|
627
|
+
title: "请求已发出,但服务商立刻断开连接",
|
|
628
|
+
detail: `连接建立成功、${sentLine}也已写出,对端却一个字节的回应都没给就把连接关了。`,
|
|
629
|
+
hint: "常见于被网关/WAF 拦截、该线路需要特定代理、Key 被限流封禁,或地址需要不同的线路。试试「高级设置」填代理;仍不行就换一家或联系服务商。",
|
|
630
|
+
},
|
|
631
|
+
"wait-status": {
|
|
632
|
+
level: "bad",
|
|
633
|
+
title: "请求已发出,但服务端一直没回应",
|
|
634
|
+
detail: `${sentLine}已经写进连接发出去了,服务商连 HTTP 状态行都没返回,一直等到超时。`,
|
|
635
|
+
hint: "请求确实送出去了,这不是「没调用」。多半是线路被墙/需要走代理、服务端卡住,或上游在排队。在「高级设置」填上代理再试一次;也可把超时调大试试。",
|
|
636
|
+
},
|
|
637
|
+
"wait-first-token": {
|
|
638
|
+
level: "bad",
|
|
639
|
+
title: "连上了、状态也是 200,但一直等不到正文",
|
|
640
|
+
detail: "服务端接受了请求并返回 200,却在超时内一个 token 都没吐出来。",
|
|
641
|
+
hint: "通常是上游模型排队或卡死。等几十秒重试;连续几次都这样说明这条线路不稳定。",
|
|
642
|
+
},
|
|
643
|
+
};
|
|
644
|
+
const hit = map[phase];
|
|
645
|
+
if (hit) return { ...hit, status, phase };
|
|
646
|
+
}
|
|
647
|
+
if (status > 0) {
|
|
648
|
+
// 2xx 里出错是最容易被误报的一类:网关接受了请求却没吐出正文。服务商
|
|
649
|
+
// 自己给的那句错误信息远比状态码有用,绝不能被"意料之外的状态码"盖掉。
|
|
650
|
+
if (status >= 200 && status < 300) {
|
|
651
|
+
if (/正文都没返回/.test(detail)) {
|
|
652
|
+
return {
|
|
653
|
+
level: "bad",
|
|
654
|
+
title: "返回 200,但内容是空的",
|
|
655
|
+
detail: "网络通、鉴权过、服务端也回了 200,可整个响应里没有一个字的正文。",
|
|
656
|
+
hint: "常见三种原因:① 触发了服务商的内容风控 —— 上面「测试提示词」换个中性点的说法再试一次;② 模型名不被认可但被网关静默吞掉 —— 在「模型名」里换成模型列表里确有的那个;③ 中转站上游失败后拿 200 空包糊弄你,这种得换一家。",
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
const provider = detail.trim();
|
|
660
|
+
if (provider) {
|
|
661
|
+
// 优先翻译服务商自己说的话:它比任何通用文案都准。
|
|
662
|
+
const rule = [
|
|
663
|
+
[/余额|欠费|额度|quota|billing|insufficient|arrears/i, "欠费或额度用尽", "Key 是对的,但这个账户没钱了或套餐用完了。去服务商后台充值。"],
|
|
664
|
+
[/模型.{0,6}(不存在|不存在于|无效)|model.{0,10}(not found|not exist|invalid|unsupported)|no such model/i, "模型名不被认可", "Key 和地址都对,问题出在模型名。去服务商官网核对确切写法(大小写、前缀、有没有 -latest 之类的后缀)。"],
|
|
665
|
+
[/权限|无权|permission|forbidden|not allowed|unauthorized/i, "这个 Key 没有调用权限", "Key 本身有效,但所属账号没开通这个模型,或者没做地区授权。"],
|
|
666
|
+
[/风控|审核|敏感|违规|content.?filter|safety|moderation/i, "内容被风控拦了", "响应本身是空的,但请求撞上了服务商的内容审核。换一个中性提示词,或换个模型再试。"],
|
|
667
|
+
[/超时|timeout|overloaded|rate.?limit/i, "服务端处理不过来", "上游当时正忙或超时了。等几十秒重试一次;连续几次都这样说明这条线路不稳。"],
|
|
668
|
+
];
|
|
669
|
+
for (const [re, title, hint] of rule) {
|
|
670
|
+
if (re.test(provider)) return { level: "bad", title, detail: `服务商返回:${provider.slice(0, 300)}`, hint };
|
|
671
|
+
}
|
|
672
|
+
return {
|
|
673
|
+
level: "bad",
|
|
674
|
+
title: "回 200,但没拿到内容",
|
|
675
|
+
detail: `服务商返回:${provider.slice(0, 300)}`,
|
|
676
|
+
hint: "状态码是成功的,说明地址和 Key 都通。上面是服务商自己给的错误信息,按它说的处理即可。",
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
return {
|
|
680
|
+
level: "bad",
|
|
681
|
+
title: "回 200,但没拿到内容",
|
|
682
|
+
detail: `服务端返回 ${status},但响应里没有可用的正文。`,
|
|
683
|
+
hint: "状态码是成功的,说明地址和 Key 都通。换个模型名或提示词再试一次;一直这样,多半是这个中转站上游出问题了。",
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
if (status === 401) {
|
|
687
|
+
return {
|
|
688
|
+
level: "bad",
|
|
689
|
+
title: "Key 无效",
|
|
690
|
+
detail: "服务端返回 401 Unauthorized。",
|
|
691
|
+
hint: "Key 打错了、已过期、或者复制时少了几个字符。也可能这个 Key 属于另一个服务商。",
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
if (status === 403) {
|
|
695
|
+
return {
|
|
696
|
+
level: "bad",
|
|
697
|
+
title: "Key 有效,但没权限",
|
|
698
|
+
detail: "服务端返回 403 Forbidden。",
|
|
699
|
+
hint: "常见原因:Key 所属账号没有这个模型的权限,或者服务商做了地区限制(OpenAI / Anthropic / Gemini 都有)。",
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
if (status === 404) {
|
|
703
|
+
// Report what was actually requested and nothing more. Measured against
|
|
704
|
+
// NVIDIA: /v1/chat/completions answers 405 (exists) while /chat/completions
|
|
705
|
+
// answers 404 — so a 404 does mean "nothing at this path", but which path
|
|
706
|
+
// is wrong (missing /v1, a relay prefix, a typo) is provider-specific and
|
|
707
|
+
// guessing here just produces confident nonsense. normBase() already
|
|
708
|
+
// appends /v1 to a bare host, so this cannot be the user's typo unless
|
|
709
|
+
// their own address really lacks it.
|
|
710
|
+
return {
|
|
711
|
+
level: "bad",
|
|
712
|
+
title: "这个地址上没有接口(404)",
|
|
713
|
+
detail: `请求的是 ${cfg.base}/chat/completions,服务商返回 404。`,
|
|
714
|
+
hint: "请对照该服务商的文档确认地址:有的要带 /v1,有的要带 /compatible-mode/v1 之类的前缀,中转站还有自己的规则。地址和模型名都取自服务商官方说明最稳妥。",
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
if (status === 429) {
|
|
718
|
+
return {
|
|
719
|
+
level: "bad",
|
|
720
|
+
title: "Key 有效,但被限流或欠费",
|
|
721
|
+
detail: "服务端返回 429 Too Many Requests。",
|
|
722
|
+
hint: "这是最容易被误判的一种:它说明 Key 是对的。多数中转站 429 的真实含义是余额不足或套餐用尽,也有确实是并发超限的。",
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
if (status === 400) {
|
|
726
|
+
return {
|
|
727
|
+
level: "bad",
|
|
728
|
+
title: "请求被拒绝",
|
|
729
|
+
detail: "服务端返回 400 Bad Request。",
|
|
730
|
+
hint: model
|
|
731
|
+
? `多半是模型名 "${model}" 这个服务商不认,或者该模型不支持流式输出。去服务商官网核对一下模型名。`
|
|
732
|
+
: "多半是请求格式和这个服务商的方言对不上,换一个服务商预设试试。",
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
if (status === 402) {
|
|
736
|
+
return {
|
|
737
|
+
level: "bad",
|
|
738
|
+
title: "欠费",
|
|
739
|
+
detail: "服务端返回 402 Payment Required。",
|
|
740
|
+
hint: "Key 是有效的,账户没钱了。",
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
if (status >= 500) {
|
|
744
|
+
return {
|
|
745
|
+
level: "bad",
|
|
746
|
+
title: "服务商自己出问题了",
|
|
747
|
+
detail: `服务端返回 ${status}。`,
|
|
748
|
+
hint: "这不是你的问题,也通常不是 Key 的问题。等一会儿再试,或者换一家中转站。",
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
return {
|
|
752
|
+
level: "bad",
|
|
753
|
+
title: `意料之外的状态码 ${status}`,
|
|
754
|
+
detail,
|
|
755
|
+
hint: "",
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// No response at all — this is a network-layer failure.
|
|
760
|
+
if (/ENOTFOUND|EAI_AGAIN/i.test(detail)) {
|
|
761
|
+
return {
|
|
762
|
+
level: "bad",
|
|
763
|
+
title: "域名解析不了",
|
|
764
|
+
detail: detail.split("\n")[0],
|
|
765
|
+
hint: "地址可能拼错了,或者这台机器的 DNS 有问题。换个地址验证一下。",
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
if (/ECONNREFUSED/i.test(detail)) {
|
|
769
|
+
return {
|
|
770
|
+
level: "bad",
|
|
771
|
+
title: "服务商拒绝连接",
|
|
772
|
+
detail: detail.split("\n")[0],
|
|
773
|
+
hint: "端口上没有服务在听。地址或端口写错了,或者服务商那边服务没启动。",
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
if (/ETIMEDOUT|超时/i.test(detail)) {
|
|
777
|
+
return {
|
|
778
|
+
level: "bad",
|
|
779
|
+
title: "连不上(超时)",
|
|
780
|
+
detail: detail.split("\n")[0],
|
|
781
|
+
hint: cfg.proxy
|
|
782
|
+
? "代理配了但没通。检查代理地址和端口。"
|
|
783
|
+
: "多半是需要走代理却直连。展开「高级设置」填上代理,再试一次。",
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
if (/CERT_|UNABLE_TO_VERIFY|SELF_SIGNED|self.signed|altnames/i.test(detail)) {
|
|
787
|
+
return {
|
|
788
|
+
level: "bad",
|
|
789
|
+
title: "证书有问题",
|
|
790
|
+
detail: detail.split("\n")[0],
|
|
791
|
+
hint: "证书过期、域名对不上,或被中间设备劫持了。别在证书出问题的连接上输入 Key。",
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
if (/proxy|ECONNRESET|socket hang up/i.test(detail)) {
|
|
795
|
+
return {
|
|
796
|
+
level: "bad",
|
|
797
|
+
title: "连接被中断",
|
|
798
|
+
detail: detail.split("\n")[0],
|
|
799
|
+
hint: "如果配了代理,多半是代理本身有问题;没配代理则可能是服务商主动断开了连接。",
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
return {
|
|
803
|
+
level: "bad",
|
|
804
|
+
title: "请求失败",
|
|
805
|
+
detail: detail.split("\n")[0] || "未知错误",
|
|
806
|
+
hint: "",
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// ---------------------------------------------------------------------------
|
|
811
|
+
// stage 1 — network
|
|
812
|
+
// ---------------------------------------------------------------------------
|
|
813
|
+
|
|
814
|
+
async function probeNetwork(cfg, emit) {
|
|
815
|
+
const checks = [];
|
|
816
|
+
const add = (id, label, status, detail, techDetail, ms) => {
|
|
817
|
+
const entry = { id, label, status, detail, techDetail: techDetail ?? null, ms: ms === undefined ? null : Math.round(ms) };
|
|
818
|
+
checks.push(entry);
|
|
819
|
+
emit({ type: "check", ...entry });
|
|
820
|
+
return entry;
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
const url = new URL(cfg.base);
|
|
824
|
+
const secure = url.protocol === "https:";
|
|
825
|
+
const port = url.port ? Number(url.port) : secure ? 443 : 80;
|
|
826
|
+
|
|
827
|
+
// DNS
|
|
828
|
+
let dnsOk = false;
|
|
829
|
+
let dnsMs = 0;
|
|
830
|
+
try {
|
|
831
|
+
const started = performance.now();
|
|
832
|
+
const result = await withTimeout(dnsLookup(url.hostname), 5000, "DNS 查询超时(5s)");
|
|
833
|
+
dnsMs = performance.now() - started;
|
|
834
|
+
dnsOk = true;
|
|
835
|
+
add("dns", "域名解析", "ok", "域名解析成功", `${url.hostname} → ${result.address}`, dnsMs);
|
|
836
|
+
} catch (error) {
|
|
837
|
+
add("dns", "域名解析", "fail", error.message, null, 0);
|
|
838
|
+
add("tcp", "端口连通", "skip", "域名都解析不了,跳过", null, null);
|
|
839
|
+
if (secure) add("tls", "TLS 握手", "skip", "域名都解析不了,跳过", null, null);
|
|
840
|
+
return { ok: false, checks };
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// TCP
|
|
844
|
+
let socket = null;
|
|
845
|
+
let tcpMs = 0;
|
|
846
|
+
try {
|
|
847
|
+
const started = performance.now();
|
|
848
|
+
socket = await withTimeout(
|
|
849
|
+
cfg.proxy
|
|
850
|
+
? connectTunnel({ proxyUrl: cfg.proxy, host: url.hostname, port, timeoutMs: cfg.timeout })
|
|
851
|
+
: tcpConnect({ host: url.hostname, port, timeoutMs: cfg.timeout }),
|
|
852
|
+
cfg.timeout + 500,
|
|
853
|
+
"连接超时",
|
|
854
|
+
);
|
|
855
|
+
tcpMs = performance.now() - started;
|
|
856
|
+
add("tcp", "端口连通", "ok", cfg.proxy ? `经代理连到 ${url.hostname}:${port}` : `${url.hostname}:${port} 接通`, null, tcpMs);
|
|
857
|
+
} catch (error) {
|
|
858
|
+
const info = diagnose({ status: 0, errorText: error.message, cfg });
|
|
859
|
+
add("tcp", "端口连通", "fail", info.title, null, 0);
|
|
860
|
+
// 无论是否 https 都补上 TLS 行:小白用户看到「少了一项」比看到「已跳过」更困惑。
|
|
861
|
+
add("tls", "TLS 握手", "skip", secure ? "TCP 都没通,跳过" : "http 明文地址,无需握手;连接已失败", null, null);
|
|
862
|
+
return { ok: false, checks };
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// TLS
|
|
866
|
+
if (!secure) {
|
|
867
|
+
// 标 skip 而不是 ok:没加密就是没加密,给个绿勾会让人误以为连接是安全的。
|
|
868
|
+
add("tls", "TLS 握手", "skip", "你填的是 http:// 明文地址,不加密,仅建议本机或内网使用", null, 0);
|
|
869
|
+
socket.destroy();
|
|
870
|
+
return { ok: true, checks };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
let certDays = null;
|
|
874
|
+
try {
|
|
875
|
+
const started = performance.now();
|
|
876
|
+
const secureSocket = await withTimeout(
|
|
877
|
+
tlsUpgrade(socket, url.hostname, cfg.insecure === true, cfg.timeout),
|
|
878
|
+
cfg.timeout + 500,
|
|
879
|
+
"TLS 握手超时",
|
|
880
|
+
);
|
|
881
|
+
const tlsMs = performance.now() - started;
|
|
882
|
+
const cert = typeof secureSocket.getPeerCertificate === "function" ? secureSocket.getPeerCertificate() : null;
|
|
883
|
+
if (cert && cert.valid_to) {
|
|
884
|
+
certDays = Math.floor((new Date(cert.valid_to).getTime() - Date.now()) / 86400000);
|
|
885
|
+
}
|
|
886
|
+
secureSocket.destroy();
|
|
887
|
+
if (certDays !== null && certDays < 0) {
|
|
888
|
+
add("tls", "TLS 握手", "fail", `握手成功,但证书 ${certDays} 天前就过期了`, null, tlsMs);
|
|
889
|
+
return { ok: false, checks };
|
|
890
|
+
}
|
|
891
|
+
if (certDays !== null && certDays < 15) {
|
|
892
|
+
add("tls", "TLS 握手", "warn", `握手成功;证书 ${certDays} 天后过期(建议尽快换)`, null, tlsMs);
|
|
893
|
+
} else if (certDays !== null) {
|
|
894
|
+
add("tls", "TLS 握手", "ok", `握手成功,证书还有 ${certDays} 天`, null, tlsMs);
|
|
895
|
+
} else {
|
|
896
|
+
add("tls", "TLS 握手", "ok", "握手成功", null, tlsMs);
|
|
897
|
+
}
|
|
898
|
+
} catch (error) {
|
|
899
|
+
try {
|
|
900
|
+
socket.destroy();
|
|
901
|
+
} catch {
|
|
902
|
+
/* already torn down */
|
|
903
|
+
}
|
|
904
|
+
add("tls", "TLS 握手", "fail", error.message.split("\n")[0], null, 0);
|
|
905
|
+
return { ok: false, checks };
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
return { ok: true, checks };
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// ---------------------------------------------------------------------------
|
|
912
|
+
// stage 2 — auth + model discovery
|
|
913
|
+
// ---------------------------------------------------------------------------
|
|
914
|
+
|
|
915
|
+
function parseModels(text) {
|
|
916
|
+
try {
|
|
917
|
+
const parsed = JSON.parse(text);
|
|
918
|
+
const list = parsed?.data ?? parsed?.models ?? [];
|
|
919
|
+
if (!Array.isArray(list)) return [];
|
|
920
|
+
const names = [];
|
|
921
|
+
for (const item of list) {
|
|
922
|
+
if (typeof item === "string") names.push(item);
|
|
923
|
+
else if (typeof item?.id === "string") names.push(item.id);
|
|
924
|
+
else if (typeof item?.name === "string") names.push(item.name);
|
|
925
|
+
}
|
|
926
|
+
return names;
|
|
927
|
+
} catch {
|
|
928
|
+
return [];
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
async function discover(cfg, emit) {
|
|
933
|
+
const checks = [];
|
|
934
|
+
const add = (id, label, status, detail) => {
|
|
935
|
+
const entry = { id, label, status, detail, ms: null };
|
|
936
|
+
checks.push(entry);
|
|
937
|
+
emit({ type: "check", ...entry });
|
|
938
|
+
return entry;
|
|
939
|
+
};
|
|
940
|
+
|
|
941
|
+
const schemes = cfg.key
|
|
942
|
+
? cfg.api === "anthropic"
|
|
943
|
+
? ["x-api-key", "bearer"]
|
|
944
|
+
: ["bearer", "x-api-key", "query"]
|
|
945
|
+
: ["bearer"];
|
|
946
|
+
const failures = [];
|
|
947
|
+
|
|
948
|
+
for (const scheme of schemes) {
|
|
949
|
+
const target = applyQueryKey(joinUrl(cfg.base, "/models"), cfg.key, scheme);
|
|
950
|
+
let url;
|
|
951
|
+
try {
|
|
952
|
+
url = new URL(target);
|
|
953
|
+
} catch {
|
|
954
|
+
add("auth", "鉴权", "fail", "地址无法解析", null);
|
|
955
|
+
return { ok: false, scheme: null, models: [], checks, failures };
|
|
956
|
+
}
|
|
957
|
+
try {
|
|
958
|
+
const started = performance.now();
|
|
959
|
+
const socket = await establish(url, cfg, cfg.timeout);
|
|
960
|
+
const { response } = await sendRequest({
|
|
961
|
+
url,
|
|
962
|
+
socket,
|
|
963
|
+
cfg,
|
|
964
|
+
method: "GET",
|
|
965
|
+
headers: authHeaders(cfg.key, scheme, cfg.api),
|
|
966
|
+
timeoutMs: cfg.timeout,
|
|
967
|
+
});
|
|
968
|
+
const text = await readAll(response);
|
|
969
|
+
socket.destroy();
|
|
970
|
+
const ms = Math.round(performance.now() - started);
|
|
971
|
+
const status = response.statusCode ?? 0;
|
|
972
|
+
|
|
973
|
+
if (status === 200) {
|
|
974
|
+
const models = parseModels(text);
|
|
975
|
+
const shown = schemes.length > 1 ? `(用 ${scheme === "bearer" ? "Authorization: Bearer" : scheme === "x-api-key" ? "x-api-key 头" : "URL 参数"} 方式通过)` : "";
|
|
976
|
+
add(
|
|
977
|
+
"auth",
|
|
978
|
+
"鉴权",
|
|
979
|
+
"ok",
|
|
980
|
+
cfg.key ? `Key 有效${shown}` : `这个地址不需要 Key${shown}`,
|
|
981
|
+
ms,
|
|
982
|
+
);
|
|
983
|
+
add("models", "模型列表", models.length > 0 ? "ok" : "warn", models.length > 0 ? `能列出 ${models.length} 个模型` : "连上了,但这个地址不提供模型列表", ms);
|
|
984
|
+
return { ok: true, scheme, models, checks, failures };
|
|
985
|
+
}
|
|
986
|
+
if (status === 404) {
|
|
987
|
+
failures.push({ scheme, status, text });
|
|
988
|
+
add("auth", "鉴权", "fail", "地址返回 404,见下方提示", ms);
|
|
989
|
+
return {
|
|
990
|
+
ok: false,
|
|
991
|
+
scheme: null,
|
|
992
|
+
models: [],
|
|
993
|
+
checks,
|
|
994
|
+
failures,
|
|
995
|
+
hardStop: true,
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
failures.push({ scheme, status, text });
|
|
999
|
+
} catch (error) {
|
|
1000
|
+
failures.push({ scheme, status: 0, text: error.message, phase: error.probePhase ?? null, trace: error.probeTrace ?? null });
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const last = failures[failures.length - 1];
|
|
1005
|
+
const info = diagnose({ status: last?.status ?? 0, errorText: last?.text ?? "所有鉴权方式都失败", cfg, phase: last?.phase ?? null, trace: last?.trace ?? null });
|
|
1006
|
+
add("auth", "鉴权", "fail", `${info.title} — ${info.detail}`, null);
|
|
1007
|
+
return { ok: false, scheme: null, models: [], checks, failures, diagnosis: info };
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// ---------------------------------------------------------------------------
|
|
1011
|
+
// stage 3+4 — one streaming call, and the repeated benchmark
|
|
1012
|
+
// ---------------------------------------------------------------------------
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* One streaming completion, timed. TTFT is measured to the first *visible*
|
|
1016
|
+
* content delta, which is what a user actually waits for — a reasoning trace
|
|
1017
|
+
* arriving first does not make the answer appear sooner.
|
|
1018
|
+
*/
|
|
1019
|
+
async function streamCall(cfg, model, scheme) {
|
|
1020
|
+
const { url: rawUrl, body } = buildChatRequest(cfg, model, true);
|
|
1021
|
+
const url = new URL(applyQueryKey(rawUrl, cfg.key, scheme));
|
|
1022
|
+
const payload = JSON.stringify(body);
|
|
1023
|
+
const started = performance.now();
|
|
1024
|
+
let text = "";
|
|
1025
|
+
let ttftMs = null;
|
|
1026
|
+
let usage = null;
|
|
1027
|
+
let firstStatus = null;
|
|
1028
|
+
let sawError = null;
|
|
1029
|
+
let trace = null;
|
|
1030
|
+
// How many separate content-bearing payloads actually arrived. A vendor that
|
|
1031
|
+
// genuinely streams produces many; a gateway that buffers (or a provider that
|
|
1032
|
+
// effectively answers non-streaming) produces one, and then there is no
|
|
1033
|
+
// generation rate to measure — only the arrival pattern.
|
|
1034
|
+
let deltaCount = 0;
|
|
1035
|
+
const failed = (error, extra) => ({
|
|
1036
|
+
ok: false,
|
|
1037
|
+
status: firstStatus ?? 0,
|
|
1038
|
+
error: error.message,
|
|
1039
|
+
phase: error.probePhase ?? "wait-first-token",
|
|
1040
|
+
trace: error.probeTrace ?? null,
|
|
1041
|
+
ttftMs,
|
|
1042
|
+
totalMs: Math.round(performance.now() - started),
|
|
1043
|
+
text,
|
|
1044
|
+
...extra,
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
try {
|
|
1048
|
+
const socket = await establish(url, cfg, cfg.timeout);
|
|
1049
|
+
const sent = await sendRequest({
|
|
1050
|
+
url,
|
|
1051
|
+
socket,
|
|
1052
|
+
cfg,
|
|
1053
|
+
method: "POST",
|
|
1054
|
+
headers: {
|
|
1055
|
+
...authHeaders(cfg.key, scheme, cfg.api),
|
|
1056
|
+
"content-type": "application/json",
|
|
1057
|
+
accept: "text/event-stream",
|
|
1058
|
+
},
|
|
1059
|
+
body: payload,
|
|
1060
|
+
timeoutMs: cfg.timeout,
|
|
1061
|
+
});
|
|
1062
|
+
const { request, response } = sent;
|
|
1063
|
+
trace = sent.trace;
|
|
1064
|
+
firstStatus = response.statusCode ?? 0;
|
|
1065
|
+
|
|
1066
|
+
if (firstStatus !== 200) {
|
|
1067
|
+
const errText = await readAll(response);
|
|
1068
|
+
socket.destroy();
|
|
1069
|
+
return { ok: false, status: firstStatus, error: errText, phase: "status", trace, ttftMs: null, totalMs: Math.round(performance.now() - started), text: "" };
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
for await (const line of ssePayloads(response)) {
|
|
1073
|
+
if (line === "[DONE]") break;
|
|
1074
|
+
let obj;
|
|
1075
|
+
try {
|
|
1076
|
+
obj = JSON.parse(line);
|
|
1077
|
+
} catch {
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
if (obj.error) {
|
|
1081
|
+
sawError = typeof obj.error === "string" ? obj.error : JSON.stringify(obj.error);
|
|
1082
|
+
break;
|
|
1083
|
+
}
|
|
1084
|
+
const tokenCount = extractUsage(obj);
|
|
1085
|
+
if (tokenCount !== null) usage = tokenCount;
|
|
1086
|
+
const delta = extractDelta(obj, cfg.api);
|
|
1087
|
+
if (delta !== "") {
|
|
1088
|
+
if (ttftMs === null) ttftMs = performance.now() - started;
|
|
1089
|
+
text += delta;
|
|
1090
|
+
deltaCount += 1;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
try {
|
|
1094
|
+
request.destroy();
|
|
1095
|
+
socket.destroy();
|
|
1096
|
+
} catch {
|
|
1097
|
+
/* already closed */
|
|
1098
|
+
}
|
|
1099
|
+
} catch (error) {
|
|
1100
|
+
return failed(error);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const totalMs = performance.now() - started;
|
|
1104
|
+
if (sawError) {
|
|
1105
|
+
return { ok: false, status: firstStatus ?? 0, error: sawError, phase: "status", trace, ttftMs, totalMs: Math.round(totalMs), text };
|
|
1106
|
+
}
|
|
1107
|
+
if (ttftMs === null) {
|
|
1108
|
+
return {
|
|
1109
|
+
ok: false,
|
|
1110
|
+
status: firstStatus ?? 0,
|
|
1111
|
+
error: "服务端连上了、状态也是 200,但一个字的正文都没返回",
|
|
1112
|
+
phase: "empty",
|
|
1113
|
+
trace,
|
|
1114
|
+
ttftMs: null,
|
|
1115
|
+
totalMs: Math.round(totalMs),
|
|
1116
|
+
text,
|
|
1117
|
+
empty: true,
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
const tokens = usage ?? estimateTokens(text);
|
|
1121
|
+
const generateMs = Math.max(1, totalMs - ttftMs);
|
|
1122
|
+
// Output speed is a property of how the model GENERATED, so it is only
|
|
1123
|
+
// measurable across several deliveries. One payload means the whole answer
|
|
1124
|
+
// arrived at once: the window collapses to a few milliseconds and the ratio
|
|
1125
|
+
// explodes into a number no model could have produced (measured: 35000 "字/秒"
|
|
1126
|
+
// for the same text that streams at 55). Reporting that — and grading it
|
|
1127
|
+
// "很好" — would be the tool asserting something it has no evidence for, so
|
|
1128
|
+
// it reports nothing instead.
|
|
1129
|
+
const measurable = deltaCount >= 2;
|
|
1130
|
+
return {
|
|
1131
|
+
ok: true,
|
|
1132
|
+
status: firstStatus ?? 0,
|
|
1133
|
+
phase: "done",
|
|
1134
|
+
trace,
|
|
1135
|
+
text,
|
|
1136
|
+
ttftMs: Math.round(ttftMs),
|
|
1137
|
+
totalMs: Math.round(totalMs),
|
|
1138
|
+
tokens,
|
|
1139
|
+
tps: measurable ? tokens / (generateMs / 1000) : null,
|
|
1140
|
+
deltaCount,
|
|
1141
|
+
streamed: measurable,
|
|
1142
|
+
usageEstimated: usage === null,
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/** Where the request died, in words a non-engineer can act on. */
|
|
1147
|
+
export function explainPhase(sample) {
|
|
1148
|
+
const phase = sample?.phase;
|
|
1149
|
+
const trace = sample?.trace ?? null;
|
|
1150
|
+
const sentBytes = trace && Number.isFinite(trace.sentBytes) ? trace.sentBytes : null;
|
|
1151
|
+
if (phase === "connect") return "卡在第一步:TCP 连接就没建起来,请求一个字节都没发出去。";
|
|
1152
|
+
if (phase === "proxy") return "卡在第一步:连代理服务器失败,请求一个字节都没发出去。";
|
|
1153
|
+
if (phase === "tls") return "卡在第二步:TCP 通了但 TLS 加密握手失败,请求一个字节都没发出去。";
|
|
1154
|
+
if (phase === "write") return "卡在第三步:连接是通的,但请求没能写进这条连接。";
|
|
1155
|
+
if (phase === "closed") return "请求写出去了,但对端一个字节的回应都没给就把连接关了。";
|
|
1156
|
+
if (phase === "wait-status") {
|
|
1157
|
+
const howMuch = sentBytes === null ? "请求正文" : `${sentBytes} 字节的请求正文`;
|
|
1158
|
+
return `卡在第四步:${howMuch}已经发出去了,服务商连 HTTP 状态行都没回。`;
|
|
1159
|
+
}
|
|
1160
|
+
if (phase === "wait-first-token") return "连接正常、状态也是 200,但正文一个 token 都没等到。";
|
|
1161
|
+
if (phase === "empty") return "状态是 200、正文也读完了,但里面没有模型说的话。";
|
|
1162
|
+
if (phase === "status") return `服务商回话了,状态码 ${sample?.status ?? "未知"}。`;
|
|
1163
|
+
if (phase === "done") return "请求成功走完,模型正常吐字了。";
|
|
1164
|
+
return "";
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/** Run `rounds` calls with at most `concurrency` in flight; never throws. */
|
|
1168
|
+
async function bench(cfg, model, scheme, rounds, concurrency) {
|
|
1169
|
+
const results = [];
|
|
1170
|
+
let cursor = 0;
|
|
1171
|
+
const workers = [];
|
|
1172
|
+
for (let worker = 0; worker < Math.min(concurrency, rounds); worker += 1) {
|
|
1173
|
+
workers.push(
|
|
1174
|
+
(async () => {
|
|
1175
|
+
for (;;) {
|
|
1176
|
+
const index = cursor;
|
|
1177
|
+
cursor += 1;
|
|
1178
|
+
if (index >= rounds) return;
|
|
1179
|
+
try {
|
|
1180
|
+
results.push(await streamCall(cfg, model, scheme));
|
|
1181
|
+
} catch (error) {
|
|
1182
|
+
results.push({ ok: false, status: 0, error: error.message, ttftMs: null, totalMs: 0, text: "" });
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
})(),
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
await Promise.all(workers);
|
|
1189
|
+
|
|
1190
|
+
const successes = results.filter((r) => r.ok);
|
|
1191
|
+
const ttfts = successes.map((r) => r.ttftMs).filter((v) => typeof v === "number");
|
|
1192
|
+
const tpss = successes.map((r) => r.tps).filter((v) => typeof v === "number");
|
|
1193
|
+
const avg = (list) => (list.length === 0 ? null : list.reduce((a, b) => a + b, 0) / list.length);
|
|
1194
|
+
const median = (list) => {
|
|
1195
|
+
if (list.length === 0) return null;
|
|
1196
|
+
const sorted = [...list].sort((a, b) => a - b);
|
|
1197
|
+
const mid = Math.floor(sorted.length / 2);
|
|
1198
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
1199
|
+
};
|
|
1200
|
+
return {
|
|
1201
|
+
runs: results,
|
|
1202
|
+
attempts: rounds,
|
|
1203
|
+
successes: successes.length,
|
|
1204
|
+
successRate: rounds === 0 ? 0 : successes.length / rounds,
|
|
1205
|
+
ttftAvg: avg(ttfts),
|
|
1206
|
+
ttftMedian: median(ttfts),
|
|
1207
|
+
tpsAvg: avg(tpss),
|
|
1208
|
+
tpsMedian: median(tpss),
|
|
1209
|
+
failures: results.filter((r) => !r.ok).map((r) => ({ status: r.status, error: r.error, phase: r.phase ?? null, where: explainPhase(r) })),
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// ---------------------------------------------------------------------------
|
|
1214
|
+
// verdict
|
|
1215
|
+
// ---------------------------------------------------------------------------
|
|
1216
|
+
|
|
1217
|
+
function gradeTtft(ms) {
|
|
1218
|
+
if (ms === null) return "none";
|
|
1219
|
+
if (ms < 500) return "good";
|
|
1220
|
+
if (ms < 1200) return "fair";
|
|
1221
|
+
if (ms < 2500) return "slow";
|
|
1222
|
+
return "bad";
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function gradeTps(tps) {
|
|
1226
|
+
if (tps === null) return "none";
|
|
1227
|
+
if (tps >= 45) return "good";
|
|
1228
|
+
if (tps >= 20) return "fair";
|
|
1229
|
+
if (tps >= 8) return "slow";
|
|
1230
|
+
return "bad";
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function gradeRate(rate) {
|
|
1234
|
+
if (rate === null) return "none";
|
|
1235
|
+
if (rate >= 1) return "good";
|
|
1236
|
+
if (rate >= 0.95) return "fair";
|
|
1237
|
+
if (rate >= 0.8) return "slow";
|
|
1238
|
+
return "bad";
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Flatten a diagnosis into verdict reason lines. The verdict banner is the part
|
|
1243
|
+
* of the report a beginner actually reads, so it carries the actionable hint —
|
|
1244
|
+
* a bare title like "意料之外的状态码 200" tells them nothing about what to do.
|
|
1245
|
+
*/
|
|
1246
|
+
function reasonLines(info, fallback) {
|
|
1247
|
+
if (!info) return [fallback];
|
|
1248
|
+
const lines = [info.title];
|
|
1249
|
+
if (info.hint) lines.push(info.hint);
|
|
1250
|
+
return lines;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* One sentence plus the reasons behind it. The three headline numbers are only
|
|
1255
|
+
* meaningful together — a beautiful TTFT on a 60%-success line reads as fast
|
|
1256
|
+
* in a demo and as unusable in production, so the verdict refuses to call that
|
|
1257
|
+
* a good API.
|
|
1258
|
+
*/
|
|
1259
|
+
function buildVerdict(state) {
|
|
1260
|
+
const { network, auth, sample, speed } = state;
|
|
1261
|
+
const reasons = [];
|
|
1262
|
+
|
|
1263
|
+
if (!network.ok) {
|
|
1264
|
+
return { level: "bad", headline: "连不上:网络这关就没过", reasons: ["先把网络/DNS/证书这层解决,Key 还没轮到检验。"] };
|
|
1265
|
+
}
|
|
1266
|
+
if (!auth.ok) {
|
|
1267
|
+
return { level: "bad", headline: "网络没问题,Key 或模型不对", reasons: reasonLines(auth.diagnosis, "鉴权失败。") };
|
|
1268
|
+
}
|
|
1269
|
+
if (!sample || !sample.ok) {
|
|
1270
|
+
const info = sample?.error
|
|
1271
|
+
? diagnose({ status: sample.status, errorText: sample.error, cfg: state.cfg, model: state.model, phase: sample.phase, trace: sample.trace })
|
|
1272
|
+
: null;
|
|
1273
|
+
// "调不动模型" is an accusation the probe may not be entitled to make: it
|
|
1274
|
+
// may simply be that the request never reached anyone, or that the server
|
|
1275
|
+
// went quiet. Say which one was actually observed.
|
|
1276
|
+
const headline = !info
|
|
1277
|
+
? "Key 有效,但这次没测出结果"
|
|
1278
|
+
: info.phase === "wait-status" || info.phase === "closed"
|
|
1279
|
+
? "Key 有效,请求确实发出去了,但服务端没回话"
|
|
1280
|
+
: info.phase === "connect" || info.phase === "proxy" || info.phase === "tls" || info.phase === "write"
|
|
1281
|
+
? "Key 有效,但请求根本没送到服务商手上"
|
|
1282
|
+
: "Key 有效,但实际调不动模型";
|
|
1283
|
+
const reasons = reasonLines(info, "连通性测试失败。");
|
|
1284
|
+
const where = explainPhase(sample);
|
|
1285
|
+
if (where) reasons.push(where);
|
|
1286
|
+
return { level: "bad", headline, reasons };
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
const rate = speed.successRate;
|
|
1290
|
+
const ttft = speed.ttftMedian ?? sample.ttftMs;
|
|
1291
|
+
const tps = speed.tpsMedian ?? sample.tps;
|
|
1292
|
+
const streamed = sample.streamed ?? true;
|
|
1293
|
+
|
|
1294
|
+
if (rate < 0.8) {
|
|
1295
|
+
reasons.push(`成功率只有 ${(rate * 100).toFixed(0)}%(${speed.successes}/${speed.attempts}),线上会频繁失败。`);
|
|
1296
|
+
}
|
|
1297
|
+
if (ttft !== null && ttft >= 1200) {
|
|
1298
|
+
reasons.push(`首字要等 ${Math.round(ttft)}ms,等待感明显。`);
|
|
1299
|
+
}
|
|
1300
|
+
if (tps !== null && tps < 20) {
|
|
1301
|
+
reasons.push(`输出速度只有 ${tps.toFixed(1)} 字/秒,读起来会逐字蹦。`);
|
|
1302
|
+
}
|
|
1303
|
+
if (!streamed) {
|
|
1304
|
+
// Say this out loud rather than letting a missing metric read as a good
|
|
1305
|
+
// one: an unmeasured speed is not a fast one.
|
|
1306
|
+
reasons.push("响应是整块返回的,测不出逐字输出速度——首字延迟仍可信。");
|
|
1307
|
+
}
|
|
1308
|
+
if (reasons.length === 0) {
|
|
1309
|
+
// Reached only when nothing raised a flag. ttft is guaranteed non-null
|
|
1310
|
+
// here (sample.ok implies real output), but tps may legitimately be null
|
|
1311
|
+
// when the vendor answered in one piece, so it needs its own wording
|
|
1312
|
+
// instead of a `.toFixed()` on nothing.
|
|
1313
|
+
reasons.push(
|
|
1314
|
+
tps === null
|
|
1315
|
+
? `首字 ${Math.round(ttft)}ms、成功率 ${(rate * 100).toFixed(0)}%,都在舒服的区间;输出速度这次没测到。`
|
|
1316
|
+
: `首字 ${Math.round(ttft)}ms、输出 ${tps.toFixed(1)} 字/秒、成功率 ${(rate * 100).toFixed(0)}%,三项都在舒服的区间。`,
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
let level;
|
|
1321
|
+
let headline;
|
|
1322
|
+
if (rate >= 0.95 && ttft !== null && ttft < 800 && tps !== null && tps >= 20) {
|
|
1323
|
+
level = "good";
|
|
1324
|
+
headline = "好用,可以放心用";
|
|
1325
|
+
} else if (rate >= 0.8 && ttft !== null && ttft < 1800) {
|
|
1326
|
+
level = "ok";
|
|
1327
|
+
headline = "能用,但不算快";
|
|
1328
|
+
} else if (rate >= 0.8) {
|
|
1329
|
+
level = "slow";
|
|
1330
|
+
headline = "能用,但是慢";
|
|
1331
|
+
} else {
|
|
1332
|
+
level = "bad";
|
|
1333
|
+
headline = "不建议上生产";
|
|
1334
|
+
}
|
|
1335
|
+
return { level, headline, reasons };
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// ---------------------------------------------------------------------------
|
|
1339
|
+
// orchestration
|
|
1340
|
+
// ---------------------------------------------------------------------------
|
|
1341
|
+
|
|
1342
|
+
function readConfig(body) {
|
|
1343
|
+
const base = normBase(body.base);
|
|
1344
|
+
const timeoutMs = Number(body.timeout);
|
|
1345
|
+
const rounds = Number(body.rounds);
|
|
1346
|
+
const concurrency = Number(body.concurrency);
|
|
1347
|
+
return {
|
|
1348
|
+
base,
|
|
1349
|
+
key: typeof body.key === "string" ? body.key.trim() : "",
|
|
1350
|
+
model: typeof body.model === "string" ? body.model.trim() : "",
|
|
1351
|
+
api: ["openai", "anthropic", "gemini"].includes(body.api) ? body.api : "openai",
|
|
1352
|
+
timeout: Number.isFinite(timeoutMs) ? Math.min(120000, Math.max(3000, timeoutMs)) : 30000,
|
|
1353
|
+
maxTokens: Number.isFinite(Number(body.maxTokens)) ? Math.min(4096, Math.max(16, Number(body.maxTokens))) : 128,
|
|
1354
|
+
rounds: Number.isFinite(rounds) ? Math.min(10, Math.max(1, Math.round(rounds))) : 3,
|
|
1355
|
+
concurrency: Number.isFinite(concurrency) ? Math.min(5, Math.max(1, Math.round(concurrency))) : 1,
|
|
1356
|
+
prompt: typeof body.prompt === "string" && body.prompt.trim() !== "" ? body.prompt.trim() : DEFAULT_PROMPT,
|
|
1357
|
+
proxy: typeof body.proxy === "string" && body.proxy.trim() !== "" ? body.proxy.trim() : "",
|
|
1358
|
+
insecure: body.insecure === true,
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* The whole probe. `emit` is called for every check as it completes; the return
|
|
1364
|
+
* value is the same payload the client finally renders, so a client that
|
|
1365
|
+
* ignores the stream still gets a complete answer.
|
|
1366
|
+
*/
|
|
1367
|
+
export async function runProbe(cfg, emit) {
|
|
1368
|
+
// state 会原样回传浏览器并写进报告,cfg 里绝不能留明文 key;
|
|
1369
|
+
// diagnose() 只用到 cfg.proxy,所以这里直接换成打码副本。
|
|
1370
|
+
const state = { cfg: { ...cfg, key: maskKey(cfg.key) }, network: { ok: false, checks: [] }, auth: { ok: false, models: [], checks: [] }, sample: null, speed: null };
|
|
1371
|
+
|
|
1372
|
+
if (!cfg.base) {
|
|
1373
|
+
return { ok: false, error: "请先填写 API 地址。", config: { base: "", keyMasked: maskKey(cfg.key), api: cfg.api, model: cfg.model }, stages: state };
|
|
1374
|
+
}
|
|
1375
|
+
let parsed;
|
|
1376
|
+
try {
|
|
1377
|
+
parsed = new URL(cfg.base);
|
|
1378
|
+
} catch {
|
|
1379
|
+
return { ok: false, error: `地址看不懂:${cfg.base}`, config: { base: cfg.base, keyMasked: maskKey(cfg.key), api: cfg.api, model: cfg.model }, stages: state };
|
|
1380
|
+
}
|
|
1381
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1382
|
+
return { ok: false, error: "地址必须以 http:// 或 https:// 开头。", config: { base: cfg.base, keyMasked: maskKey(cfg.key), api: cfg.api, model: cfg.model }, stages: state };
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// Normalize the base URL to ensure it has /v1 if needed
|
|
1386
|
+
cfg.base = normBase(cfg.base);
|
|
1387
|
+
|
|
1388
|
+
// 1 — network
|
|
1389
|
+
emit({ type: "stage", stage: "network", title: "检查网络" });
|
|
1390
|
+
state.network = await probeNetwork(cfg, emit);
|
|
1391
|
+
if (!state.network.ok) {
|
|
1392
|
+
const verdict = buildVerdict(state);
|
|
1393
|
+
return { ok: false, verdict, config: { base: cfg.base, keyMasked: maskKey(cfg.key), api: cfg.api, model: cfg.model }, stages: state };
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// 2 — auth + model
|
|
1397
|
+
emit({ type: "stage", stage: "auth", title: "检查 Key 和模型" });
|
|
1398
|
+
state.auth = await discover(cfg, emit);
|
|
1399
|
+
if (!state.auth.ok) {
|
|
1400
|
+
if (state.auth.hardStop) {
|
|
1401
|
+
state.auth.diagnosis = diagnose({ status: 404, errorText: "", cfg });
|
|
1402
|
+
}
|
|
1403
|
+
const verdict = buildVerdict(state);
|
|
1404
|
+
return { ok: false, verdict, config: { base: cfg.base, keyMasked: maskKey(cfg.key), api: cfg.api, model: cfg.model }, stages: state };
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
const picked = pickModel(cfg.model, state.auth.models);
|
|
1408
|
+
if (!picked.model) {
|
|
1409
|
+
state.auth.diagnosis = {
|
|
1410
|
+
level: "bad",
|
|
1411
|
+
title: "没法确定用哪个模型",
|
|
1412
|
+
detail: state.auth.models.length === 0 ? "这个地址没有提供模型列表。" : `列出来的模型都不是对话模型(${state.auth.models.slice(0, 3).join(", ")}…)。`,
|
|
1413
|
+
hint: "在上方「模型名」里手动填一个这个服务商支持的对话模型。",
|
|
1414
|
+
};
|
|
1415
|
+
const verdict = buildVerdict(state);
|
|
1416
|
+
return { ok: false, verdict, config: { base: cfg.base, keyMasked: maskKey(cfg.key), api: cfg.api, model: "" }, stages: state };
|
|
1417
|
+
}
|
|
1418
|
+
state.model = picked.model;
|
|
1419
|
+
state.modelAuto = picked.auto;
|
|
1420
|
+
// A misspelled model name otherwise surfaces much later as an opaque 400 from
|
|
1421
|
+
// the chat endpoint. The provider already told us what it has — say it now.
|
|
1422
|
+
const known = state.auth.models;
|
|
1423
|
+
const notListed = !picked.auto && known.length > 0 && !known.includes(picked.model);
|
|
1424
|
+
const nearMiss = notListed ? nearestModelName(picked.model, known) : null;
|
|
1425
|
+
state.modelNotice = notListed
|
|
1426
|
+
? nearMiss
|
|
1427
|
+
? `你填的「${picked.model}」不在服务商列出的 ${known.length} 个模型里,可能拼错了。服务商有「${nearMiss}」。`
|
|
1428
|
+
: `你填的「${picked.model}」不在服务商列出的 ${known.length} 个模型里,可能拼错了——用下面的「服务商提供的模型」里点一个试试。`
|
|
1429
|
+
: null;
|
|
1430
|
+
emit({ type: "model", model: picked.model, auto: picked.auto, notice: state.modelNotice });
|
|
1431
|
+
if (state.auth.models.length > 0) {
|
|
1432
|
+
emit({ type: "models", models: state.auth.models });
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// 3 — one live call
|
|
1436
|
+
emit({ type: "stage", stage: "probe", title: "试一次真实对话" });
|
|
1437
|
+
state.sample = await streamCall(cfg, picked.model, state.auth.scheme);
|
|
1438
|
+
emit({ type: "sample", ok: state.sample.ok, ttftMs: state.sample.ttftMs ?? null, totalMs: state.sample.totalMs ?? null, tps: state.sample.tps ?? null, tokens: state.sample.tokens ?? null, deltaCount: state.sample.deltaCount ?? 0, streamed: state.sample.streamed ?? false, text: (state.sample.text ?? "").slice(0, 400), status: state.sample.status ?? 0, error: state.sample.error ?? null, phase: state.sample.phase ?? null, where: explainPhase(state.sample) });
|
|
1439
|
+
if (!state.sample.ok) {
|
|
1440
|
+
state.sample.diagnosis = diagnose({ status: state.sample.status, errorText: state.sample.error, cfg, model: picked.model, phase: state.sample.phase, trace: state.sample.trace });
|
|
1441
|
+
const verdict = buildVerdict(state);
|
|
1442
|
+
return { ok: false, verdict, config: { base: cfg.base, keyMasked: maskKey(cfg.key), api: cfg.api, model: picked.model }, stages: state };
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// 4 — repeat for stability
|
|
1446
|
+
emit({ type: "stage", stage: "speed", title: `连测 ${cfg.rounds} 次看稳定性` });
|
|
1447
|
+
state.speed = await bench(cfg, picked.model, state.auth.scheme, cfg.rounds, cfg.concurrency);
|
|
1448
|
+
emit({
|
|
1449
|
+
type: "metrics",
|
|
1450
|
+
successRate: state.speed.successRate,
|
|
1451
|
+
successes: state.speed.successes,
|
|
1452
|
+
attempts: state.speed.attempts,
|
|
1453
|
+
ttftMedian: state.speed.ttftMedian,
|
|
1454
|
+
ttftAvg: state.speed.ttftAvg,
|
|
1455
|
+
ttftGrade: gradeTtft(state.speed.ttftMedian),
|
|
1456
|
+
tpsMedian: state.speed.tpsMedian,
|
|
1457
|
+
tpsAvg: state.speed.tpsAvg,
|
|
1458
|
+
tpsGrade: gradeTps(state.speed.tpsMedian),
|
|
1459
|
+
rateGrade: gradeRate(state.speed.successRate),
|
|
1460
|
+
failures: state.speed.failures,
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
const verdict = buildVerdict(state);
|
|
1464
|
+
return {
|
|
1465
|
+
ok: verdict.level !== "bad",
|
|
1466
|
+
verdict,
|
|
1467
|
+
metrics: {
|
|
1468
|
+
ttftMedian: state.speed.ttftMedian,
|
|
1469
|
+
ttftAvg: state.speed.ttftAvg,
|
|
1470
|
+
ttftGrade: gradeTtft(state.speed.ttftMedian),
|
|
1471
|
+
tpsMedian: state.speed.tpsMedian,
|
|
1472
|
+
tpsAvg: state.speed.tpsAvg,
|
|
1473
|
+
tpsGrade: gradeTps(state.speed.tpsMedian),
|
|
1474
|
+
successRate: state.speed.successRate,
|
|
1475
|
+
rateGrade: gradeRate(state.speed.successRate),
|
|
1476
|
+
successes: state.speed.successes,
|
|
1477
|
+
attempts: state.speed.attempts,
|
|
1478
|
+
},
|
|
1479
|
+
sample: { text: state.sample.text.slice(0, 400), ttftMs: state.sample.ttftMs, totalMs: state.sample.totalMs },
|
|
1480
|
+
config: { base: cfg.base, keyMasked: maskKey(cfg.key), api: cfg.api, model: picked.model, modelAuto: picked.auto, rounds: cfg.rounds, concurrency: cfg.concurrency },
|
|
1481
|
+
stages: state,
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// ---------------------------------------------------------------------------
|
|
1486
|
+
// routes
|
|
1487
|
+
// ---------------------------------------------------------------------------
|
|
1488
|
+
|
|
1489
|
+
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8", "referrer-policy": "no-referrer" };
|
|
1490
|
+
|
|
1491
|
+
function writeJson(res, status, body, headers = {}) {
|
|
1492
|
+
res.writeHead(status, { ...JSON_HEADERS, ...headers });
|
|
1493
|
+
res.end(JSON.stringify(body));
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
/** Bounded JSON body reader; destroys the request instead of draining overflow. */
|
|
1497
|
+
async function readJsonBody(req, maxBytes = 64 * 1024) {
|
|
1498
|
+
const chunks = [];
|
|
1499
|
+
let size = 0;
|
|
1500
|
+
for await (const chunk of req) {
|
|
1501
|
+
size += chunk.length;
|
|
1502
|
+
if (size > maxBytes) {
|
|
1503
|
+
req.destroy();
|
|
1504
|
+
return null;
|
|
1505
|
+
}
|
|
1506
|
+
chunks.push(chunk);
|
|
1507
|
+
}
|
|
1508
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
1509
|
+
if (text === "") return null;
|
|
1510
|
+
try {
|
|
1511
|
+
const parsed = JSON.parse(text);
|
|
1512
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
|
|
1513
|
+
} catch {
|
|
1514
|
+
return null;
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* POST /probe → NDJSON. One event per line, so the panel fills in live; the
|
|
1520
|
+
* final `result` event carries the same payload `runProbe` returned, which
|
|
1521
|
+
* makes the panel correct even if it only ever rendered the last line.
|
|
1522
|
+
*/
|
|
1523
|
+
async function handleProbe(ctx, req, res) {
|
|
1524
|
+
if (!isPairedOrLoopbackAllowed(ctx, req)) {
|
|
1525
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
if (req.method !== "POST") {
|
|
1529
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` });
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
const body = await readJsonBody(req);
|
|
1533
|
+
if (body === null) {
|
|
1534
|
+
writeJson(res, 400, { error: "invalid JSON body" });
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
const cfg = readConfig(body);
|
|
1538
|
+
|
|
1539
|
+
res.writeHead(200, {
|
|
1540
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
1541
|
+
"cache-control": "no-store",
|
|
1542
|
+
"x-accel-buffering": "no",
|
|
1543
|
+
"referrer-policy": "no-referrer",
|
|
1544
|
+
});
|
|
1545
|
+
let closed = false;
|
|
1546
|
+
const emit = (event) => {
|
|
1547
|
+
if (closed) return;
|
|
1548
|
+
try {
|
|
1549
|
+
res.write(JSON.stringify(event) + "\n");
|
|
1550
|
+
} catch {
|
|
1551
|
+
closed = true;
|
|
1552
|
+
}
|
|
1553
|
+
};
|
|
1554
|
+
req.on("close", () => {
|
|
1555
|
+
closed = true;
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
try {
|
|
1559
|
+
const result = await runProbe(cfg, emit);
|
|
1560
|
+
emit({ type: "result", result });
|
|
1561
|
+
} catch (error) {
|
|
1562
|
+
ctx.logger?.warn?.(error);
|
|
1563
|
+
emit({ type: "result", result: { ok: false, error: error?.message ?? String(error), stages: {} } });
|
|
1564
|
+
}
|
|
1565
|
+
if (!closed) res.end();
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
function handleHealth(ctx, req, res) {
|
|
1569
|
+
if (!isPairedOrLoopbackAllowed(ctx, req)) {
|
|
1570
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
if (req.method !== "GET") {
|
|
1574
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` });
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
// No preset count here on purpose. The list lives in the client bundle, and
|
|
1578
|
+
// this route used to report a hardcoded 10 from a stale host-side copy that
|
|
1579
|
+
// disagreed with the 38 the panel actually offers.
|
|
1580
|
+
writeJson(res, 200, { ok: true, plugin: "api-probe" });
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* Build the route list for one ctx.
|
|
1585
|
+
*
|
|
1586
|
+
* ROUTE_LIST must NOT be a module-level constant: its handlers close over
|
|
1587
|
+
* `ctx`, which only exists inside apply(). A top-level array would evaluate
|
|
1588
|
+
* fine and then throw `ReferenceError: ctx is not defined` on the first
|
|
1589
|
+
* request — the kind of bug unit tests on runProbe never see.
|
|
1590
|
+
* @param ctx - host plugin context carrying webServer.
|
|
1591
|
+
*/
|
|
1592
|
+
function makeRoutes(ctx) {
|
|
1593
|
+
return [
|
|
1594
|
+
{ kind: "exact", path: ROUTES.probe, handler: (req, res) => handleProbe(ctx, req, res) },
|
|
1595
|
+
{ kind: "exact", path: ROUTES.health, handler: (req, res) => handleHealth(ctx, req, res) },
|
|
1596
|
+
];
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/**
|
|
1600
|
+
* Mount the probe routes.
|
|
1601
|
+
* @param ctx - host plugin context carrying webServer.
|
|
1602
|
+
* @param config - resolved plugin config.
|
|
1603
|
+
*/
|
|
1604
|
+
export function apply(ctx, config) {
|
|
1605
|
+
if (config?.enabled === false) return;
|
|
1606
|
+
ctx.effect(() => {
|
|
1607
|
+
const disposers = makeRoutes(ctx).map((route) => ctx.webServer.register(route));
|
|
1608
|
+
return () => {
|
|
1609
|
+
for (const dispose of disposers) dispose();
|
|
1610
|
+
};
|
|
1611
|
+
}, "api-probe: routes");
|
|
1612
|
+
}
|