dsh-plugin-subscriptions 0.5.1 → 0.5.2

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,13 +2,14 @@ import z from "@deepseek-ai/schemastery";
2
2
  import { CONTEXT_WINDOW_EXCEEDED_CODE, CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, ReasoningEffortId, attributionHeaders, errorChain, isContextWindowExceededError, isQuotaExceededError, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
3
3
  import { createServer } from "node:http";
4
4
  import { createHash, randomBytes, randomUUID } from "node:crypto";
5
+ import { ProxyAgent, fetch as fetch$1 } from "undici";
5
6
  import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
7
  import { basename, dirname, join } from "node:path";
8
+ import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
7
9
  import { execFileSync } from "node:child_process";
8
10
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
11
  import { homedir } from "node:os";
10
12
  import { AttachmentId } from "@deepseek-ai/dsh-attachment";
11
- import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
12
13
  import { defineTool } from "@deepseek-ai/dsh-tools";
13
14
 
14
15
  //#region src/auth/pkce.ts
@@ -251,16 +252,355 @@ var OAuthFlowManager = class {
251
252
  };
252
253
 
253
254
  //#endregion
254
- //#region src/auth/device-flow.ts
255
+ //#region src/http.ts
256
+ /**
257
+ * undici's own fetch, typed to the DOM fetch signature: its bundled types are
258
+ * stricter (Request requires `duplex`, `RequestInit.body` is non-null) and
259
+ * incompatible with the DOM shapes the provider code passes. The runtime
260
+ * object is the same Web-fetch implementation Node uses.
261
+ */
262
+ const dispatchFetch = fetch$1;
263
+ /** Destination the `proxyTest` endpoint probes when none is given. */
264
+ const DEFAULT_PROXY_TEST_URL = "https://api.x.ai/v1/models";
265
+ /** Probe deadline; a hung proxy must not pin the Settings dialog forever. */
266
+ const DEFAULT_PROXY_TEST_TIMEOUT_MS = 15e3;
267
+ /** Disabled configuration: the module state before the first load. */
268
+ const DISABLED = {
269
+ enabled: false,
270
+ url: "",
271
+ bypass: []
272
+ };
273
+ /** Current config; updated by every load/apply/save. */
274
+ let current = DISABLED;
275
+ /** The live dispatcher, or undefined when proxies are off/errored. */
276
+ let agent;
277
+ /** Last load/apply failure, surfaced by the config view. */
278
+ let configError;
279
+ /** One lazy load of the on-disk config (module-import cheap; file read once). */
280
+ let ready;
281
+ /** Absolute path of the proxy config file. */
282
+ function proxyFilePath() {
283
+ return dshHomePath("plugins", "subscriptions", "proxy.json");
284
+ }
285
+ function errorMessage(error) {
286
+ return error instanceof Error ? error.message : String(error);
287
+ }
288
+ /**
289
+ * Flatten a fetch failure into a readable message: undici wraps the true
290
+ * cause (`connect ECONNREFUSED ...`) behind a bare "fetch failed", so walk
291
+ * the cause chain and append each distinct layer (up to four, cycle-safe).
292
+ * A hostname resolving to several addresses (e.g. `localhost` → ::1 and
293
+ * 127.0.0.1) fails as an `AggregateError` with an empty message, so its
294
+ * per-address `errors` entries are folded in too.
295
+ */
296
+ function describeFetchError(error) {
297
+ const parts = [];
298
+ let node = error;
299
+ for (let depth = 0; depth < 4 && node !== void 0 && node !== null; depth += 1) {
300
+ const layer = node;
301
+ if (Array.isArray(layer.errors)) for (const child of layer.errors) {
302
+ const childText = child instanceof Error && child.message !== "" ? child.message : String(child);
303
+ if (childText !== "" && !parts.includes(childText)) parts.push(childText);
304
+ }
305
+ let text = layer instanceof Error ? layer.message : String(node);
306
+ const code = layer.code;
307
+ if (typeof code === "string" && code !== "") {
308
+ if (text === "") text = code;
309
+ else if (!text.includes(code)) text = `${text} (${code})`;
310
+ }
311
+ if (text !== "" && !parts.includes(text)) parts.push(text);
312
+ const next = layer.cause;
313
+ if (next === void 0 || next === null || next === node) break;
314
+ node = next;
315
+ }
316
+ return parts.join(" → ");
317
+ }
318
+ function withError(error) {
319
+ configError = errorMessage(error);
320
+ }
321
+ /**
322
+ * Parse and validate a proxy URL. Only HTTP(S) proxies are supported because
323
+ * the undici dispatcher speaks CONNECT over HTTP; socks5 is not supported.
324
+ * @param raw - the URL the user configured.
325
+ * @returns the parsed URL (credentials attached by the caller).
326
+ */
327
+ function parseProxyUrl(raw) {
328
+ let url;
329
+ try {
330
+ url = new URL(raw);
331
+ } catch {
332
+ throw new Error(`proxy URL "${raw}" is not a valid URL`);
333
+ }
334
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`proxy URL must use the http:// or https:// scheme (got "${raw}")`);
335
+ if (url.hostname === "") throw new Error("proxy URL must include a host");
336
+ return url;
337
+ }
338
+ /**
339
+ * Whether a request hostname bypasses the proxy.
340
+ * @param hostname - the request's hostname.
341
+ * @param entries - configured bypass entries: exact host, plain suffix
342
+ * (`example.com` also matches `api.example.com`), or `*.example.com`.
343
+ */
344
+ function matchesBypass(hostname, entries) {
345
+ const host = hostname.toLowerCase();
346
+ for (const raw of entries) {
347
+ let entry = raw.trim().toLowerCase();
348
+ if (entry === "") continue;
349
+ if (entry.includes("://")) try {
350
+ entry = new URL(entry).hostname;
351
+ } catch {
352
+ continue;
353
+ }
354
+ entry = entry.replace(/:\d+$/, "");
355
+ if (entry === "" || entry === "*") continue;
356
+ if (entry.startsWith("*.")) {
357
+ if (host.endsWith(entry.slice(1))) return true;
358
+ } else if (host === entry || host.endsWith(`.${entry}`)) return true;
359
+ }
360
+ return false;
361
+ }
362
+ /** Validate and normalize one config (throws with a user-facing message). */
363
+ function normalizeConfig(input) {
364
+ const url = input.url.trim();
365
+ if (input.enabled && url === "") throw new Error("a proxy URL is required when the proxy is enabled");
366
+ if (url !== "") parseProxyUrl(url);
367
+ const bypass = Array.from(new Set((input.bypass ?? []).map((entry) => entry.trim()).filter((entry) => entry !== "")));
368
+ return {
369
+ enabled: input.enabled,
370
+ url,
371
+ ...input.username !== void 0 && input.username !== "" ? { username: input.username.trim() } : {},
372
+ ...input.password !== void 0 && input.password !== "" && input.password !== null ? { password: input.password } : {},
373
+ bypass
374
+ };
375
+ }
376
+ /** Build the undici agent for a config (throws on an unusable URL). */
377
+ function buildAgent(cfg) {
378
+ if (!cfg.enabled || cfg.url === "") return void 0;
379
+ const url = parseProxyUrl(cfg.url);
380
+ if (cfg.username !== void 0) url.username = cfg.username;
381
+ if (cfg.password !== void 0) url.password = cfg.password;
382
+ return new ProxyAgent(url.toString());
383
+ }
384
+ /** Swap in a config and its agent; a failed agent keeps the requests direct. */
385
+ async function applyConfig(cfg) {
386
+ let next;
387
+ if (cfg !== void 0) {
388
+ configError = void 0;
389
+ try {
390
+ next = buildAgent(cfg);
391
+ } catch (error) {
392
+ withError(error);
393
+ next = void 0;
394
+ }
395
+ current = cfg;
396
+ }
397
+ const previous = agent;
398
+ agent = next;
399
+ if (previous !== void 0) previous.close().catch(() => void 0);
400
+ }
401
+ /** Read the on-disk config. A missing file is the disabled default. */
402
+ async function loadConfigFile(path) {
403
+ let text;
404
+ try {
405
+ text = await readFile(path, "utf8");
406
+ } catch (error) {
407
+ if (error.code === "ENOENT") return {
408
+ ...DISABLED,
409
+ bypass: []
410
+ };
411
+ throw error;
412
+ }
413
+ let parsed;
414
+ try {
415
+ parsed = JSON.parse(text);
416
+ } catch {
417
+ throw new Error(`subscriptions proxy config at ${path} is not valid JSON; fix or delete the file`);
418
+ }
419
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("subscriptions proxy config must be a JSON object");
420
+ const record = parsed;
421
+ const enabled = record.enabled === true;
422
+ const url = typeof record.url === "string" ? record.url : "";
423
+ const username = typeof record.username === "string" ? record.username : void 0;
424
+ const password = typeof record.password === "string" ? record.password : void 0;
425
+ const bypass = Array.isArray(record.bypass) ? record.bypass.filter((entry) => typeof entry === "string") : [];
426
+ return normalizeConfig({
427
+ enabled,
428
+ url,
429
+ ...username === void 0 ? {} : { username },
430
+ ...password === void 0 ? {} : { password },
431
+ bypass
432
+ });
433
+ }
434
+ /** Resolve the module state once from disk; failures disable the proxy. */
435
+ async function ensureReady() {
436
+ ready ??= loadConfigFile(proxyFilePath()).then(async (cfg) => {
437
+ await applyConfig(cfg);
438
+ return current;
439
+ }, async (error) => {
440
+ withError(error);
441
+ await applyConfig(void 0);
442
+ return current;
443
+ });
444
+ return ready;
445
+ }
446
+ /** Persist a config atomically with owner-only permissions, then apply it. */
447
+ async function persistConfig(cfg, path) {
448
+ await mkdir(dirname(path), { recursive: true });
449
+ const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
450
+ try {
451
+ await writeFile(tmp, JSON.stringify(cfg, null, 2), { mode: 384 });
452
+ await chmod(tmp, 384);
453
+ await rename(tmp, path);
454
+ } catch (error) {
455
+ await rm(tmp, { force: true });
456
+ throw error;
457
+ }
458
+ }
255
459
  /**
256
- * GitHub OAuth device-authorization flow (RFC 8628) for providers that cannot
257
- * use the loopback redirect engine: no redirect URI, no PKCE, no client
258
- * secret. The user opens a verification URL and types a short code while the
259
- * plugin polls the token endpoint until GitHub releases the access token.
260
- * The management model (one attempt per provider, `isBusy`/`pending`/`cancel`)
261
- * mirrors {@link OAuthFlowManager} so the auth controller can treat both
262
- * engines uniformly.
460
+ * Current proxy config as served to the client (secrets omitted).
461
+ * @returns the view; {@link ProxyConfigView.error} carries the last
462
+ * load/apply failure when the stored config is unusable.
263
463
  */
