@fonlan/dsh-model-parameters 0.1.7 → 0.2.0
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/README.en.md +21 -0
- package/README.md +11 -0
- package/lib/client.js +40 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +586 -22
- package/lib/types/index.d.ts +3 -0
- package/lib/types/server/catalog.d.ts +10 -2
- package/lib/types/server/system-proxy.d.ts +112 -0
- package/package.json +16 -15
- package/src/client/api.ts +10 -0
- package/src/client/locales.ts +12 -0
- package/src/client/settings-section.tsx +25 -1
- package/src/index.ts +42 -0
- package/src/server/catalog.ts +34 -18
- package/src/server/rpc.ts +2 -0
- package/src/server/system-proxy.ts +683 -0
package/lib/index.js
CHANGED
|
@@ -2,6 +2,12 @@ import z from "@deepseek-ai/schemastery";
|
|
|
2
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { request } from "node:http";
|
|
8
|
+
import { Agent, request as request$1 } from "node:https";
|
|
9
|
+
import { isIP } from "node:net";
|
|
10
|
+
import { connect } from "node:tls";
|
|
5
11
|
//#region src/shared/config.ts
|
|
6
12
|
/**
|
|
7
13
|
* Plugin configuration and shared constants for @fonlan/dsh-model-parameters.
|
|
@@ -121,10 +127,562 @@ function readConfig(config) {
|
|
|
121
127
|
};
|
|
122
128
|
}
|
|
123
129
|
//#endregion
|
|
130
|
+
//#region src/server/system-proxy.ts
|
|
131
|
+
/**
|
|
132
|
+
* System-proxy discovery and transport for the models.dev catalog download.
|
|
133
|
+
*
|
|
134
|
+
* The harness already routes plain `fetch()` through undici's global
|
|
135
|
+
* dispatcher, which resolves `http_proxy` / `https_proxy` / `all_proxy` from
|
|
136
|
+
* the launch environment (`@deepseek-ai/dsh-http-proxy`). That dispatcher
|
|
137
|
+
* deliberately does NOT read the operating system's proxy configuration, so on
|
|
138
|
+
* a machine where the user only enabled a proxy in Windows Internet Settings or
|
|
139
|
+
* macOS Network settings the catalog download went direct and failed behind the
|
|
140
|
+
* proxy.
|
|
141
|
+
*
|
|
142
|
+
* This module supplies the missing half:
|
|
143
|
+
* 1. when the environment names a proxy, defer to the harness so the existing
|
|
144
|
+
* routing (including its diagnostics and loopback bypass) is unchanged;
|
|
145
|
+
* 2. otherwise read the OS proxy — Windows registry `Internet Settings`,
|
|
146
|
+
* macOS `scutil --proxy` — and tunnel the download through it with Node's
|
|
147
|
+
* own https stack (CONNECT, proxy Basic auth, redirects);
|
|
148
|
+
* 3. otherwise download directly.
|
|
149
|
+
*
|
|
150
|
+
* Deliberate limits: SOCKS proxies and PAC scripts are never evaluated (a
|
|
151
|
+
* PAC-only configuration is reported and the download stays direct — set
|
|
152
|
+
* `HTTP_PROXY`/`HTTPS_PROXY` instead); a bypass entry in CIDR form
|
|
153
|
+
* (`10.0.0.0/8`) is not matched; proxy credentials stored in the OS credential
|
|
154
|
+
* manager are not sent.
|
|
155
|
+
*/
|
|
156
|
+
const PROXY_ENV_NAMES = [
|
|
157
|
+
"https_proxy",
|
|
158
|
+
"HTTPS_PROXY",
|
|
159
|
+
"http_proxy",
|
|
160
|
+
"HTTP_PROXY",
|
|
161
|
+
"all_proxy",
|
|
162
|
+
"ALL_PROXY"
|
|
163
|
+
];
|
|
164
|
+
/** Per-user Internet Settings key WinINET (and every proxy app that toggles "system proxy") writes. */
|
|
165
|
+
const WINDOWS_INTERNET_SETTINGS = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings";
|
|
166
|
+
/** How long one detection probe may take. */
|
|
167
|
+
const DETECT_TIMEOUT_MS = 5e3;
|
|
168
|
+
/** Redirect statuses a download follows. */
|
|
169
|
+
const REDIRECT_STATUSES = /* @__PURE__ */ new Set([
|
|
170
|
+
301,
|
|
171
|
+
302,
|
|
172
|
+
303,
|
|
173
|
+
307,
|
|
174
|
+
308
|
|
175
|
+
]);
|
|
176
|
+
const MAX_REDIRECTS = 4;
|
|
177
|
+
/** The first proxy environment variable that carries a value, if any. */
|
|
178
|
+
function proxyEnvironmentName(env) {
|
|
179
|
+
return PROXY_ENV_NAMES.find((name) => (env[name] ?? "").trim() !== "");
|
|
180
|
+
}
|
|
181
|
+
const runCommand = (command, args, timeoutMs) => new Promise((resolve, reject) => {
|
|
182
|
+
execFile(command, args, {
|
|
183
|
+
encoding: "utf8",
|
|
184
|
+
timeout: timeoutMs,
|
|
185
|
+
windowsHide: true,
|
|
186
|
+
maxBuffer: 1048576
|
|
187
|
+
}, (error, stdout, stderr) => {
|
|
188
|
+
if (error !== null) {
|
|
189
|
+
reject(/* @__PURE__ */ new Error(`${command} ${args.join(" ")} failed: ${stderr.trim() === "" ? error.message : stderr.trim()}`));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
resolve(stdout);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
/**
|
|
196
|
+
* Parse the value table `reg query <key>` prints. Value names and the
|
|
197
|
+
* `REG_*` type tokens are not localized, so the same table parses under every
|
|
198
|
+
* Windows display language. Binary values (the `DefaultConnectionSettings`
|
|
199
|
+
* blob) are read as empty — nothing in this module consumes them.
|
|
200
|
+
*/
|
|
201
|
+
function parseRegistryQuery(output) {
|
|
202
|
+
const values = /* @__PURE__ */ new Map();
|
|
203
|
+
for (const line of output.split(/\r?\n/)) {
|
|
204
|
+
const match = /^\s{2,}(\S+)\s+(REG_[A-Z_]+)\s*(.*)$/.exec(line);
|
|
205
|
+
if (match === null) continue;
|
|
206
|
+
const [, name, type, raw] = match;
|
|
207
|
+
if (type === "REG_BINARY") {
|
|
208
|
+
values.set(name, "");
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
values.set(name, type === "REG_DWORD" ? String(Number.parseInt(raw.trim(), 16) || 0) : raw.trim());
|
|
212
|
+
}
|
|
213
|
+
return values;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Turn the Internet Settings values into a proxy configuration.
|
|
217
|
+
* `ProxyServer` is either `host:port` (both schemes) or a
|
|
218
|
+
* `http=host:port;https=host:port` list.
|
|
219
|
+
* @returns the configuration, or undefined when the OS names no proxy at all.
|
|
220
|
+
*/
|
|
221
|
+
function windowsProxyFromRegistry(values) {
|
|
222
|
+
const enabled = (values.get("ProxyEnable") ?? "0") !== "0";
|
|
223
|
+
const server = (values.get("ProxyServer") ?? "").trim();
|
|
224
|
+
const override = windowsBypassList(values.get("ProxyOverride") ?? "");
|
|
225
|
+
const autoConfig = (values.get("AutoConfigURL") ?? "").trim();
|
|
226
|
+
const diagnostics = [];
|
|
227
|
+
const config = {
|
|
228
|
+
source: "windows",
|
|
229
|
+
noProxy: override,
|
|
230
|
+
diagnostics
|
|
231
|
+
};
|
|
232
|
+
if (enabled && server !== "") {
|
|
233
|
+
if (server.includes("=")) for (const entry of server.split(";")) {
|
|
234
|
+
const separator = entry.indexOf("=");
|
|
235
|
+
if (separator === -1) continue;
|
|
236
|
+
const scheme = entry.slice(0, separator).trim().toLowerCase();
|
|
237
|
+
const hostPort = entry.slice(separator + 1).trim();
|
|
238
|
+
if (hostPort === "") continue;
|
|
239
|
+
if (scheme !== "http" && scheme !== "https") {
|
|
240
|
+
if (scheme === "socks") diagnostics.push("Windows names a SOCKS proxy, which is not supported; the catalog download stays direct");
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const url = proxyUrlFromHostPort(hostPort);
|
|
244
|
+
if (url === void 0) {
|
|
245
|
+
diagnostics.push(`Windows proxy entry "${scheme}=${hostPort}" is not a usable proxy URL; ignored`);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (scheme === "http") config.http = url;
|
|
249
|
+
else config.https = url;
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
const url = proxyUrlFromHostPort(server);
|
|
253
|
+
if (url === void 0) diagnostics.push(`Windows proxy server "${server}" is not a usable proxy URL; the catalog download stays direct`);
|
|
254
|
+
else {
|
|
255
|
+
config.http = url;
|
|
256
|
+
config.https = url;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (autoConfig !== "") diagnostics.push("Windows uses an automatic proxy configuration (PAC) script, which is not evaluated; set HTTP_PROXY/HTTPS_PROXY to route the catalog download");
|
|
261
|
+
return config.http !== void 0 || config.https !== void 0 || diagnostics.length > 0 ? config : void 0;
|
|
262
|
+
}
|
|
263
|
+
/** `ProxyOverride` is `;`-separated and may carry `<local>`; our matcher consumes both forms. */
|
|
264
|
+
function windowsBypassList(override) {
|
|
265
|
+
return override.split(";").map((entry) => entry.trim()).filter((entry) => entry !== "").join(",");
|
|
266
|
+
}
|
|
267
|
+
/** macOS `scutil --proxy` prints a flat key/value dictionary with nested arrays. */
|
|
268
|
+
function parseScutilProxyOutput(output) {
|
|
269
|
+
const values = /* @__PURE__ */ new Map();
|
|
270
|
+
const exceptions = [];
|
|
271
|
+
for (const line of output.split(/\r?\n/)) {
|
|
272
|
+
const match = /^\s*([^:<]+?)\s*:\s*(.+?)\s*$/.exec(line);
|
|
273
|
+
if (match === null) continue;
|
|
274
|
+
const key = match[1].trim();
|
|
275
|
+
const value = match[2].trim();
|
|
276
|
+
if (/^\d+$/.test(key)) {
|
|
277
|
+
exceptions.push(value);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
values.set(key, value);
|
|
281
|
+
}
|
|
282
|
+
const flag = (key) => values.get(key) === "1" || values.get(key) === "true";
|
|
283
|
+
const port = (key, fallback) => {
|
|
284
|
+
const value = values.get(key);
|
|
285
|
+
return value !== void 0 && /^\d+$/.test(value) ? value : String(fallback);
|
|
286
|
+
};
|
|
287
|
+
const diagnostics = [];
|
|
288
|
+
const config = {
|
|
289
|
+
source: "macos",
|
|
290
|
+
noProxy: exceptions.join(","),
|
|
291
|
+
diagnostics
|
|
292
|
+
};
|
|
293
|
+
const httpHost = values.get("HTTPProxy");
|
|
294
|
+
if (flag("HTTPEnable") && httpHost !== void 0 && httpHost !== "") config.http = `${/^https?:\/\//.test(httpHost) ? "" : "http://"}${httpHost}:${port("HTTPPort", 80)}`;
|
|
295
|
+
const httpsHost = values.get("HTTPSProxy");
|
|
296
|
+
if (flag("HTTPSEnable") && httpsHost !== void 0 && httpsHost !== "") config.https = `${/^https?:\/\//.test(httpsHost) ? "" : "http://"}${httpsHost}:${port("HTTPSPort", 443)}`;
|
|
297
|
+
if (flag("ProxyAutoConfigEnable")) diagnostics.push("macOS uses an automatic proxy configuration (PAC) file, which is not evaluated; set HTTP_PROXY/HTTPS_PROXY to route the catalog download");
|
|
298
|
+
if (flag("ProxyAutoDiscoveryEnable")) diagnostics.push("macOS runs automatic proxy discovery (WPAD), which is not evaluated; set HTTP_PROXY/HTTPS_PROXY to route the catalog download");
|
|
299
|
+
if (flag("SOCKSEnable")) diagnostics.push("macOS names a SOCKS proxy, which is not supported; the catalog download stays direct for that route");
|
|
300
|
+
return config.http !== void 0 || config.https !== void 0 || diagnostics.length > 0 ? config : void 0;
|
|
301
|
+
}
|
|
302
|
+
async function detectWindowsProxy(run, env, timeoutMs) {
|
|
303
|
+
const systemRoot = env.SystemRoot ?? env.SYSTEMROOT;
|
|
304
|
+
const executable = systemRoot !== void 0 && existsSync(join(systemRoot, "System32", "reg.exe")) ? join(systemRoot, "System32", "reg.exe") : "reg";
|
|
305
|
+
let output;
|
|
306
|
+
try {
|
|
307
|
+
output = await run(executable, ["query", WINDOWS_INTERNET_SETTINGS], timeoutMs);
|
|
308
|
+
} catch {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
return windowsProxyFromRegistry(parseRegistryQuery(output));
|
|
312
|
+
}
|
|
313
|
+
async function detectMacProxy(run, timeoutMs) {
|
|
314
|
+
let output;
|
|
315
|
+
try {
|
|
316
|
+
output = await run("scutil", ["--proxy"], timeoutMs);
|
|
317
|
+
} catch {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
return parseScutilProxyOutput(output);
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Read the operating system's proxy configuration (never the environment).
|
|
324
|
+
* Linux has no portable API for this; its users configure the environment,
|
|
325
|
+
* which the harness routes.
|
|
326
|
+
* @param options - platform, probe runner and timeout (tests pin all three).
|
|
327
|
+
* @returns the detected configuration, or undefined when the OS names none.
|
|
328
|
+
*/
|
|
329
|
+
async function detectSystemProxy(options = {}) {
|
|
330
|
+
const platform = options.platform ?? process.platform;
|
|
331
|
+
const env = options.env ?? process.env;
|
|
332
|
+
const run = options.run ?? runCommand;
|
|
333
|
+
const timeoutMs = options.timeoutMs ?? DETECT_TIMEOUT_MS;
|
|
334
|
+
if (platform === "win32") return detectWindowsProxy(run, env, timeoutMs);
|
|
335
|
+
if (platform === "darwin") return detectMacProxy(run, timeoutMs);
|
|
336
|
+
}
|
|
337
|
+
/** A host this machine owns: a proxy cannot reach it, so it always goes direct. */
|
|
338
|
+
function isLoopbackHost(hostname) {
|
|
339
|
+
const host = hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase();
|
|
340
|
+
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
341
|
+
if (host === "::1" || host === "::" || host === "0.0.0.0") return true;
|
|
342
|
+
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
|
|
343
|
+
}
|
|
344
|
+
/** Split `host:port`, `[::1]:port` or a bare IPv6 literal without reading `::1` as host `:port`. */
|
|
345
|
+
function splitHostPort(entry) {
|
|
346
|
+
if (entry.startsWith("[")) {
|
|
347
|
+
const close = entry.indexOf("]");
|
|
348
|
+
if (close !== -1) {
|
|
349
|
+
const rest = entry.slice(close + 1);
|
|
350
|
+
const host = entry.slice(1, close);
|
|
351
|
+
return rest.startsWith(":") ? {
|
|
352
|
+
host,
|
|
353
|
+
port: rest.slice(1)
|
|
354
|
+
} : { host };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const colon = entry.indexOf(":");
|
|
358
|
+
if (colon !== -1 && entry.indexOf(":", colon + 1) === -1) return {
|
|
359
|
+
host: entry.slice(0, colon),
|
|
360
|
+
port: entry.slice(colon + 1)
|
|
361
|
+
};
|
|
362
|
+
return { host: entry };
|
|
363
|
+
}
|
|
364
|
+
/** `*` / `?` entries appear in OS bypass lists (`10.*`, `*.corp`); CIDR does not. */
|
|
365
|
+
function globMatch(pattern, host) {
|
|
366
|
+
const source = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
367
|
+
return new RegExp(`^${source}$`).test(host);
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Decide whether a bypass list exempts one URL. Semantics follow the harness's
|
|
371
|
+
* own matcher — an entry covers its subdomains, a leading `.`/`*.` means the
|
|
372
|
+
* same, an entry may carry a `:port`, `*` bypasses everything — plus the two
|
|
373
|
+
* forms OS lists add: `host*` wildcards and `<local>` (a dotless hostname).
|
|
374
|
+
* Loopback is always bypassed. CIDR notation is not matched.
|
|
375
|
+
*/
|
|
376
|
+
function shouldBypassProxy(noProxy, url) {
|
|
377
|
+
const host = url.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase();
|
|
378
|
+
if (isLoopbackHost(host)) return true;
|
|
379
|
+
const port = url.port !== "" ? url.port : url.protocol === "https:" ? "443" : "80";
|
|
380
|
+
for (const raw of noProxy.split(/[,\s;]+/)) {
|
|
381
|
+
const entry = raw.trim().toLowerCase();
|
|
382
|
+
if (entry === "") continue;
|
|
383
|
+
if (entry === "*") return true;
|
|
384
|
+
if (entry === "<local>") {
|
|
385
|
+
if (!host.includes(".")) return true;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const split = splitHostPort(entry);
|
|
389
|
+
if (split.port !== void 0 && split.port !== port) continue;
|
|
390
|
+
const candidate = split.host.replace(/^\*?\./, "").replace(/\.$/, "");
|
|
391
|
+
if (candidate === "") continue;
|
|
392
|
+
if (candidate.includes("*") || candidate.includes("?")) {
|
|
393
|
+
if (globMatch(candidate, host)) return true;
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (host === candidate || host.endsWith(`.${candidate}`)) return true;
|
|
397
|
+
}
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Decide how the catalog download for `target` is routed: defer to the harness
|
|
402
|
+
* for environment proxies, tunnel a detected OS proxy, or go direct. The OS is
|
|
403
|
+
* consulted only when the environment names no proxy, so an exported variable
|
|
404
|
+
* keeps winning exactly as it does for the rest of the harness.
|
|
405
|
+
*/
|
|
406
|
+
async function resolveCatalogRoute(target, options = {}) {
|
|
407
|
+
const url = typeof target === "string" ? new URL(target) : target;
|
|
408
|
+
const env = options.env ?? process.env;
|
|
409
|
+
if (proxyEnvironmentName(env) !== void 0) return {
|
|
410
|
+
tunnel: false,
|
|
411
|
+
source: "env",
|
|
412
|
+
noProxy: (env.no_proxy ?? env.NO_PROXY ?? "").trim(),
|
|
413
|
+
diagnostics: []
|
|
414
|
+
};
|
|
415
|
+
const detected = await detectSystemProxy(options);
|
|
416
|
+
if (detected === void 0) return {
|
|
417
|
+
tunnel: false,
|
|
418
|
+
source: "none",
|
|
419
|
+
noProxy: "",
|
|
420
|
+
diagnostics: []
|
|
421
|
+
};
|
|
422
|
+
const noProxy = detected.noProxy ?? "";
|
|
423
|
+
const proxy = url.protocol === "https:" ? detected.https : detected.http;
|
|
424
|
+
const diagnostics = [...detected.diagnostics];
|
|
425
|
+
if (proxy === void 0) {
|
|
426
|
+
if ((url.protocol === "https:" ? detected.http : detected.https) !== void 0) {
|
|
427
|
+
const missing = url.protocol === "https:" ? "HTTPS" : "HTTP";
|
|
428
|
+
const present = url.protocol === "https:" ? "HTTP" : "HTTPS";
|
|
429
|
+
diagnostics.push(`${detected.source === "windows" ? "Windows" : "macOS"} configures an ${present} proxy but no ${missing} proxy; the catalog download connects directly`);
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
tunnel: false,
|
|
433
|
+
source: detected.source,
|
|
434
|
+
noProxy,
|
|
435
|
+
diagnostics
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
if (shouldBypassProxy(noProxy, url)) return {
|
|
439
|
+
tunnel: false,
|
|
440
|
+
source: detected.source,
|
|
441
|
+
noProxy,
|
|
442
|
+
diagnostics
|
|
443
|
+
};
|
|
444
|
+
return {
|
|
445
|
+
tunnel: true,
|
|
446
|
+
proxy,
|
|
447
|
+
source: detected.source,
|
|
448
|
+
noProxy,
|
|
449
|
+
diagnostics
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
/** Strip any proxy credentials before the route crosses the settings API or logs. */
|
|
453
|
+
function maskProxyUrl(proxy) {
|
|
454
|
+
try {
|
|
455
|
+
const url = new URL(proxy);
|
|
456
|
+
url.username = "";
|
|
457
|
+
url.password = "";
|
|
458
|
+
return `${url.protocol}//${url.host}`;
|
|
459
|
+
} catch {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/** Describe a route for the settings page. */
|
|
464
|
+
function transportOf(route) {
|
|
465
|
+
if (route.tunnel && route.proxy !== void 0) return {
|
|
466
|
+
kind: route.source === "windows" ? "windows" : route.source === "macos" ? "macos" : "env",
|
|
467
|
+
proxy: maskProxyUrl(route.proxy)
|
|
468
|
+
};
|
|
469
|
+
if (route.source === "env") return { kind: "env" };
|
|
470
|
+
return { kind: "direct" };
|
|
471
|
+
}
|
|
472
|
+
function describeTransport(transport) {
|
|
473
|
+
switch (transport.kind) {
|
|
474
|
+
case "env": return "environment proxy variables (routed by the harness)";
|
|
475
|
+
case "windows": return `Windows system proxy ${transport.proxy ?? ""}`.trim();
|
|
476
|
+
case "macos": return `macOS system proxy ${transport.proxy ?? ""}`.trim();
|
|
477
|
+
default: return "direct (no system proxy)";
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function proxyUrlFromHostPort(value) {
|
|
481
|
+
try {
|
|
482
|
+
const parsed = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `http://${value}`);
|
|
483
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return void 0;
|
|
484
|
+
if (parsed.hostname === "") return void 0;
|
|
485
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
486
|
+
} catch {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
function errorMessage(error) {
|
|
491
|
+
if (!(error instanceof Error)) return String(error);
|
|
492
|
+
const cause = error.cause;
|
|
493
|
+
if (cause instanceof Error && cause.message !== "" && !error.message.includes(cause.message)) return `${error.message}: ${cause.message}`;
|
|
494
|
+
return error.message;
|
|
495
|
+
}
|
|
496
|
+
async function fetchJsonDirect(url, timeoutMs) {
|
|
497
|
+
const controller = new AbortController();
|
|
498
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
499
|
+
try {
|
|
500
|
+
const response = await fetch(url, {
|
|
501
|
+
signal: controller.signal,
|
|
502
|
+
headers: { accept: "application/json" }
|
|
503
|
+
});
|
|
504
|
+
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
505
|
+
return await response.json();
|
|
506
|
+
} catch (error) {
|
|
507
|
+
if (controller.signal.aborted) throw new Error(`catalog download timed out after ${timeoutMs}ms`);
|
|
508
|
+
throw new Error(errorMessage(error));
|
|
509
|
+
} finally {
|
|
510
|
+
clearTimeout(timer);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
/** A URL hostname without the brackets WHATWG puts around IPv6 literals. */
|
|
514
|
+
function bareHostname(hostname) {
|
|
515
|
+
return hostname.replace(/^\[|\]$/g, "");
|
|
516
|
+
}
|
|
517
|
+
/** Open a CONNECT tunnel to `host:port` through the proxy, authenticating when the URL carries credentials. */
|
|
518
|
+
function openProxyTunnel(proxy, host, port, timeoutMs) {
|
|
519
|
+
const authority = `${isIP(host) === 6 ? `[${host}]` : host}:${port}`;
|
|
520
|
+
const headers = { host: authority };
|
|
521
|
+
if (proxy.username !== "" || proxy.password !== "") {
|
|
522
|
+
const credentials = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`;
|
|
523
|
+
headers["proxy-authorization"] = `Basic ${Buffer.from(credentials).toString("base64")}`;
|
|
524
|
+
}
|
|
525
|
+
const secureProxy = proxy.protocol === "https:";
|
|
526
|
+
const connectRequest = (secureProxy ? request$1 : request)({
|
|
527
|
+
host: bareHostname(proxy.hostname),
|
|
528
|
+
port: Number(proxy.port) || (secureProxy ? 443 : 80),
|
|
529
|
+
method: "CONNECT",
|
|
530
|
+
path: authority,
|
|
531
|
+
headers,
|
|
532
|
+
agent: false,
|
|
533
|
+
timeout: timeoutMs
|
|
534
|
+
});
|
|
535
|
+
return new Promise((resolve, reject) => {
|
|
536
|
+
connectRequest.once("connect", (response, socket, head) => {
|
|
537
|
+
if (response.statusCode !== 200) {
|
|
538
|
+
socket.destroy();
|
|
539
|
+
reject(/* @__PURE__ */ new Error(`proxy ${bareHostname(proxy.hostname)} refused CONNECT with HTTP ${response.statusCode ?? 0}`));
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (head.length > 0) socket.unshift(head);
|
|
543
|
+
resolve(socket);
|
|
544
|
+
});
|
|
545
|
+
connectRequest.once("timeout", () => {
|
|
546
|
+
connectRequest.destroy(/* @__PURE__ */ new Error(`proxy CONNECT timed out after ${timeoutMs}ms`));
|
|
547
|
+
});
|
|
548
|
+
connectRequest.once("error", (error) => reject(error));
|
|
549
|
+
connectRequest.end();
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
/** An https agent whose every socket is a TLS session opened inside a proxy tunnel. */
|
|
553
|
+
var ProxyTunnelAgent = class extends Agent {
|
|
554
|
+
proxy;
|
|
555
|
+
timeoutMs;
|
|
556
|
+
constructor(proxy, timeoutMs) {
|
|
557
|
+
super({
|
|
558
|
+
keepAlive: false,
|
|
559
|
+
maxSockets: 1
|
|
560
|
+
});
|
|
561
|
+
this.proxy = proxy;
|
|
562
|
+
this.timeoutMs = timeoutMs;
|
|
563
|
+
}
|
|
564
|
+
createConnection(options, callback) {
|
|
565
|
+
const host = typeof options.host === "string" ? options.host : "";
|
|
566
|
+
const port = Number(options.port ?? 443);
|
|
567
|
+
const servername = options.servername;
|
|
568
|
+
this.open(host, port, typeof servername === "string" ? servername : void 0).then((socket) => callback?.(null, socket), (error) => callback?.(error instanceof Error ? error : new Error(String(error)), void 0));
|
|
569
|
+
}
|
|
570
|
+
async open(host, port, servername) {
|
|
571
|
+
const raw = await openProxyTunnel(this.proxy, host, port, this.timeoutMs);
|
|
572
|
+
const sni = servername !== void 0 && isIP(servername) === 0 ? servername : isIP(host) === 0 ? host : void 0;
|
|
573
|
+
const secure = connect({
|
|
574
|
+
socket: raw,
|
|
575
|
+
servername: sni
|
|
576
|
+
});
|
|
577
|
+
await new Promise((resolve, reject) => {
|
|
578
|
+
secure.once("secureConnect", () => resolve());
|
|
579
|
+
secure.once("error", (error) => reject(error));
|
|
580
|
+
});
|
|
581
|
+
return secure;
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
/** One HTTPS GET through the tunnel; the whole exchange (connect included) is bounded by `timeoutMs`. */
|
|
585
|
+
function requestThroughTunnel(target, proxy, timeoutMs) {
|
|
586
|
+
return new Promise((resolve, reject) => {
|
|
587
|
+
const agent = new ProxyTunnelAgent(proxy, timeoutMs);
|
|
588
|
+
let settled = false;
|
|
589
|
+
let request;
|
|
590
|
+
const finish = (error, value) => {
|
|
591
|
+
if (settled) return;
|
|
592
|
+
settled = true;
|
|
593
|
+
clearTimeout(timer);
|
|
594
|
+
request?.destroy();
|
|
595
|
+
agent.destroy();
|
|
596
|
+
if (error === null && value !== void 0) resolve(value);
|
|
597
|
+
else reject(error ?? /* @__PURE__ */ new Error("catalog download failed"));
|
|
598
|
+
};
|
|
599
|
+
const timer = setTimeout(() => finish(/* @__PURE__ */ new Error(`catalog download timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
600
|
+
request = request$1(target, {
|
|
601
|
+
agent,
|
|
602
|
+
method: "GET",
|
|
603
|
+
headers: {
|
|
604
|
+
accept: "application/json",
|
|
605
|
+
"accept-encoding": "identity"
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
request.once("response", (response) => {
|
|
609
|
+
const chunks = [];
|
|
610
|
+
response.on("data", (chunk) => chunks.push(chunk));
|
|
611
|
+
response.once("end", () => {
|
|
612
|
+
finish(null, {
|
|
613
|
+
status: response.statusCode ?? 0,
|
|
614
|
+
statusText: response.statusMessage ?? "",
|
|
615
|
+
headers: response.headers,
|
|
616
|
+
body: Buffer.concat(chunks).toString("utf8")
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
response.once("error", (error) => finish(error));
|
|
620
|
+
});
|
|
621
|
+
request.once("error", (error) => finish(error));
|
|
622
|
+
request.end();
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Download and parse one HTTPS JSON document through an `http(s)://` proxy,
|
|
627
|
+
* following redirects. Throws on a non-2xx answer, a malformed body, or a
|
|
628
|
+
* transport timeout so callers keep the previously cached catalog.
|
|
629
|
+
*/
|
|
630
|
+
async function fetchJsonViaProxy(url, proxy, timeoutMs) {
|
|
631
|
+
let proxyUrl;
|
|
632
|
+
try {
|
|
633
|
+
proxyUrl = new URL(proxy);
|
|
634
|
+
} catch {
|
|
635
|
+
throw new Error(`proxy URL "${maskProxyUrl(proxy) ?? "invalid"}" is not a URL`);
|
|
636
|
+
}
|
|
637
|
+
if (proxyUrl.protocol !== "http:" && proxyUrl.protocol !== "https:") throw new Error(`proxy URL "${maskProxyUrl(proxy) ?? "invalid"}" uses ${proxyUrl.protocol}//, which is not supported`);
|
|
638
|
+
let current = new URL(url);
|
|
639
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
640
|
+
if (current.protocol !== "https:") throw new Error(`proxy transport supports https targets only (got ${current.protocol}//)`);
|
|
641
|
+
const response = await requestThroughTunnel(current, proxyUrl, timeoutMs);
|
|
642
|
+
if (REDIRECT_STATUSES.has(response.status)) {
|
|
643
|
+
const location = response.headers.location;
|
|
644
|
+
if (typeof location !== "string" || location === "") throw new Error(`HTTP ${response.status} redirect without a Location header`);
|
|
645
|
+
current = new URL(location, current);
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
if (response.status < 200 || response.status >= 300) throw new Error(`HTTP ${response.status}${response.statusText === "" ? "" : ` ${response.statusText}`}`);
|
|
649
|
+
return JSON.parse(response.body);
|
|
650
|
+
}
|
|
651
|
+
throw new Error("too many redirects while downloading the catalog");
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Download the catalog for the settings page: defer to the harness when the
|
|
655
|
+
* environment names a proxy, otherwise tunnel a detected OS proxy, otherwise
|
|
656
|
+
* fetch directly. Never throws for routing reasons — transport failures do
|
|
657
|
+
* throw, and the caller keeps its cached copy.
|
|
658
|
+
* @param options - detection overrides; production passes nothing, tests pin
|
|
659
|
+
* the platform and its OS probes.
|
|
660
|
+
*/
|
|
661
|
+
async function fetchJsonWithSystemProxy(url, timeoutMs, logger, options = {}) {
|
|
662
|
+
const route = await resolveCatalogRoute(url, options);
|
|
663
|
+
for (const diagnostic of route.diagnostics) logger?.warn(`model-parameters: ${diagnostic}`);
|
|
664
|
+
const transport = transportOf(route);
|
|
665
|
+
logger?.info(`model-parameters: catalog download via ${describeTransport(transport)}`);
|
|
666
|
+
try {
|
|
667
|
+
return {
|
|
668
|
+
body: route.tunnel && route.proxy !== void 0 && new URL(url).protocol === "https:" ? await fetchJsonViaProxy(url, route.proxy, timeoutMs) : await fetchJsonDirect(url, timeoutMs),
|
|
669
|
+
transport
|
|
670
|
+
};
|
|
671
|
+
} catch (error) {
|
|
672
|
+
const wrapped = error instanceof Error ? error : new Error(String(error));
|
|
673
|
+
wrapped.transport = transport;
|
|
674
|
+
throw wrapped;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
//#endregion
|
|
124
678
|
//#region src/server/catalog.ts
|
|
125
679
|
/**
|
|
126
680
|
* models.dev catalog: fetch, disk cache, lazy TTL refresh, and id matching.
|
|
127
681
|
*
|
|
682
|
+
* The download goes through {@link fetchJsonWithSystemProxy}, so it follows the
|
|
683
|
+
* proxy environment the harness already routes (unchanged) and, when no proxy
|
|
684
|
+
* variable is set, the operating system's proxy — see system-proxy.ts.
|
|
685
|
+
*
|
|
128
686
|
* The catalog is flattened into a list of entries keyed by provider, then
|
|
129
687
|
* indexed by the last path segment of the model id (case-insensitive) so a
|
|
130
688
|
* synced bare id like `qwen3.7-max` can resolve `Qwen/Qwen3.7-Max` wherever
|
|
@@ -160,16 +718,24 @@ var CatalogManager = class {
|
|
|
160
718
|
};
|
|
161
719
|
loaded = false;
|
|
162
720
|
inflight = null;
|
|
721
|
+
transport;
|
|
722
|
+
fetchError;
|
|
163
723
|
dir;
|
|
164
724
|
cachePath;
|
|
165
|
-
|
|
725
|
+
fetchJson;
|
|
726
|
+
constructor(logger, dataDir, fetchJson) {
|
|
166
727
|
this.logger = logger;
|
|
167
728
|
this.dir = dataDir ?? join(homedir(), ".dsh", "model-parameters");
|
|
168
729
|
this.cachePath = join(this.dir, "catalog.json");
|
|
730
|
+
this.fetchJson = fetchJson ?? ((url, timeoutMs) => fetchJsonWithSystemProxy(url, timeoutMs, logger));
|
|
169
731
|
}
|
|
170
|
-
/** The current snapshot
|
|
732
|
+
/** The current snapshot, including the route and error of the last download attempt. */
|
|
171
733
|
snapshotOf() {
|
|
172
|
-
return
|
|
734
|
+
return {
|
|
735
|
+
...this.snapshot,
|
|
736
|
+
transport: this.transport,
|
|
737
|
+
error: this.fetchError
|
|
738
|
+
};
|
|
173
739
|
}
|
|
174
740
|
/**
|
|
175
741
|
* Load the disk cache once, then fetch when stale (or `force`). A failed
|
|
@@ -189,8 +755,13 @@ var CatalogManager = class {
|
|
|
189
755
|
await this.inflight;
|
|
190
756
|
return true;
|
|
191
757
|
}
|
|
192
|
-
this.inflight = this.fetchAndPersist().
|
|
193
|
-
this.
|
|
758
|
+
this.inflight = this.fetchAndPersist().then(() => {
|
|
759
|
+
this.fetchError = void 0;
|
|
760
|
+
}).catch((error) => {
|
|
761
|
+
this.fetchError = error instanceof Error ? error.message : String(error);
|
|
762
|
+
const attempted = error.transport;
|
|
763
|
+
if (attempted !== void 0) this.transport = attempted;
|
|
764
|
+
this.logger.warn(`model-parameters: catalog fetch failed (${this.fetchError}); using cached copy`);
|
|
194
765
|
}).finally(() => {
|
|
195
766
|
this.inflight = null;
|
|
196
767
|
});
|
|
@@ -256,27 +827,18 @@ var CatalogManager = class {
|
|
|
256
827
|
}
|
|
257
828
|
}
|
|
258
829
|
async fetchAndPersist() {
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
try {
|
|
263
|
-
response = await fetch(CATALOG_URL, {
|
|
264
|
-
signal: controller.signal,
|
|
265
|
-
headers: { accept: "application/json" }
|
|
266
|
-
});
|
|
267
|
-
} finally {
|
|
268
|
-
clearTimeout(timer);
|
|
269
|
-
}
|
|
270
|
-
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
271
|
-
const body = await response.json();
|
|
830
|
+
const { body, transport } = await this.fetchJson(CATALOG_URL, FETCH_TIMEOUT_MS);
|
|
831
|
+
if (typeof body !== "object" || body === null) throw new Error("catalog response is not a JSON object");
|
|
832
|
+
const document = body;
|
|
272
833
|
const fetchedAt = Date.now();
|
|
273
|
-
this.snapshot = this.buildSnapshot(
|
|
834
|
+
this.snapshot = this.buildSnapshot(document, fetchedAt);
|
|
274
835
|
await mkdir(this.dir, { recursive: true });
|
|
275
836
|
const doc = {
|
|
276
837
|
fetchedAt,
|
|
277
|
-
body
|
|
838
|
+
body: document
|
|
278
839
|
};
|
|
279
840
|
await writeFile(this.cachePath, JSON.stringify(doc), "utf8");
|
|
841
|
+
this.transport = transport;
|
|
280
842
|
this.logger.info(`model-parameters: catalog refreshed from models.dev (${this.snapshot.providers} providers, ${this.snapshot.entries.length} models)`);
|
|
281
843
|
}
|
|
282
844
|
buildSnapshot(body, fetchedAt) {
|
|
@@ -666,7 +1228,9 @@ function stateOf(service) {
|
|
|
666
1228
|
providers: snapshot.providers,
|
|
667
1229
|
fetchedAt: snapshot.fetchedAt,
|
|
668
1230
|
fresh: snapshot.fetchedAt > 0 && Date.now() - snapshot.fetchedAt < config.ttlDays * 24 * 60 * 60 * 1e3,
|
|
669
|
-
ttlDays: config.ttlDays
|
|
1231
|
+
ttlDays: config.ttlDays,
|
|
1232
|
+
transport: snapshot.transport ?? null,
|
|
1233
|
+
error: snapshot.error ?? null
|
|
670
1234
|
},
|
|
671
1235
|
report: service.report()
|
|
672
1236
|
};
|
|
@@ -686,4 +1250,4 @@ function apply(ctx, config = {}) {
|
|
|
686
1250
|
});
|
|
687
1251
|
}
|
|
688
1252
|
//#endregion
|
|
689
|
-
export { CatalogManager, Config, acceptedInputModalities, apply, identityReasoningEfforts, inject, name };
|
|
1253
|
+
export { CatalogManager, Config, acceptedInputModalities, apply, detectSystemProxy, fetchJsonViaProxy, fetchJsonWithSystemProxy, identityReasoningEfforts, inject, isLoopbackHost, maskProxyUrl, name, parseRegistryQuery, parseScutilProxyOutput, resolveCatalogRoute, shouldBypassProxy, transportOf, windowsProxyFromRegistry };
|
package/lib/types/index.d.ts
CHANGED
|
@@ -12,9 +12,12 @@
|
|
|
12
12
|
import type { Context } from '@deepseek-ai/cordis';
|
|
13
13
|
import { Config, type ModelParametersLiveConfig } from './shared/config.js';
|
|
14
14
|
import { CatalogManager, acceptedInputModalities, identityReasoningEfforts, type CatalogEntry, type CatalogSnapshot } from './server/catalog.js';
|
|
15
|
+
import { detectSystemProxy, fetchJsonViaProxy, fetchJsonWithSystemProxy, isLoopbackHost, maskProxyUrl, parseRegistryQuery, parseScutilProxyOutput, resolveCatalogRoute, shouldBypassProxy, transportOf, windowsProxyFromRegistry, type CatalogFetch, type CatalogFetchResult, type CatalogRoute, type CatalogTransport, type CommandRunner, type DetectOptions, type SystemProxyConfig } from './server/system-proxy.js';
|
|
15
16
|
export declare const name = "@fonlan/dsh-model-parameters";
|
|
16
17
|
export declare const inject: string[];
|
|
17
18
|
export { Config };
|
|
18
19
|
export { CatalogManager, acceptedInputModalities, identityReasoningEfforts };
|
|
19
20
|
export type { CatalogEntry, CatalogSnapshot };
|
|
21
|
+
export { detectSystemProxy, fetchJsonViaProxy, fetchJsonWithSystemProxy, isLoopbackHost, maskProxyUrl, parseRegistryQuery, parseScutilProxyOutput, resolveCatalogRoute, shouldBypassProxy, transportOf, windowsProxyFromRegistry, };
|
|
22
|
+
export type { CatalogFetch, CatalogFetchResult, CatalogRoute, CatalogTransport, CommandRunner, DetectOptions, SystemProxyConfig, };
|
|
20
23
|
export declare function apply(ctx: Context, config?: ModelParametersLiveConfig): void;
|