@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/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
- constructor(logger, dataDir) {
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 (empty until the first load/fetch completes). */
732
+ /** The current snapshot, including the route and error of the last download attempt. */
171
733
  snapshotOf() {
172
- return this.snapshot;
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().catch((error) => {
193
- this.logger.warn(`model-parameters: catalog fetch failed (${error instanceof Error ? error.message : String(error)}); using cached copy`);
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 controller = new AbortController();
260
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
261
- let response;
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(body, fetchedAt);
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 };
@@ -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;