464
+ async function proxyGetConfig() {
465
+ await ensureReady();
466
+ return {
467
+ enabled: current.enabled,
468
+ url: current.url,
469
+ ...current.username === void 0 ? {} : { username: current.username },
470
+ passwordSet: current.password !== void 0 && current.password !== "",
471
+ bypass: [...current.bypass],
472
+ ...configError === void 0 ? {} : { error: configError }
473
+ };
474
+ }
475
+ /**
476
+ * Validate, persist, and apply one proxy config. A `password` of `undefined`
477
+ * keeps the stored value; `null` or `''` clears it.
478
+ * @param input - the client's payload.
479
+ * @returns the resulting view (secrets omitted).
480
+ */
481
+ async function proxySetConfig(input) {
482
+ await ensureReady();
483
+ const password = input.password === void 0 ? current.password : input.password === null || input.password === "" ? void 0 : input.password;
484
+ const next = normalizeConfig({
485
+ enabled: input.enabled,
486
+ url: input.url,
487
+ ...input.username === void 0 ? {} : { username: input.username },
488
+ ...password === void 0 ? {} : { password },
489
+ bypass: input.bypass ?? current.bypass
490
+ });
491
+ await persistConfig(next, proxyFilePath());
492
+ await applyConfig(next);
493
+ return proxyGetConfig();
494
+ }
495
+ /**
496
+ * The fetch caller all subscription code uses: routes through the configured
497
+ * proxy unless the host bypasses it. Identity-passthrough otherwise.
498
+ *
499
+ * Proxied requests run on undici's own fetch (not the global one) so the
500
+ * ProxyAgent dispatcher always comes from the same undici build the request
501
+ * is issued with — a mismatched dispatcher can be silently ignored by the
502
+ * host's global fetch.
503
+ */
504
+ async function proxiedFetch(input, init = {}) {
505
+ await ensureReady();
506
+ let dispatcher;
507
+ if (current.enabled && agent !== void 0) {
508
+ let hostname = "";
509
+ try {
510
+ hostname = (typeof input === "string" ? new URL(input) : input instanceof URL ? input : new URL(input.url)).hostname;
511
+ } catch {
512
+ hostname = "";
513
+ }
514
+ if (!matchesBypass(hostname, current.bypass)) dispatcher = agent;
515
+ }
516
+ if (dispatcher === void 0) return fetch(input, init);
517
+ return dispatchFetch(input, {
518
+ ...init,
519
+ dispatcher
520
+ });
521
+ }
522
+ /**
523
+ * Probe a destination through a proxy, answering with the HTTP status or a
524
+ * flattened transport error. The probe uses `draft` when given (the dialog's
525
+ * current inputs, without saving) and the stored config otherwise.
526
+ * @param target - `http(s)` URL to fetch; defaults to {@link DEFAULT_PROXY_TEST_URL}.
527
+ * @param draft - unsaved proxy inputs to test; absent means the stored config.
528
+ * @returns the result; any HTTP status counts as a successful connection,
529
+ * only a transport failure is an error.
530
+ */
531
+ async function proxyTestConnection(target = DEFAULT_PROXY_TEST_URL, draft) {
532
+ let parsed;
533
+ try {
534
+ parsed = new URL(target);
535
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return {
536
+ ok: false,
537
+ viaProxy: false,
538
+ error: `test destination must be http or https (got "${parsed.protocol}//")`
539
+ };
540
+ } catch (error) {
541
+ return {
542
+ ok: false,
543
+ viaProxy: false,
544
+ error: errorMessage(error)
545
+ };
546
+ }
547
+ await ensureReady();
548
+ let probeAgent;
549
+ let viaProxy;
550
+ let closeProbe = false;
551
+ if (draft !== void 0) try {
552
+ probeAgent = buildAgent(normalizeConfig({
553
+ enabled: true,
554
+ url: draft.url,
555
+ ...draft.username === void 0 || draft.username === "" ? {} : { username: draft.username },
556
+ ...draft.password === void 0 || draft.password === "" ? {} : { password: draft.password },
557
+ bypass: []
558
+ }));
559
+ viaProxy = probeAgent !== void 0;
560
+ closeProbe = true;
561
+ } catch (error) {
562
+ return {
563
+ ok: false,
564
+ viaProxy: false,
565
+ error: errorMessage(error)
566
+ };
567
+ }
568
+ else {
569
+ viaProxy = current.enabled && agent !== void 0 && !matchesBypass(parsed.hostname, current.bypass);
570
+ probeAgent = viaProxy ? agent : void 0;
571
+ }
572
+ const started = Date.now();
573
+ try {
574
+ const init = probeAgent !== void 0 ? {
575
+ method: "GET",
576
+ dispatcher: probeAgent,
577
+ signal: AbortSignal.timeout(DEFAULT_PROXY_TEST_TIMEOUT_MS)
578
+ } : {
579
+ method: "GET",
580
+ signal: AbortSignal.timeout(DEFAULT_PROXY_TEST_TIMEOUT_MS)
581
+ };
582
+ const response = probeAgent !== void 0 ? await dispatchFetch(parsed.toString(), init) : await fetch(parsed.toString(), init);
583
+ response.arrayBuffer().catch(() => void 0);
584
+ return {
585
+ ok: true,
586
+ viaProxy,
587
+ status: response.status,
588
+ latencyMs: Date.now() - started
589
+ };
590
+ } catch (error) {
591
+ return {
592
+ ok: false,
593
+ viaProxy,
594
+ latencyMs: Date.now() - started,
595
+ error: describeFetchError(error)
596
+ };
597
+ } finally {
598
+ if (closeProbe && probeAgent !== void 0) await probeAgent.close().catch(() => void 0);
599
+ }
600
+ }
601
+
602
+ //#endregion
603
+ //#region src/auth/device-flow.ts
264
604
  /** Default poll interval when the device-code response omits one. */
265
605
  const DEFAULT_INTERVAL_SEC = 5;
266
606
  /** Default device-code lifetime when the response omits one (GitHub: 15 minutes). */
@@ -316,7 +656,7 @@ var DeviceFlowManager = class {
316
656
  */
317
657
  async start(provider, spec) {
318
658
  if (this.attempts.has(provider)) throw new Error(`a ${provider} login attempt is already in progress`);
319
- const fetchFn = spec.fetchFn ?? fetch;
659
+ const fetchFn = spec.fetchFn ?? proxiedFetch;
320
660
  const response = await fetchFn(spec.deviceCodeUrl, {
321
661
  method: "POST",
322
662
  headers: {
@@ -835,7 +1175,69 @@ function readSessionId(payload) {
835
1175
  if (typeof payload !== "object" || payload === null) throw new BadRequest("payload must be an object");
836
1176
  return readString(payload, "sessionId");
837
1177
  }
838
- async function dispatch(controller, speed, endpoint, payload, signal) {
1178
+ /** Validate a `proxySet` payload into a shape `ProxyInput` accepts. */
1179
+ function readProxyInput(payload) {
1180
+ if (typeof payload !== "object" || payload === null) throw new BadRequest("payload must be an object");
1181
+ const record = payload;
1182
+ if (typeof record.enabled !== "boolean") throw new BadRequest("payload.enabled must be a boolean");
1183
+ if (typeof record.url !== "string") throw new BadRequest("payload.url must be a string");
1184
+ let username;
1185
+ if (record.username !== void 0) {
1186
+ if (typeof record.username !== "string") throw new BadRequest("payload.username must be a string when present");
1187
+ username = record.username;
1188
+ }
1189
+ let password;
1190
+ if (record.password !== void 0) {
1191
+ if (record.password !== null && typeof record.password !== "string") throw new BadRequest("payload.password must be a string or null when present");
1192
+ password = record.password;
1193
+ }
1194
+ let bypass;
1195
+ if (record.bypass !== void 0) {
1196
+ if (!Array.isArray(record.bypass) || record.bypass.some((entry) => typeof entry !== "string")) throw new BadRequest("payload.bypass must be an array of strings when present");
1197
+ bypass = record.bypass;
1198
+ }
1199
+ return {
1200
+ enabled: record.enabled,
1201
+ url: record.url,
1202
+ ...username === void 0 ? {} : { username },
1203
+ ...password === void 0 ? {} : { password },
1204
+ ...bypass === void 0 ? {} : { bypass }
1205
+ };
1206
+ }
1207
+ /** Validate a `proxyTest` payload (the destination URL and an optional draft). */
1208
+ function readProxyTestPayload(payload) {
1209
+ if (typeof payload !== "object" || payload === null) return {};
1210
+ const record = payload;
1211
+ const url = record.url;
1212
+ if (url === void 0 && record.proxy === void 0) return {};
1213
+ if (url !== void 0 && (typeof url !== "string" || url.length === 0)) throw new BadRequest("payload.url must be a non-empty string when present");
1214
+ let proxy;
1215
+ if (record.proxy !== void 0) {
1216
+ if (typeof record.proxy !== "object" || record.proxy === null) throw new BadRequest("payload.proxy must be an object when present");
1217
+ const draftRecord = record.proxy;
1218
+ if (typeof draftRecord.url !== "string" || draftRecord.url.length === 0) throw new BadRequest("payload.proxy.url must be a non-empty string");
1219
+ let username;
1220
+ if (draftRecord.username !== void 0) {
1221
+ if (typeof draftRecord.username !== "string") throw new BadRequest("payload.proxy.username must be a string when present");
1222
+ username = draftRecord.username;
1223
+ }
1224
+ let password;
1225
+ if (draftRecord.password !== void 0) {
1226
+ if (typeof draftRecord.password !== "string") throw new BadRequest("payload.proxy.password must be a string when present");
1227
+ password = draftRecord.password;
1228
+ }
1229
+ proxy = {
1230
+ url: draftRecord.url,
1231
+ ...username === void 0 ? {} : { username },
1232
+ ...password === void 0 ? {} : { password }
1233
+ };
1234
+ }
1235
+ return {
1236
+ ...url === void 0 ? {} : { url },
1237
+ ...proxy === void 0 ? {} : { proxy }
1238
+ };
1239
+ }
1240
+ async function dispatch(controller, speed, proxy, endpoint, payload, signal) {
839
1241
  switch (endpoint) {
840
1242
  case "status": {
841
1243
  const entries = await Promise.all(PROVIDER_IDS.map(async (provider) => [provider, await controller.status(provider)]));
@@ -860,6 +1262,15 @@ async function dispatch(controller, speed, endpoint, payload, signal) {
860
1262
  case "setSpeed":
861
1263
  await speed.setSpeed(readSessionId(payload), readSpeedTier(payload));
862
1264
  return ok({ ok: true });
1265
+ case "proxyGet":
1266
+ if (proxy === void 0) throw new BadRequest("proxy configuration is unavailable");
1267
+ return ok(await proxy.get());
1268
+ case "proxySet":
1269
+ if (proxy === void 0) throw new BadRequest("proxy configuration is unavailable");
1270
+ return ok(await proxy.set(readProxyInput(payload)));
1271
+ case "proxyTest":
1272
+ if (proxy === void 0) throw new BadRequest("proxy configuration is unavailable");
1273
+ return ok(await proxy.test(readProxyTestPayload(payload)));
863
1274
  default: throw new BadRequest(`unknown /subscriptions-auth endpoint "${endpoint}"`);
864
1275
  }
865
1276
  }
@@ -868,13 +1279,14 @@ async function dispatch(controller, speed, endpoint, payload, signal) {
868
1279
  * @param ctx - the plugin context (headless profiles have no `connection`).
869
1280
  * @param controller - the auth operations backing the endpoints.
870
1281
  * @param speed - the per-session speed-tier state backing the Speed toggle.
1282
+ * @param proxy - optional proxy-config controller backing `proxyGet`/`proxySet`/`proxyTest`.
871
1283
  */
872
- function registerAuthRpc(ctx, controller, speed) {
1284
+ function registerAuthRpc(ctx, controller, speed, proxy = void 0) {
873
1285
  ctx.inject(["connection"], (ctx$1) => {
874
1286
  const connection = ctx$1.get("connection");
875
1287
  ctx$1.effect(() => connection.rpc.handle(SUBSCRIPTIONS_AUTH_CHANNEL, async (endpoint, payload, signal) => {
876
1288
  try {
877
- return await dispatch(controller, speed, endpoint, payload, signal);
1289
+ return await dispatch(controller, speed, proxy, endpoint, payload, signal);
878
1290
  } catch (error) {
879
1291
  return failure(error);
880
1292
  }
@@ -1075,9 +1487,9 @@ var TokenManager = class {
1075
1487
  }
1076
1488
  }
1077
1489
  async doRefresh(session) {
1078
- const current = await this.options.load();
1079
- if (current !== void 0 && current.accessToken !== session.accessToken && current.expiresAt - Date.now() > this.options.preemptMs) return current;
1080
- const next = await this.options.refresh(current ?? session);
1490
+ const current$1 = await this.options.load();
1491
+ if (current$1 !== void 0 && current$1.accessToken !== session.accessToken && current$1.expiresAt - Date.now() > this.options.preemptMs) return current$1;
1492
+ const next = await this.options.refresh(current$1 ?? session);
1081
1493
  await this.options.save(next);
1082
1494
  return next;
1083
1495
  }
@@ -1960,7 +2372,7 @@ function codexSession(tokens, fallback) {
1960
2372
  * @returns the session to store.
1961
2373
  */
1962
2374
  async function exchangeCodexCode(code, verifier, redirectUri) {
1963
- const response = await fetch(CODEX_TOKEN_URL, {
2375
+ const response = await proxiedFetch(CODEX_TOKEN_URL, {
1964
2376
  method: "POST",
1965
2377
  headers: { "content-type": "application/x-www-form-urlencoded" },
1966
2378
  body: new URLSearchParams({
@@ -1980,7 +2392,7 @@ async function exchangeCodexCode(code, verifier, redirectUri) {
1980
2392
  * @returns the fresh session to store.
1981
2393
  */
1982
2394
  async function refreshCodex(session) {
1983
- const response = await fetch(CODEX_TOKEN_URL, {
2395
+ const response = await proxiedFetch(CODEX_TOKEN_URL, {
1984
2396
  method: "POST",
1985
2397
  headers: { "content-type": "application/json" },
1986
2398
  body: JSON.stringify({
@@ -2048,7 +2460,7 @@ function codexUsageWindow(value, fallbackKind) {
2048
2460
  * @param signal - caller cancellation from the RPC transport.
2049
2461
  * @returns the mapped usage snapshot.
2050
2462
  */
2051
- async function fetchCodexUsage(session, fetchFn = fetch, signal) {
2463
+ async function fetchCodexUsage(session, fetchFn = proxiedFetch, signal) {
2052
2464
  const response = await fetchFn(CODEX_USAGE_URL, {
2053
2465
  headers: {
2054
2466
  "authorization": `Bearer ${session.accessToken}`,
@@ -2098,7 +2510,7 @@ function supportsFastTier(entry) {
2098
2510
  * @param fetchFn - fetch implementation (injectable for tests).
2099
2511
  * @returns discovered models: hidden entries dropped, sorted by priority.
2100
2512
  */
2101
- async function fetchCodexModels(session, fetchFn = fetch) {
2513
+ async function fetchCodexModels(session, fetchFn = proxiedFetch) {
2102
2514
  const response = await fetchFn(`${CODEX_MODELS_URL}?client_version=${CODEX_CLIENT_VERSION}`, { headers: {
2103
2515
  "authorization": `Bearer ${session.accessToken}`,
2104
2516
  "chatgpt-account-id": session.accountId,
@@ -2311,7 +2723,7 @@ var CodexAdapter = class extends LlmAdapter {
2311
2723
  const messages = await resolveImages(options.messages, this.options.resolveAttachments?.(), signal);
2312
2724
  const fast = this.options.speedFor !== void 0 && await this.options.speedFor(options.sessionId, options.model);
2313
2725
  const body = codexRequestBody(options, toResponsesInput(messages, options.system), fast);
2314
- return fetch(CODEX_API_URL, {
2726
+ return proxiedFetch(CODEX_API_URL, {
2315
2727
  method: "POST",
2316
2728
  headers: {
2317
2729
  "authorization": `Bearer ${session.accessToken}`,
@@ -2846,7 +3258,7 @@ const claudeFlow = {
2846
3258
  /** Best-effort account profile; login must not fail when this does. */
2847
3259
  async function fetchClaudeProfile(accessToken) {
2848
3260
  try {
2849
- const response = await fetch(CLAUDE_PROFILE_URL, { headers: { authorization: `Bearer ${accessToken}` } });
3261
+ const response = await proxiedFetch(CLAUDE_PROFILE_URL, { headers: { authorization: `Bearer ${accessToken}` } });
2850
3262
  if (!response.ok) return {};
2851
3263
  const profile = await response.json();
2852
3264
  const account = typeof profile.account === "object" && profile.account !== null ? profile.account : {};
@@ -2884,7 +3296,7 @@ async function claudeSession(tokens, fallbackRefreshToken, withProfile) {
2884
3296
  * @returns the session to store.
2885
3297
  */
2886
3298
  async function exchangeClaudeCode(code, verifier, redirectUri, state) {
2887
- const response = await fetch(CLAUDE_TOKEN_URL, {
3299
+ const response = await proxiedFetch(CLAUDE_TOKEN_URL, {
2888
3300
  method: "POST",
2889
3301
  headers: { "content-type": "application/json" },
2890
3302
  body: JSON.stringify({
@@ -2905,7 +3317,7 @@ async function exchangeClaudeCode(code, verifier, redirectUri, state) {
2905
3317
  * @returns the fresh session to store.
2906
3318
  */
2907
3319
  async function refreshClaude(session) {
2908
- const response = await fetch(CLAUDE_TOKEN_URL, {
3320
+ const response = await proxiedFetch(CLAUDE_TOKEN_URL, {
2909
3321
  method: "POST",
2910
3322
  headers: { "content-type": "application/json" },
2911
3323
  body: JSON.stringify({
@@ -2980,7 +3392,7 @@ function claudeLimitsWindows(value) {
2980
3392
  * @param signal - caller cancellation from the RPC transport.
2981
3393
  * @returns the mapped usage snapshot.
2982
3394
  */
2983
- async function fetchClaudeUsage(session, fetchFn = fetch, signal) {
3395
+ async function fetchClaudeUsage(session, fetchFn = proxiedFetch, signal) {
2984
3396
  const response = await fetchFn(CLAUDE_USAGE_URL, {
2985
3397
  headers: {
2986
3398
  "authorization": `Bearer ${session.accessToken}`,
@@ -3033,7 +3445,7 @@ function claudeReasoning(capabilities) {
3033
3445
  return efforts.length > 0 ? { efforts } : void 0;
3034
3446
  }
3035
3447
  /** Fetch the live model catalog from the subscription endpoint. */
3036
- async function fetchClaudeModels(session, fetchFn = fetch) {
3448
+ async function fetchClaudeModels(session, fetchFn = proxiedFetch) {
3037
3449
  const response = await fetchFn(CLAUDE_MODELS_URL, { headers: {
3038
3450
  "authorization": `Bearer ${session.accessToken}`,
3039
3451
  "anthropic-version": "2023-06-01",
@@ -3215,7 +3627,7 @@ var ClaudeAdapter = class extends LlmAdapter {
3215
3627
  const maxTokens = options.maxTokens ?? this.options.models.find((entry) => entry.id === options.model)?.maxTokens ?? CLAUDE_DEFAULT_MAX_TOKENS;
3216
3628
  const disc = await this.discovered(options.model);
3217
3629
  const body = claudeRequestBody(options, messages, maxTokens, this.thinkingParam(disc?.thinkingType, maxTokens), options.reasoningEffort !== void 0 && disc?.reasoning !== void 0 ? String(options.reasoningEffort) : void 0);
3218
- return fetch(CLAUDE_API_URL, {
3630
+ return proxiedFetch(CLAUDE_API_URL, {
3219
3631
  method: "POST",
3220
3632
  headers: {
3221
3633
  "authorization": `Bearer ${session.accessToken}`,
@@ -3262,7 +3674,7 @@ let discoveryCache;
3262
3674
  */
3263
3675
  async function grokDiscovery() {
3264
3676
  if (discoveryCache !== void 0) return discoveryCache;
3265
- const response = await fetch(GROK_DISCOVERY_URL);
3677
+ const response = await proxiedFetch(GROK_DISCOVERY_URL);
3266
3678
  if (!response.ok) throw await oauthEndpointError(response, "grok OIDC discovery");
3267
3679
  const document = await response.json();
3268
3680
  if (typeof document.authorization_endpoint !== "string" || typeof document.token_endpoint !== "string") throw new Error("grok OIDC discovery document is missing endpoints");
@@ -3363,7 +3775,7 @@ function grokSession(tokens, tokenEndpoint, fallbackRefreshToken) {
3363
3775
  */
3364
3776
  async function exchangeGrokCode(code, verifier, redirectUri, challenge) {
3365
3777
  const discovery = await grokDiscovery();
3366
- const response = await fetch(discovery.tokenEndpoint, {
3778
+ const response = await proxiedFetch(discovery.tokenEndpoint, {
3367
3779
  method: "POST",
3368
3780
  headers: { "content-type": "application/x-www-form-urlencoded" },
3369
3781
  body: new URLSearchParams({
@@ -3386,7 +3798,7 @@ async function exchangeGrokCode(code, verifier, redirectUri, challenge) {
3386
3798
  * @returns the fresh session to store.
3387
3799
  */
3388
3800
  async function refreshGrok(session) {
3389
- const response = await fetch(session.tokenEndpoint, {
3801
+ const response = await proxiedFetch(session.tokenEndpoint, {
3390
3802
  method: "POST",
3391
3803
  headers: { "content-type": "application/x-www-form-urlencoded" },
3392
3804
  body: new URLSearchParams({
@@ -3433,7 +3845,7 @@ function grokResetsAt(value) {
3433
3845
  * @param signal - caller cancellation from the RPC transport.
3434
3846
  * @returns the mapped usage snapshot.
3435
3847
  */
3436
- async function fetchGrokUsage(session, fetchFn = fetch, signal) {
3848
+ async function fetchGrokUsage(session, fetchFn = proxiedFetch, signal) {
3437
3849
  const response = await fetchFn(GROK_BILLING_URL, {
3438
3850
  headers: {
3439
3851
  "authorization": `Bearer ${session.accessToken}`,
@@ -3508,7 +3920,7 @@ function grokCliReasoning(entry) {
3508
3920
  * @param fetchFn - fetch implementation (injectable for tests).
3509
3921
  * @returns model id → contributed metadata.
3510
3922
  */
3511
- async function fetchGrokCliCatalog(session, fetchFn = fetch) {
3923
+ async function fetchGrokCliCatalog(session, fetchFn = proxiedFetch) {
3512
3924
  const response = await fetchFn(GROK_CLI_MODELS_URL, { headers: {
3513
3925
  "authorization": `Bearer ${session.accessToken}`,
3514
3926
  "x-xai-token-auth": "xai-grok-cli",
@@ -3569,7 +3981,7 @@ function grokPriorMeta(prior) {
3569
3981
  * catalog is down or omits a model.
3570
3982
  * @returns discovered chat models in endpoint order.
3571
3983
  */
3572
- async function fetchGrokModels(session, fetchFn = fetch, onWarn, previous) {
3984
+ async function fetchGrokModels(session, fetchFn = proxiedFetch, onWarn, previous) {
3573
3985
  const previousById = previous === void 0 || previous.length === 0 ? void 0 : new Map(previous.map((model) => [model.id, model]));
3574
3986
  const [response, cliCatalog] = await Promise.all([fetchFn(GROK_MODELS_URL, { headers: {
3575
3987
  "authorization": `Bearer ${session.accessToken}`,
@@ -3703,7 +4115,7 @@ var GrokAdapter = class extends LlmAdapter {
3703
4115
  store: false,
3704
4116
  stream: true
3705
4117
  };
3706
- return fetch(GROK_API_URL, {
4118
+ return proxiedFetch(GROK_API_URL, {
3707
4119
  method: "POST",
3708
4120
  headers: {
3709
4121
  "authorization": `Bearer ${session.accessToken}`,
@@ -4117,7 +4529,7 @@ let vscodeVersionInflight;
4117
4529
  * @param forceRefresh - bypass the cache (a 401 `IDE token expired` retry).
4118
4530
  * @returns a `major.minor.patch` version string.
4119
4531
  */
4120
- async function latestVsCodeVersion(fetchFn = fetch, forceRefresh = false) {
4532
+ async function latestVsCodeVersion(fetchFn = proxiedFetch, forceRefresh = false) {
4121
4533
  if (!forceRefresh && vscodeVersionCache !== void 0 && Date.now() - vscodeVersionCache.at < VSCODE_VERSION_TTL_MS) return vscodeVersionCache.version;
4122
4534
  vscodeVersionInflight ??= (async () => {
4123
4535
  try {
@@ -4177,7 +4589,7 @@ function copilotHeaders(hasVision = false, vscodeVersion = FALLBACK_VSCODE_VERSI
4177
4589
  * @param fetchFn - fetch implementation (injectable for tests).
4178
4590
  * @returns the Copilot API token and its expiry.
4179
4591
  */
4180
- async function exchangeCopilotToken(githubToken, fetchFn = fetch) {
4592
+ async function exchangeCopilotToken(githubToken, fetchFn = proxiedFetch) {
4181
4593
  const response = await fetchFn(COPILOT_TOKEN_URL, { headers: {
4182
4594
  "authorization": `Bearer ${githubToken}`,
4183
4595
  "accept": "application/json",
@@ -4198,7 +4610,7 @@ async function exchangeCopilotToken(githubToken, fetchFn = fetch) {
4198
4610
  * @param fetchFn - fetch implementation (injectable for tests).
4199
4611
  * @returns the session to store.
4200
4612
  */
4201
- async function completeCopilotLogin(githubToken, fetchFn = fetch) {
4613
+ async function completeCopilotLogin(githubToken, fetchFn = proxiedFetch) {
4202
4614
  const pair = await exchangeCopilotToken(githubToken, fetchFn);
4203
4615
  let account;
4204
4616
  try {
@@ -4226,7 +4638,7 @@ async function completeCopilotLogin(githubToken, fetchFn = fetch) {
4226
4638
  * @param fetchFn - fetch implementation (injectable for tests).
4227
4639
  * @returns the fresh session to store.
4228
4640
  */
4229
- async function refreshCopilot(session, fetchFn = fetch) {
4641
+ async function refreshCopilot(session, fetchFn = proxiedFetch) {
4230
4642
  const pair = await exchangeCopilotToken(session.refreshToken, fetchFn);
4231
4643
  return {
4232
4644
  accessToken: pair.accessToken,
@@ -4284,7 +4696,7 @@ function copilotReasoning(entry) {
4284
4696
  * @param fetchFn - fetch implementation (injectable for tests).
4285
4697
  * @returns discovered chat models in endpoint order.
4286
4698
  */
4287
- async function fetchCopilotModels(session, fetchFn = fetch) {
4699
+ async function fetchCopilotModels(session, fetchFn = proxiedFetch) {
4288
4700
  const response = await fetchFn(COPILOT_MODELS_URL, { headers: {
4289
4701
  "authorization": `Bearer ${session.accessToken}`,
4290
4702
  "accept": "application/json",
@@ -4696,7 +5108,7 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4696
5108
  const scope = this.replayScope(session.refreshToken, options);
4697
5109
  let response = await this.request(options, session, watchdog.signal, wire, scope);
4698
5110
  if (response.status === 401) {
4699
- await latestVsCodeVersion(this.options.fetchFn ?? fetch, true);
5111
+ await latestVsCodeVersion(this.options.fetchFn ?? proxiedFetch, true);
4700
5112
  session = await this.options.tokens.session(true);
4701
5113
  response = await this.request(options, session, watchdog.signal, wire, scope);
4702
5114
  }
@@ -4721,13 +5133,13 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4721
5133
  const messages = await resolveImages(options.messages, this.options.resolveAttachments?.(), signal);
4722
5134
  const hasVision = messages.some((message) => message.content.some((block) => block.type === "image"));
4723
5135
  const body = wire === "responses" ? copilotResponsesRequestBody(options, toResponsesInput(messages, options.system, (callId) => this.replayFor(replayScopeKey, callId))) : copilotChatRequestBody(options, toChatMessages(messages, options.system));
4724
- return fetch(wire === "responses" ? COPILOT_RESPONSES_URL : COPILOT_API_URL, {
5136
+ return proxiedFetch(wire === "responses" ? COPILOT_RESPONSES_URL : COPILOT_API_URL, {
4725
5137
  method: "POST",
4726
5138
  headers: {
4727
5139
  "authorization": `Bearer ${session.accessToken}`,
4728
5140
  "accept": "text/event-stream",
4729
5141
  "content-type": "application/json",
4730
- ...copilotHeaders(hasVision, await latestVsCodeVersion(this.options.fetchFn ?? fetch))
5142
+ ...copilotHeaders(hasVision, await latestVsCodeVersion(this.options.fetchFn ?? proxiedFetch))
4731
5143
  },
4732
5144
  body: JSON.stringify(body),
4733
5145
  signal
@@ -4895,7 +5307,7 @@ function createXSearchTool(options) {
4895
5307
  async execute(args, exec) {
4896
5308
  const request = buildXSearchRequest(args);
4897
5309
  const session = await options.tokens.session();
4898
- const response = await (options.fetchFn ?? fetch)(X_SEARCH_URL, {
5310
+ const response = await (options.fetchFn ?? proxiedFetch)(X_SEARCH_URL, {
4899
5311
  method: "POST",
4900
5312
  headers: {
4901
5313
  "authorization": `Bearer ${session.accessToken}`,
@@ -5160,7 +5572,7 @@ function createImageGenerateTool(options) {
5160
5572
  content: result.content.filter((block) => block.type === "text")
5161
5573
  }),
5162
5574
  async execute(args, exec) {
5163
- const fetchFn = options.fetchFn ?? fetch;
5575
+ const fetchFn = options.fetchFn ?? proxiedFetch;
5164
5576
  const preferGrok = args.provider === "grok";
5165
5577
  const codexReady = options.codexTokens !== void 0 && await options.codexTokens.hasSession();
5166
5578
  const grokReady = options.grokTokens !== void 0 && await options.grokTokens.hasSession();
@@ -5430,7 +5842,7 @@ function createVideoGenerateTool(options) {
5430
5842
  async execute(args, exec) {
5431
5843
  const body = buildVideoGenerateBody(args);
5432
5844
  const session = await options.tokens.session();
5433
- const fetchFn = options.fetchFn ?? fetch;
5845
+ const fetchFn = options.fetchFn ?? proxiedFetch;
5434
5846
  const headers = {
5435
5847
  "authorization": `Bearer ${session.accessToken}`,
5436
5848
  "accept": "application/json"
@@ -5852,7 +6264,7 @@ function apply(ctx, config) {
5852
6264
  }
5853
6265
  });
5854
6266
  codexTokens = tokens;
5855
- usageFetchers.codex = async (signal) => fetchCodexUsage(await tokens.session(), fetch, signal);
6267
+ usageFetchers.codex = async (signal) => fetchCodexUsage(await tokens.session(), proxiedFetch, signal);
5856
6268
  let adapter;
5857
6269
  adapter = new CodexAdapter({
5858
6270
  models: catalog.codex,
@@ -5882,7 +6294,7 @@ function apply(ctx, config) {
5882
6294
  }
5883
6295
  });
5884
6296
  claudeTokens = tokens;
5885
- usageFetchers.claude = async (signal) => fetchClaudeUsage(await tokens.session(), fetch, signal);
6297
+ usageFetchers.claude = async (signal) => fetchClaudeUsage(await tokens.session(), proxiedFetch, signal);
5886
6298
  handles.set("claude", ctx.llm.registerAdapter(["claude"], new ClaudeAdapter({
5887
6299
  models: catalog.claude,
5888
6300
  streamIdleTimeoutMs,
@@ -5909,7 +6321,7 @@ function apply(ctx, config) {
5909
6321
  }
5910
6322
  });
5911
6323
  grokTokens = tokens;
5912
- usageFetchers.grok = async (signal) => fetchGrokUsage(await tokens.session(), fetch, signal);
6324
+ usageFetchers.grok = async (signal) => fetchGrokUsage(await tokens.session(), proxiedFetch, signal);
5913
6325
  handles.set("grok", ctx.llm.registerAdapter(["grok"], new GrokAdapter({
5914
6326
  models: catalog.grok,
5915
6327
  streamIdleTimeoutMs,
@@ -5958,6 +6370,10 @@ function apply(ctx, config) {
5958
6370
  if (tier === "standard") speedBySession.delete(sessionId);
5959
6371
  else speedBySession.set(sessionId, tier);
5960
6372
  }
6373
+ }, {
6374
+ get: () => proxyGetConfig(),
6375
+ set: (input) => proxySetConfig(input),
6376
+ test: (payload) => proxyTestConnection(payload.url, payload.proxy)
5961
6377
  });
5962
6378
  if (claudeTokens !== void 0) {
5963
6379
  const syncTimer = setInterval(() => {