dsh-plugin-subscriptions 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +42 -1
  2. package/README.zh.md +42 -1
  3. package/lib/auth/device-flow.d.ts +0 -9
  4. package/lib/auth/device-flow.js +2 -1
  5. package/lib/auth/rpc.d.ts +44 -13
  6. package/lib/auth/rpc.js +127 -9
  7. package/lib/auth/store.d.ts +75 -17
  8. package/lib/auth/store.js +148 -27
  9. package/lib/client/SubscriptionsSection.d.ts +26 -3
  10. package/lib/client/SubscriptionsSection.js +263 -67
  11. package/lib/client/index.js +11 -0
  12. package/lib/client/locales.d.ts +82 -10
  13. package/lib/client/locales.js +82 -10
  14. package/lib/client.js +837 -223
  15. package/lib/client.js.map +1 -1
  16. package/lib/http.d.ts +114 -0
  17. package/lib/http.js +402 -0
  18. package/lib/index.d.ts +21 -0
  19. package/lib/index.js +1938 -208
  20. package/lib/providers/accounts.d.ts +102 -0
  21. package/lib/providers/accounts.js +123 -0
  22. package/lib/providers/antigravity.d.ts +90 -0
  23. package/lib/providers/antigravity.js +392 -0
  24. package/lib/providers/claude.d.ts +22 -4
  25. package/lib/providers/claude.js +97 -16
  26. package/lib/providers/codex.d.ts +24 -3
  27. package/lib/providers/codex.js +121 -21
  28. package/lib/providers/common.d.ts +17 -0
  29. package/lib/providers/common.js +67 -3
  30. package/lib/providers/copilot.d.ts +23 -4
  31. package/lib/providers/copilot.js +99 -19
  32. package/lib/providers/grok.d.ts +24 -4
  33. package/lib/providers/grok.js +106 -19
  34. package/lib/providers/pool-family.d.ts +56 -0
  35. package/lib/providers/pool-family.js +45 -0
  36. package/lib/providers/pool-health.d.ts +74 -0
  37. package/lib/providers/pool-health.js +148 -0
  38. package/lib/providers/pool-usage.d.ts +57 -0
  39. package/lib/providers/pool-usage.js +130 -0
  40. package/lib/providers/pool.d.ts +107 -0
  41. package/lib/providers/pool.js +371 -0
  42. package/lib/tools/image-generate.d.ts +3 -3
  43. package/lib/tools/image-generate.js +4 -2
  44. package/lib/tools/video-generate.d.ts +2 -2
  45. package/lib/tools/video-generate.js +4 -2
  46. package/lib/tools/x-search.d.ts +2 -2
  47. package/lib/tools/x-search.js +4 -2
  48. package/lib/translate/antigravity.d.ts +110 -0
  49. package/lib/translate/antigravity.js +303 -0
  50. package/package.json +14 -9
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
+ }
459
+ /**
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.
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
+ }
255
495
  /**
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.
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.
263
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: {
@@ -599,6 +939,31 @@ const PROVIDER_IDS = [
599
939
  "copilot"
600
940
  ];
601
941
  /**
942
+ * The stable identity of one session's account: codex keys on the always
943
+ * present `accountId` claim, the others on their display identity, falling
944
+ * back to a refresh-token hash for sessions stored before identity fields
945
+ * existed. Logging the same account in again lands on the same key, so a
946
+ * re-login updates in place instead of duplicating. (The hash fallback can
947
+ * miss that dedup once for a legacy session re-logged with a now-known
948
+ * identity — the duplicate is visible on the Settings page and can simply
949
+ * be logged out.)
950
+ * @param provider - the provider route.
951
+ * @param session - the session to key.
952
+ * @returns the account map key.
953
+ */
954
+ function accountKeyOf(provider, session) {
955
+ switch (provider) {
956
+ case "codex": return session.accountId;
957
+ case "claude": return session.emailAddress ?? tokenHash(session.refreshToken);
958
+ case "grok": return session.account ?? tokenHash(session.refreshToken);
959
+ case "copilot": return session.account ?? tokenHash(session.refreshToken);
960
+ }
961
+ }
962
+ /** Short stable hash for sessions without an identity field. */
963
+ function tokenHash(refreshToken) {
964
+ return `token-${createHash("sha256").update(refreshToken).digest("hex").slice(0, 16)}`;
965
+ }
966
+ /**
602
967
  * Absolute path of the auth store file.
603
968
  * @returns `dshHomePath('plugins', 'subscriptions', 'auth.json')`.
604
969
  */
@@ -609,16 +974,17 @@ function authFilePath() {
609
974
  function legacyAuthFilePath() {
610
975
  return dshHomePath("plugins", "router", "auth.json");
611
976
  }
612
- /** Check that one durable entry carries the fields every session needs. */
613
- function assertSessionShape(provider, value) {
614
- if (typeof value !== "object" || value === null) throw new Error(`subscriptions auth store: entry "${provider}" is not an object; fix or delete the store file`);
977
+ /** Check that one durable session carries the fields every session needs. */
978
+ function assertSessionShape(provider, account, value) {
979
+ if (typeof value !== "object" || value === null) throw new Error(`subscriptions auth store: entry "${provider}/${account}" is not an object; fix or delete the store file`);
615
980
  const entry = value;
616
- if (typeof entry.accessToken !== "string" || entry.accessToken.length === 0 || typeof entry.refreshToken !== "string" || entry.refreshToken.length === 0 || typeof entry.expiresAt !== "number" || !Number.isFinite(entry.expiresAt)) throw new Error(`subscriptions auth store: entry "${provider}" is missing accessToken/refreshToken/expiresAt; fix or delete the store file`);
981
+ if (typeof entry.accessToken !== "string" || entry.accessToken.length === 0 || typeof entry.refreshToken !== "string" || entry.refreshToken.length === 0 || typeof entry.expiresAt !== "number" || !Number.isFinite(entry.expiresAt)) throw new Error(`subscriptions auth store: entry "${provider}/${account}" is missing accessToken/refreshToken/expiresAt; fix or delete the store file`);
617
982
  }
618
983
  /**
619
984
  * Read the whole store. A missing file is an empty store; malformed JSON or a
620
985
  * malformed entry throws, because silently discarding tokens would strand the
621
- * user without a diagnosis.
986
+ * user without a diagnosis. Single-account entries are migrated in memory;
987
+ * the next write persists the new shape.
622
988
  * @param path - store file path; defaults to {@link authFilePath}.
623
989
  * @returns the parsed session map.
624
990
  */
@@ -642,7 +1008,7 @@ async function loadStore(path = authFilePath()) {
642
1008
  }
643
1009
  return parseStore(text, path);
644
1010
  }
645
- /** Parse and validate store JSON read from `path`. */
1011
+ /** Parse, validate, and migrate store JSON read from `path`. */
646
1012
  function parseStore(text, path) {
647
1013
  let parsed;
648
1014
  try {
@@ -651,10 +1017,28 @@ function parseStore(text, path) {
651
1017
  throw new Error(`subscriptions auth store at ${path} is not valid JSON; fix or delete the file`);
652
1018
  }
653
1019
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`subscriptions auth store at ${path} must be a JSON object keyed by provider; fix or delete the file`);
654
- const store = parsed;
1020
+ const raw = parsed;
1021
+ const store = {};
655
1022
  for (const provider of PROVIDER_IDS) {
656
- const entry = store[provider];
657
- if (entry !== void 0) assertSessionShape(provider, entry);
1023
+ const entry = raw[provider];
1024
+ if (entry === void 0) continue;
1025
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new Error(`subscriptions auth store: entry "${provider}" is not an object; fix or delete the store file`);
1026
+ const record = entry;
1027
+ if (typeof record.accessToken === "string") {
1028
+ assertSessionShape(provider, "(legacy)", record);
1029
+ const session = record;
1030
+ const key = accountKeyOf(provider, session);
1031
+ store[provider] = {
1032
+ default: key,
1033
+ accounts: { [key]: session }
1034
+ };
1035
+ continue;
1036
+ }
1037
+ const accounts = record.accounts;
1038
+ if (typeof accounts !== "object" || accounts === null || Array.isArray(accounts)) throw new Error(`subscriptions auth store: entry "${provider}" has no accounts map; fix or delete the store file`);
1039
+ if (record.default !== void 0 && typeof record.default !== "string") throw new Error(`subscriptions auth store: entry "${provider}" default is not a string; fix or delete the store file`);
1040
+ for (const [account, session] of Object.entries(accounts)) assertSessionShape(provider, account, session);
1041
+ store[provider] = record;
658
1042
  }
659
1043
  return store;
660
1044
  }
@@ -674,8 +1058,8 @@ async function writeStore(store, path) {
674
1058
  /**
675
1059
  * One write chain per store path. Every mutation is a read-modify-write of a
676
1060
  * single JSON file, and the plugin has several independent writers — a login,
677
- * a logout, and one token refresh per provider adapter, each on its own
678
- * schedule. Overlapping them unserialized costs whichever provider read the
1061
+ * a logout, and one token refresh per provider account, each on its own
1062
+ * schedule. Overlapping them unserialized costs whichever account read the
679
1063
  * store first its entry.
680
1064
  *
681
1065
  * A chain is dropped once nothing is queued behind it, so the map holds an
@@ -700,37 +1084,94 @@ async function serialize(path, action) {
700
1084
  }
701
1085
  }
702
1086
  /**
703
- * Read one provider's session.
1087
+ * List one provider's accounts, default first.
704
1088
  * @param provider - the provider route.
705
1089
  * @param path - store file path; defaults to {@link authFilePath}.
706
- * @returns the stored session, or `undefined` when logged out.
1090
+ * @returns the account entries in stable order (empty when logged out).
707
1091
  */
708
- async function getSession(provider, path = authFilePath()) {
709
- return (await loadStore(path))[provider];
1092
+ async function listAccounts(provider, path = authFilePath()) {
1093
+ const entry = (await loadStore(path))[provider];
1094
+ if (entry === void 0) return [];
1095
+ const accounts = Object.entries(entry.accounts).map(([key, session]) => ({
1096
+ key,
1097
+ session
1098
+ }));
1099
+ accounts.sort((a, b) => Number(b.key === entry.default) - Number(a.key === entry.default));
1100
+ return accounts;
710
1101
  }
711
1102
  /**
712
- * Write one provider's session, preserving the others.
1103
+ * Read one account's session.
713
1104
  * @param provider - the provider route.
1105
+ * @param account - the account key; defaults to the provider's default account.
1106
+ * @param path - store file path; defaults to {@link authFilePath}.
1107
+ * @returns the stored session, or `undefined` when absent.
1108
+ */
1109
+ async function getAccountSession(provider, account, path = authFilePath()) {
1110
+ const entry = (await loadStore(path))[provider];
1111
+ if (entry === void 0) return void 0;
1112
+ const key = account ?? entry.default;
1113
+ if (key === void 0) return void 0;
1114
+ return entry.accounts[key];
1115
+ }
1116
+ /**
1117
+ * Write one account's session, preserving the others. The first account of a
1118
+ * provider becomes its default.
1119
+ * @param provider - the provider route.
1120
+ * @param account - the account key (see {@link accountKeyOf}).
714
1121
  * @param session - the fresh session from a login or refresh.
715
1122
  * @param path - store file path; defaults to {@link authFilePath}.
716
1123
  */
717
- async function saveSession(provider, session, path = authFilePath()) {
1124
+ async function saveAccountSession(provider, account, session, path = authFilePath()) {
718
1125
  return serialize(path, async () => {
719
1126
  const store = await loadStore(path);
720
- store[provider] = session;
1127
+ const entry = store[provider];
1128
+ store[provider] = {
1129
+ default: entry?.default ?? account,
1130
+ accounts: {
1131
+ ...entry?.accounts,
1132
+ [account]: session
1133
+ }
1134
+ };
721
1135
  await writeStore(store, path);
722
1136
  });
723
1137
  }
724
1138
  /**
725
- * Delete one provider's session (logout).
1139
+ * Delete one account's session (logout). Deleting the default moves the badge
1140
+ * to the next remaining account.
726
1141
  * @param provider - the provider route.
1142
+ * @param account - the account key.
727
1143
  * @param path - store file path; defaults to {@link authFilePath}.
728
1144
  */
729
- async function deleteSession(provider, path = authFilePath()) {
1145
+ async function deleteAccountSession(provider, account, path = authFilePath()) {
730
1146
  return serialize(path, async () => {
731
1147
  const store = await loadStore(path);
732
- if (store[provider] === void 0) return;
733
- delete store[provider];
1148
+ const entry = store[provider];
1149
+ if (entry === void 0 || !(account in entry.accounts)) return;
1150
+ const accounts = { ...entry.accounts };
1151
+ delete accounts[account];
1152
+ if (Object.keys(accounts).length === 0) delete store[provider];
1153
+ else store[provider] = {
1154
+ ...entry.default === account ? { default: Object.keys(accounts)[0] } : { default: entry.default },
1155
+ accounts
1156
+ };
1157
+ await writeStore(store, path);
1158
+ });
1159
+ }
1160
+ /**
1161
+ * Pin the account direct (non-pool) routes serve.
1162
+ * @param provider - the provider route.
1163
+ * @param account - the account key; must exist.
1164
+ * @param path - store file path; defaults to {@link authFilePath}.
1165
+ */
1166
+ async function setDefaultAccount(provider, account, path = authFilePath()) {
1167
+ return serialize(path, async () => {
1168
+ const store = await loadStore(path);
1169
+ const entry = store[provider];
1170
+ if (entry === void 0 || !(account in entry.accounts)) throw new Error(`no ${provider} account "${account}" is logged in`);
1171
+ store[provider] = {
1172
+ ...entry,
1173
+ default: account
1174
+ };
734
1175
  await writeStore(store, path);
735
1176
  });
736
1177
  }
@@ -786,6 +1227,14 @@ function readString(payload, field) {
786
1227
  if (typeof value !== "string" || value.length === 0) throw new BadRequest(`payload.${field} must be a non-empty string`);
787
1228
  return value;
788
1229
  }
1230
+ /** Validate the optional Claude login method. */
1231
+ function readLoginMethod(payload, provider) {
1232
+ const method = payload.method;
1233
+ if (method === void 0) return void 0;
1234
+ if (provider !== "claude") throw new BadRequest("payload.method is only valid for claude");
1235
+ if (method !== "oauth" && method !== "keychain") throw new BadRequest("payload.method must be \"oauth\" or \"keychain\"");
1236
+ return method;
1237
+ }
789
1238
  /** Validate the `setSpeed` endpoint's tier. */
790
1239
  function readSpeedTier(payload) {
791
1240
  const tier = payload.tier;
@@ -835,13 +1284,78 @@ function readSessionId(payload) {
835
1284
  if (typeof payload !== "object" || payload === null) throw new BadRequest("payload must be an object");
836
1285
  return readString(payload, "sessionId");
837
1286
  }
838
- async function dispatch(controller, speed, endpoint, payload, signal) {
1287
+ /** Validate a `proxySet` payload into a shape `ProxyInput` accepts. */
1288
+ function readProxyInput(payload) {
1289
+ if (typeof payload !== "object" || payload === null) throw new BadRequest("payload must be an object");
1290
+ const record = payload;
1291
+ if (typeof record.enabled !== "boolean") throw new BadRequest("payload.enabled must be a boolean");
1292
+ if (typeof record.url !== "string") throw new BadRequest("payload.url must be a string");
1293
+ let username;
1294
+ if (record.username !== void 0) {
1295
+ if (typeof record.username !== "string") throw new BadRequest("payload.username must be a string when present");
1296
+ username = record.username;
1297
+ }
1298
+ let password;
1299
+ if (record.password !== void 0) {
1300
+ if (record.password !== null && typeof record.password !== "string") throw new BadRequest("payload.password must be a string or null when present");
1301
+ password = record.password;
1302
+ }
1303
+ let bypass;
1304
+ if (record.bypass !== void 0) {
1305
+ 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");
1306
+ bypass = record.bypass;
1307
+ }
1308
+ return {
1309
+ enabled: record.enabled,
1310
+ url: record.url,
1311
+ ...username === void 0 ? {} : { username },
1312
+ ...password === void 0 ? {} : { password },
1313
+ ...bypass === void 0 ? {} : { bypass }
1314
+ };
1315
+ }
1316
+ /** Validate a `proxyTest` payload (the destination URL and an optional draft). */
1317
+ function readProxyTestPayload(payload) {
1318
+ if (typeof payload !== "object" || payload === null) return {};
1319
+ const record = payload;
1320
+ const url = record.url;
1321
+ if (url === void 0 && record.proxy === void 0) return {};
1322
+ if (url !== void 0 && (typeof url !== "string" || url.length === 0)) throw new BadRequest("payload.url must be a non-empty string when present");
1323
+ let proxy;
1324
+ if (record.proxy !== void 0) {
1325
+ if (typeof record.proxy !== "object" || record.proxy === null) throw new BadRequest("payload.proxy must be an object when present");
1326
+ const draftRecord = record.proxy;
1327
+ if (typeof draftRecord.url !== "string" || draftRecord.url.length === 0) throw new BadRequest("payload.proxy.url must be a non-empty string");
1328
+ let username;
1329
+ if (draftRecord.username !== void 0) {
1330
+ if (typeof draftRecord.username !== "string") throw new BadRequest("payload.proxy.username must be a string when present");
1331
+ username = draftRecord.username;
1332
+ }
1333
+ let password;
1334
+ if (draftRecord.password !== void 0) {
1335
+ if (typeof draftRecord.password !== "string") throw new BadRequest("payload.proxy.password must be a string when present");
1336
+ password = draftRecord.password;
1337
+ }
1338
+ proxy = {
1339
+ url: draftRecord.url,
1340
+ ...username === void 0 ? {} : { username },
1341
+ ...password === void 0 ? {} : { password }
1342
+ };
1343
+ }
1344
+ return {
1345
+ ...url === void 0 ? {} : { url },
1346
+ ...proxy === void 0 ? {} : { proxy }
1347
+ };
1348
+ }
1349
+ async function dispatch(controller, speed, proxy, endpoint, payload, signal) {
839
1350
  switch (endpoint) {
840
1351
  case "status": {
841
1352
  const entries = await Promise.all(PROVIDER_IDS.map(async (provider) => [provider, await controller.status(provider)]));
842
1353
  return ok({ providers: Object.fromEntries(entries) });
843
1354
  }
844
- case "login": return ok(await controller.login(readProvider(payload)));
1355
+ case "login": {
1356
+ const provider = readProvider(payload);
1357
+ return ok(await controller.login(provider, readLoginMethod(payload, provider)));
1358
+ }
845
1359
  case "manual": {
846
1360
  const provider = readProvider(payload);
847
1361
  await controller.manual(provider, readString(payload, "input"));
@@ -850,16 +1364,35 @@ async function dispatch(controller, speed, endpoint, payload, signal) {
850
1364
  case "cancel":
851
1365
  await controller.cancel(readProvider(payload));
852
1366
  return ok({ ok: true });
853
- case "logout":
854
- await controller.logout(readProvider(payload));
1367
+ case "logout": {
1368
+ const provider = readProvider(payload);
1369
+ await controller.logout(provider, readString(payload, "account"));
1370
+ return ok({ ok: true });
1371
+ }
1372
+ case "setDefault": {
1373
+ const provider = readProvider(payload);
1374
+ await controller.setDefault(provider, readString(payload, "account"));
855
1375
  return ok({ ok: true });
856
- case "usage": return ok(await controller.usage(readProvider(payload), signal));
1376
+ }
1377
+ case "usage": {
1378
+ const provider = readProvider(payload);
1379
+ return ok(await controller.usage(provider, readString(payload, "account"), signal));
1380
+ }
857
1381
  case "image": return ok(await controller.readImage(readImageRef(payload), signal));
858
1382
  case "video": return ok(await controller.readVideo(readVideoName(payload), signal));
859
1383
  case "speed": return ok(await speed.speed(readSessionId(payload)));
860
1384
  case "setSpeed":
861
1385
  await speed.setSpeed(readSessionId(payload), readSpeedTier(payload));
862
1386
  return ok({ ok: true });
1387
+ case "proxyGet":
1388
+ if (proxy === void 0) throw new BadRequest("proxy configuration is unavailable");
1389
+ return ok(await proxy.get());
1390
+ case "proxySet":
1391
+ if (proxy === void 0) throw new BadRequest("proxy configuration is unavailable");
1392
+ return ok(await proxy.set(readProxyInput(payload)));
1393
+ case "proxyTest":
1394
+ if (proxy === void 0) throw new BadRequest("proxy configuration is unavailable");
1395
+ return ok(await proxy.test(readProxyTestPayload(payload)));
863
1396
  default: throw new BadRequest(`unknown /subscriptions-auth endpoint "${endpoint}"`);
864
1397
  }
865
1398
  }
@@ -868,13 +1401,14 @@ async function dispatch(controller, speed, endpoint, payload, signal) {
868
1401
  * @param ctx - the plugin context (headless profiles have no `connection`).
869
1402
  * @param controller - the auth operations backing the endpoints.
870
1403
  * @param speed - the per-session speed-tier state backing the Speed toggle.
1404
+ * @param proxy - optional proxy-config controller backing `proxyGet`/`proxySet`/`proxyTest`.
871
1405
  */
872
- function registerAuthRpc(ctx, controller, speed) {
1406
+ function registerAuthRpc(ctx, controller, speed, proxy = void 0) {
873
1407
  ctx.inject(["connection"], (ctx$1) => {
874
1408
  const connection = ctx$1.get("connection");
875
1409
  ctx$1.effect(() => connection.rpc.handle(SUBSCRIPTIONS_AUTH_CHANNEL, async (endpoint, payload, signal) => {
876
1410
  try {
877
- return await dispatch(controller, speed, endpoint, payload, signal);
1411
+ return await dispatch(controller, speed, proxy, endpoint, payload, signal);
878
1412
  } catch (error) {
879
1413
  return failure(error);
880
1414
  }
@@ -1075,13 +1609,41 @@ var TokenManager = class {
1075
1609
  }
1076
1610
  }
1077
1611
  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);
1612
+ const current$1 = await this.options.load();
1613
+ if (current$1 !== void 0 && current$1.accessToken !== session.accessToken && current$1.expiresAt - Date.now() > this.options.preemptMs) return current$1;
1614
+ const next = await this.options.refresh(current$1 ?? session);
1081
1615
  await this.options.save(next);
1082
1616
  return next;
1083
1617
  }
1084
1618
  };
1619
+ /** Bound on one account catalog fetch or usage poll — a hang must not block the picker. */
1620
+ const DISCOVERY_TIMEOUT_MS = 1e4;
1621
+ /**
1622
+ * Run `work` with an aborting signal. Resolves undefined when the timeout
1623
+ * fires (the fetch is aborted); other failures propagate.
1624
+ */
1625
+ function withTimeout(work, timeoutMs) {
1626
+ const signal = AbortSignal.timeout(timeoutMs);
1627
+ const aborted = new Promise((resolve) => {
1628
+ if (signal.aborted) resolve(void 0);
1629
+ else signal.addEventListener("abort", () => resolve(void 0), { once: true });
1630
+ });
1631
+ return Promise.race([work(signal).then((value) => signal.aborted ? void 0 : value, (error) => {
1632
+ if (signal.aborted) return void 0;
1633
+ throw error;
1634
+ }), aborted]);
1635
+ }
1636
+ /**
1637
+ * First account catalog that lists `model` (callers pass default-first).
1638
+ * One failing lookup sits that account out so a sibling's metadata still
1639
+ * resolves — the same isolation as the picker catalog union.
1640
+ */
1641
+ async function discoverAcrossAccounts(accounts, lookup) {
1642
+ for (const account of accounts) try {
1643
+ const found = await lookup(account);
1644
+ if (found !== void 0) return found;
1645
+ } catch {}
1646
+ }
1085
1647
  /** How long a discovered catalog is trusted before re-fetching. */
1086
1648
  const DISCOVERY_TTL_MS = 5 * 6e4;
1087
1649
  /**
@@ -1103,6 +1665,8 @@ var ModelCatalogCache = class {
1103
1665
  seeded;
1104
1666
  /** Set by {@link invalidate} so an in-flight disk read cannot resurrect dropped state. */
1105
1667
  seedDisabled = false;
1668
+ /** Bumped by {@link invalidate} so a loser in-flight fetch cannot write back. */
1669
+ generation = 0;
1106
1670
  constructor(persistence, ttlMs = DISCOVERY_TTL_MS) {
1107
1671
  this.persistence = persistence;
1108
1672
  this.ttlMs = ttlMs;
@@ -1133,7 +1697,10 @@ var ModelCatalogCache = class {
1133
1697
  }
1134
1698
  /** Run (or join) the single in-flight fetch, updating memory and disk on success. */
1135
1699
  refresh(fetcher) {
1136
- this.inflight ??= fetcher().then((models) => {
1700
+ if (this.inflight !== void 0) return this.inflight;
1701
+ const gen = this.generation;
1702
+ const pending = fetcher().then((models) => {
1703
+ if (this.generation !== gen) return models;
1137
1704
  const snapshot = {
1138
1705
  at: Date.now(),
1139
1706
  models
@@ -1142,9 +1709,10 @@ var ModelCatalogCache = class {
1142
1709
  this.persistence?.save(snapshot).catch(() => void 0);
1143
1710
  return models;
1144
1711
  }).finally(() => {
1145
- this.inflight = void 0;
1712
+ if (this.generation === gen) this.inflight = void 0;
1146
1713
  });
1147
- return this.inflight;
1714
+ this.inflight = pending;
1715
+ return pending;
1148
1716
  }
1149
1717
  /**
1150
1718
  * Return the cached catalog when fresh, otherwise fetch and cache it.
@@ -1182,7 +1750,9 @@ var ModelCatalogCache = class {
1182
1750
  }
1183
1751
  /** Drop the cached catalog (e.g. after a 401 proved the credential changed). */
1184
1752
  invalidate() {
1753
+ this.generation += 1;
1185
1754
  this.entry = void 0;
1755
+ this.inflight = void 0;
1186
1756
  this.seedDisabled = true;
1187
1757
  this.persistence?.clear().catch(() => void 0);
1188
1758
  }
@@ -1191,6 +1761,11 @@ var ModelCatalogCache = class {
1191
1761
  function isMissingOrInvalidCredential(error) {
1192
1762
  return error instanceof LlmError && (error.code === "MISSING_CREDENTIAL" || error.code === "INVALID_CREDENTIAL");
1193
1763
  }
1764
+ /** Whether discovery stopped because the caller cancelled or the timeout fired. */
1765
+ function isDiscoveryAborted(error, signal) {
1766
+ if (signal?.aborted === true) return true;
1767
+ return signal !== void 0 && error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
1768
+ }
1194
1769
  /** Whether discovery failed because the access token was rejected. */
1195
1770
  function isDiscoveryAuthFailure(error) {
1196
1771
  return error instanceof OAuthEndpointError && error.status === 401 || error instanceof LlmError && error.code === "AUTH";
@@ -1216,6 +1791,109 @@ async function discoverOrRetryAuth(session, catalog, run) {
1216
1791
  }
1217
1792
  }
1218
1793
 
1794
+ //#endregion
1795
+ //#region src/providers/accounts.ts
1796
+ /** Catalog sort hint when the provider advertised one (Codex `priority`). */
1797
+ function catalogPriority(model) {
1798
+ const ranked = model;
1799
+ return typeof ranked.priority === "number" ? ranked.priority : Number.MAX_SAFE_INTEGER;
1800
+ }
1801
+ /**
1802
+ * Merge per-account catalogs, keeping the first occurrence of each model id.
1803
+ * Rows that carry a numeric `priority` (Codex discovery) are then ordered by
1804
+ * it so a model only the second account lists — e.g. `gpt-5.6-sol` — still
1805
+ * sits with its generation instead of being appended after the default
1806
+ * account's older ids.
1807
+ */
1808
+ async function unionAccountCatalogs(accounts, listOne, options) {
1809
+ const timeoutMs = options?.timeoutMs;
1810
+ const caller = options?.signal;
1811
+ const catalogs = await Promise.all(accounts.map(async (account) => {
1812
+ try {
1813
+ if (timeoutMs === void 0) return await listOne(account, caller);
1814
+ return await withTimeout((timeoutSignal) => listOne(account, caller === void 0 ? timeoutSignal : AbortSignal.any([timeoutSignal, caller])), timeoutMs) ?? [];
1815
+ } catch (error) {
1816
+ if (caller?.aborted === true) throw error;
1817
+ return [];
1818
+ }
1819
+ }));
1820
+ const seen = /* @__PURE__ */ new Set();
1821
+ const models = [];
1822
+ for (const catalog of catalogs) for (const model of catalog) {
1823
+ if (seen.has(model.id)) continue;
1824
+ seen.add(model.id);
1825
+ models.push(model);
1826
+ }
1827
+ models.sort((left, right) => catalogPriority(left) - catalogPriority(right));
1828
+ return models;
1829
+ }
1830
+ var AccountTokenManager = class {
1831
+ managers = /* @__PURE__ */ new Map();
1832
+ io;
1833
+ constructor(options) {
1834
+ this.options = options;
1835
+ const provider = options.provider;
1836
+ this.io = options.io ?? {
1837
+ list: () => listAccounts(provider),
1838
+ get: (account) => getAccountSession(provider, account),
1839
+ save: (account, session) => saveAccountSession(provider, account, session),
1840
+ remove: (account) => deleteAccountSession(provider, account)
1841
+ };
1842
+ }
1843
+ /** The provider's accounts, default first (straight from the store). */
1844
+ list() {
1845
+ return this.io.list();
1846
+ }
1847
+ /** The default account's key, or undefined when logged out. */
1848
+ async defaultAccount() {
1849
+ return (await this.list())[0]?.key;
1850
+ }
1851
+ /**
1852
+ * Resolve a usable session for one account (default when omitted),
1853
+ * refreshing proactively or on demand.
1854
+ * @param account - the account key; the default account when undefined.
1855
+ * @param forceRefresh - refresh regardless of expiry (used after a 401).
1856
+ * @returns the persisted session to send.
1857
+ * @throws LlmError MISSING_CREDENTIAL when the account is not logged in.
1858
+ */
1859
+ async session(account, forceRefresh = false) {
1860
+ const key = account ?? await this.defaultAccount();
1861
+ if (key === void 0) throw this.missingCredential();
1862
+ return this.tokensFor(key).session(forceRefresh);
1863
+ }
1864
+ /** Read an account's stored session without any refresh side effect. */
1865
+ peek(account) {
1866
+ return this.io.get(account);
1867
+ }
1868
+ /** Whether a session is stored for the account (cheap; never refreshes). */
1869
+ async hasSession(account) {
1870
+ return await this.peek(account) !== void 0;
1871
+ }
1872
+ /** The TokenManager bound to one account (created lazily, then cached). */
1873
+ tokensFor(account) {
1874
+ let manager = this.managers.get(account);
1875
+ if (manager === void 0) {
1876
+ const io = this.io;
1877
+ manager = new TokenManager({
1878
+ displayName: this.options.displayName,
1879
+ ...this.options.makeOptions(account),
1880
+ load: () => io.get(account),
1881
+ save: (session) => io.save(account, session),
1882
+ remove: () => io.remove(account),
1883
+ onRemoved: () => {
1884
+ this.options.onAccountRemoved?.(account);
1885
+ }
1886
+ });
1887
+ this.managers.set(account, manager);
1888
+ }
1889
+ return manager;
1890
+ }
1891
+ /** The logged-out error, mirroring TokenManager's own message. */
1892
+ missingCredential() {
1893
+ return new LlmError(`dsh-plugin-subscriptions: not logged in to ${this.options.displayName}; log in via Settings → Subscriptions in the dsh web app`, "MISSING_CREDENTIAL");
1894
+ }
1895
+ };
1896
+
1219
1897
  //#endregion
1220
1898
  //#region src/providers/catalog-store.ts
1221
1899
  /**
@@ -1360,6 +2038,616 @@ function catalogStore(provider, path = modelsFilePath()) {
1360
2038
  };
1361
2039
  }
1362
2040
 
2041
+ //#endregion
2042
+ //#region src/providers/pool-family.ts
2043
+ /** Map key for one provider's pool of one model (ids collide across providers). */
2044
+ function poolKey(provider, model) {
2045
+ return `${provider}/${model}`;
2046
+ }
2047
+ /**
2048
+ * Build per-provider account routes. Each model id becomes a definition of
2049
+ * the accounts that list it: two or more fail over; one is pinned to that
2050
+ * account (so a Max-only model is never sent to a Plus login). The picker
2051
+ * unions these catalogs; a logout that drops a model to one account keeps
2052
+ * the same id and pins it to whoever remains.
2053
+ * @param sources - per-account catalogs (providers with no accounts list
2054
+ * nothing and simply never join a pool).
2055
+ * @returns `provider/model` → pool definition (not listed as an extra entry).
2056
+ */
2057
+ function buildAccountPools(sources) {
2058
+ const pools = /* @__PURE__ */ new Map();
2059
+ for (const [provider, source] of Object.entries(sources)) {
2060
+ const byModel = /* @__PURE__ */ new Map();
2061
+ for (const catalog of source.catalogs) for (const model of catalog.models) {
2062
+ let entry = byModel.get(model.id);
2063
+ if (entry === void 0) {
2064
+ entry = {
2065
+ members: [],
2066
+ info: model
2067
+ };
2068
+ byModel.set(model.id, entry);
2069
+ }
2070
+ entry.members.push({
2071
+ provider,
2072
+ account: catalog.account,
2073
+ model: model.id
2074
+ });
2075
+ }
2076
+ for (const [id, { members, info }] of byModel) pools.set(poolKey(provider, id), {
2077
+ members,
2078
+ ...info.name === void 0 || info.name === id ? {} : { name: info.name },
2079
+ ...info.description === void 0 ? {} : { description: info.description }
2080
+ });
2081
+ }
2082
+ return pools;
2083
+ }
2084
+
2085
+ //#endregion
2086
+ //#region src/providers/pool-health.ts
2087
+ /** Registry key for one pool member. */
2088
+ function memberKey(provider, account, model) {
2089
+ return `${provider}/${account}/${model}`;
2090
+ }
2091
+ /** Registry key parking EVERY member of one account (account-level failures). */
2092
+ function accountKey(provider, account) {
2093
+ return `${provider}/${account}/*`;
2094
+ }
2095
+ /** Default cooldown when a quota/rate failure carries no `retry-after`. */
2096
+ const DEFAULT_QUOTA_COOLDOWN_MS = 5 * 6e4;
2097
+ /** Auth failures recheck after a day; a re-login clears the record immediately. */
2098
+ const AUTH_COOLDOWN_MS = 1440 * 6e4;
2099
+ /** Transient server-side failures cool down briefly. */
2100
+ const TRANSIENT_COOLDOWN_MS = 6e4;
2101
+ /**
2102
+ * Providers whose quota windows are model-scoped, so a quota failure on one
2103
+ * model says nothing about its siblings (Claude's Opus/Sonnet lanes). Every
2104
+ * other provider meters the account as a whole: one member hitting the wall
2105
+ * means its siblings on the SAME account would too, so the cooldown parks
2106
+ * the account (other accounts of the provider are unaffected).
2107
+ */
2108
+ const MODEL_SCOPED_QUOTA_PROVIDERS = new Set(["claude"]);
2109
+ /** The `retry-after` an adapter propagated through `httpLlmError`, when any. */
2110
+ function retryAfterMs(error) {
2111
+ return error.failure.providerRetryAfterMs;
2112
+ }
2113
+ /**
2114
+ * Classify a member failure. Quota and rate-limit failures cool down (using
2115
+ * the provider's own `retry-after` when sent, which is more accurate than
2116
+ * any fixed guess) — account-wide for account-metered providers, per-member
2117
+ * for model-scoped ones; auth failures park the account until re-login
2118
+ * (credentials are account-level); server/timeout failures get a short
2119
+ * per-member cooldown; transport failures switch without a record;
2120
+ * everything else — most importantly CONTEXT_WINDOW_EXCEEDED and ABORTED —
2121
+ * is the request's own fault and is rethrown untouched.
2122
+ * @param error - the failure thrown by a member adapter's stream.
2123
+ * @param provider - the failing member's provider (decides the quota scope).
2124
+ * @returns the action the pool should take.
2125
+ */
2126
+ function classifyPoolFailure(error, provider) {
2127
+ if (!(error instanceof LlmError)) return { action: "throw" };
2128
+ switch (error.code) {
2129
+ case QUOTA_EXCEEDED_CODE:
2130
+ case "RATE_LIMIT": return {
2131
+ action: "switch",
2132
+ cooldownMs: retryAfterMs(error) ?? DEFAULT_QUOTA_COOLDOWN_MS,
2133
+ reason: error.code,
2134
+ scope: MODEL_SCOPED_QUOTA_PROVIDERS.has(provider) ? "member" : "account"
2135
+ };
2136
+ case "AUTH":
2137
+ case "INVALID_CREDENTIAL":
2138
+ case "MISSING_CREDENTIAL": return {
2139
+ action: "switch",
2140
+ cooldownMs: AUTH_COOLDOWN_MS,
2141
+ reason: error.code,
2142
+ scope: "account"
2143
+ };
2144
+ case "SERVER":
2145
+ case "TIMEOUT":
2146
+ case "EMPTY_RESPONSE": return {
2147
+ action: "switch",
2148
+ cooldownMs: TRANSIENT_COOLDOWN_MS,
2149
+ reason: error.code,
2150
+ scope: "member"
2151
+ };
2152
+ case "TRANSPORT": return { action: "switch" };
2153
+ case "HTTP_402":
2154
+ case "HTTP_404": return {
2155
+ action: "switch",
2156
+ cooldownMs: TRANSIENT_COOLDOWN_MS,
2157
+ reason: error.code,
2158
+ scope: "member"
2159
+ };
2160
+ case CONTEXT_WINDOW_EXCEEDED_CODE:
2161
+ case "ABORTED":
2162
+ default: return { action: "throw" };
2163
+ }
2164
+ }
2165
+ /**
2166
+ * Cooldown registry keyed by {@link memberKey}. A member whose cooldown has
2167
+ * expired is simply available again — recovery is proven by the next real
2168
+ * request, not by a background probe.
2169
+ */
2170
+ var PoolHealthRegistry = class {
2171
+ records = /* @__PURE__ */ new Map();
2172
+ /** Whether a member may serve: neither it nor its whole account is cooling. */
2173
+ isMemberAvailable(provider, account, model, now = Date.now()) {
2174
+ return this.isAvailable(accountKey(provider, account), now) && this.isAvailable(memberKey(provider, account, model), now);
2175
+ }
2176
+ /** Whether one registry key is clear right now. */
2177
+ isAvailable(key, now = Date.now()) {
2178
+ const record = this.records.get(key);
2179
+ if (record === void 0) return true;
2180
+ if (record.unavailableUntil <= now) {
2181
+ this.records.delete(key);
2182
+ return true;
2183
+ }
2184
+ return false;
2185
+ }
2186
+ /** Park a member for `cooldownMs`; a longer existing cooldown wins. */
2187
+ markUnavailable(key, cooldownMs, reason, now = Date.now()) {
2188
+ const until = now + cooldownMs;
2189
+ const existing = this.records.get(key);
2190
+ if (existing !== void 0 && existing.unavailableUntil > until) return;
2191
+ this.records.set(key, {
2192
+ unavailableUntil: until,
2193
+ reason
2194
+ });
2195
+ }
2196
+ /**
2197
+ * Epoch ms at which the earliest cooling record among `keys` recovers;
2198
+ * `undefined` when none of them is cooling. The registry is shared by
2199
+ * every pool, so the caller passes the keys of ITS members (member and
2200
+ * account keys alike) — an unrelated pool's cooldown must not shape this
2201
+ * pool's retry hint. Feeds the pool-exhausted error's
2202
+ * `providerRetryAfterMs`.
2203
+ */
2204
+ earliestRecovery(keys, now = Date.now()) {
2205
+ let earliest;
2206
+ for (const [key, record] of this.records) {
2207
+ if (record.unavailableUntil <= now) {
2208
+ this.records.delete(key);
2209
+ continue;
2210
+ }
2211
+ if (!keys.has(key)) continue;
2212
+ if (earliest === void 0 || record.unavailableUntil < earliest) earliest = record.unavailableUntil;
2213
+ }
2214
+ return earliest;
2215
+ }
2216
+ /** Drop records of one provider, or of a single account when given (auth changes). */
2217
+ clear(provider, account) {
2218
+ const prefix = account === void 0 ? `${provider}/` : `${provider}/${account}/`;
2219
+ for (const key of [...this.records.keys()]) if (key.startsWith(prefix)) this.records.delete(key);
2220
+ }
2221
+ };
2222
+
2223
+ //#endregion
2224
+ //#region src/providers/pool.ts
2225
+ /** Bound on sticky-session memory; oldest entries evict past it. */
2226
+ const STICKY_SESSION_LIMIT = 1e3;
2227
+ /** Display form of one member (account shown when pinned). */
2228
+ function memberLabel(member) {
2229
+ return member.account === void 0 ? `${member.provider}/${member.model}` : `${member.provider}/${member.account}/${member.model}`;
2230
+ }
2231
+ /** How long a pools snapshot is trusted (auth changes invalidate immediately). */
2232
+ const POOLS_CACHE_TTL_MS = 5e3;
2233
+ var PoolAdapter = class extends LlmAdapter {
2234
+ /** sessionId|poolId → member key of the last member that served a chunk. */
2235
+ sticky = /* @__PURE__ */ new Map();
2236
+ /** Messages already warned about — configuration diagnostics repeat every request otherwise. */
2237
+ warned = /* @__PURE__ */ new Set();
2238
+ /**
2239
+ * Short-lived pools snapshot. `owns()` runs on every resolveModel — the
2240
+ * model picker issues one per entry — and pool assembly touches every
2241
+ * provider's catalog and account store, so recompute at most this often.
2242
+ * Auth changes bump {@link generation} so a stale snapshot cannot land.
2243
+ */
2244
+ poolsCache;
2245
+ poolsInflight;
2246
+ generation = 0;
2247
+ constructor(options) {
2248
+ super();
2249
+ this.options = options;
2250
+ }
2251
+ /** Drop the pools snapshot so the next read reflects the current accounts. */
2252
+ invalidate() {
2253
+ this.generation += 1;
2254
+ this.poolsCache = void 0;
2255
+ this.poolsInflight = void 0;
2256
+ }
2257
+ /** Warn once per distinct message (pools() runs on every request). */
2258
+ warnOnce(message) {
2259
+ if (this.warned.has(message)) return;
2260
+ this.warned.add(message);
2261
+ this.options.onWarn(message);
2262
+ }
2263
+ /** Drop members whose adapter is not registered (copy — caller state is shared). */
2264
+ usable(pools) {
2265
+ const result = new Map(pools);
2266
+ for (const [id, definition] of [...result]) {
2267
+ const kept = definition.members.filter((member) => this.options.adapters[member.provider] !== void 0);
2268
+ if (kept.length === 0) result.delete(id);
2269
+ else if (kept.length < definition.members.length) result.set(id, {
2270
+ ...definition,
2271
+ members: kept
2272
+ });
2273
+ }
2274
+ return result;
2275
+ }
2276
+ /** Account pools (auto-aggregated plus config overrides) with usable members. */
2277
+ async familyPools() {
2278
+ return this.usable(new Map(await this.options.families()));
2279
+ }
2280
+ /** All pools (account pools merged with extra tiers) with usable members. */
2281
+ async pools() {
2282
+ const cached = this.poolsCache;
2283
+ if (cached !== void 0 && Date.now() - cached.at < POOLS_CACHE_TTL_MS) return cached.pools;
2284
+ const gen = this.generation;
2285
+ this.poolsInflight ??= this.assemblePools().then((pools) => {
2286
+ if (this.generation === gen) this.poolsCache = {
2287
+ at: Date.now(),
2288
+ pools
2289
+ };
2290
+ return pools;
2291
+ }).finally(() => {
2292
+ this.poolsInflight = void 0;
2293
+ });
2294
+ return this.poolsInflight;
2295
+ }
2296
+ /** Recompute the pools snapshot (account pools merged with extra tiers). */
2297
+ async assemblePools() {
2298
+ const pools = await this.familyPools();
2299
+ for (const [id, members] of Object.entries(this.options.tiers)) {
2300
+ if (members.length === 0) continue;
2301
+ const owner = members[0].provider;
2302
+ const key = poolKey(owner, id);
2303
+ if (pools.has(key)) this.warnOnce(`tier pool "${id}" overrides the account pool of the same id under ${owner}`);
2304
+ pools.set(key, {
2305
+ members,
2306
+ extra: true
2307
+ });
2308
+ }
2309
+ return this.usable(pools);
2310
+ }
2311
+ /**
2312
+ * Extra picker rows one provider lists (configured tiers). Account pools
2313
+ * reuse the catalog entry of the same wire id, so they are not listed
2314
+ * again — the picker stays one row per model in ChatGPT / Claude / ….
2315
+ */
2316
+ async modelsForProvider(provider) {
2317
+ const pools = await this.pools();
2318
+ const models = [];
2319
+ for (const [key, definition] of pools) {
2320
+ if (definition.extra !== true) continue;
2321
+ if (!key.startsWith(`${provider}/`)) continue;
2322
+ const id = key.slice(provider.length + 1);
2323
+ models.push({
2324
+ provider,
2325
+ id,
2326
+ name: definition.name ?? id,
2327
+ ...definition.description === void 0 ? {} : { description: definition.description }
2328
+ });
2329
+ }
2330
+ return models;
2331
+ }
2332
+ /**
2333
+ * Whether `model` on `provider`'s route is served here (several accounts
2334
+ * fail over, one account is pinned, or a configured tier).
2335
+ */
2336
+ async owns(provider, model) {
2337
+ return (await this.pools()).has(poolKey(provider, model));
2338
+ }
2339
+ /**
2340
+ * Resolve every member's account (config members may omit it to mean "the
2341
+ * default account") and drop members with no resolvable login. Duplicates
2342
+ * collapse — an explicitly pinned account and the default may coincide.
2343
+ */
2344
+ async concrete(members) {
2345
+ const seen = /* @__PURE__ */ new Set();
2346
+ const resolved = [];
2347
+ for (const member of members) {
2348
+ const account = member.account ?? await this.options.defaultAccount(member.provider);
2349
+ if (account === void 0) continue;
2350
+ const key = memberKey(member.provider, account, member.model);
2351
+ if (seen.has(key)) continue;
2352
+ seen.add(key);
2353
+ resolved.push({
2354
+ provider: member.provider,
2355
+ account,
2356
+ model: member.model
2357
+ });
2358
+ }
2359
+ return resolved;
2360
+ }
2361
+ /**
2362
+ * Resolve a pool model to the conservative INTERSECTION of its members'
2363
+ * capabilities: the smallest context window and output cap, the reasoning
2364
+ * efforts every member supports, and the modalities all of them accept —
2365
+ * so a request valid for the pool stays valid after a failover. Capability
2366
+ * metadata is provider-level, so each provider resolves once regardless of
2367
+ * how many accounts it pools.
2368
+ */
2369
+ async resolveModel(provider, model) {
2370
+ const definition = (await this.pools()).get(poolKey(provider, model));
2371
+ if (definition === void 0) throw new LlmError(`unknown pool model "${model}"`, "NO_ADAPTER");
2372
+ const resolved = [];
2373
+ let lastFailure;
2374
+ const seenProviders = /* @__PURE__ */ new Set();
2375
+ for (const member of definition.members) {
2376
+ if (seenProviders.has(member.provider)) continue;
2377
+ seenProviders.add(member.provider);
2378
+ const adapter = this.options.adapters[member.provider];
2379
+ if (adapter === void 0) continue;
2380
+ try {
2381
+ resolved.push(await adapter.resolveOwnModel(member.provider, member.model));
2382
+ } catch (error) {
2383
+ lastFailure = error;
2384
+ this.warnOnce(`pool "${model}": member ${memberLabel(member)} failed to resolve (${error instanceof Error ? error.message : String(error)}); excluding it`);
2385
+ }
2386
+ }
2387
+ if (resolved.length === 0) throw new LlmError(`pool "${model}" has no usable member`, "NO_ADAPTER", { ...lastFailure === void 0 ? {} : { cause: lastFailure } });
2388
+ const contextWindows = resolved.map((info) => info.context?.contextWindow).filter(isNumber);
2389
+ const maxTokens = resolved.map((info) => info.defaultMaxTokens).filter(isNumber);
2390
+ const reasoning = intersectReasoning(resolved);
2391
+ const modalities = intersectModalities(resolved);
2392
+ return {
2393
+ provider,
2394
+ id: model,
2395
+ name: definition.name ?? model,
2396
+ ...definition.description === void 0 ? {} : { description: definition.description },
2397
+ ...contextWindows.length > 0 ? { context: { contextWindow: Math.min(...contextWindows) } } : {},
2398
+ ...maxTokens.length > 0 ? { defaultMaxTokens: Math.min(...maxTokens) } : {},
2399
+ ...reasoning === void 0 ? {} : { reasoning },
2400
+ ...modalities === void 0 ? {} : { inputModalities: modalities }
2401
+ };
2402
+ }
2403
+ async *stream(options) {
2404
+ const definition = (await this.pools()).get(poolKey(options.provider, options.model));
2405
+ if (definition === void 0) throw new LlmError(`unknown pool model "${options.model}"`, "NO_ADAPTER");
2406
+ const members = await this.concrete(definition.members);
2407
+ const candidates = await this.select(options.model, members, options.sessionId);
2408
+ if (candidates.length === 0) throw this.exhausted(options.model, members);
2409
+ let lastError;
2410
+ for (const member of candidates) {
2411
+ const adapter = this.options.adapters[member.provider];
2412
+ if (adapter === void 0) continue;
2413
+ const iterator = adapter.streamAccount({
2414
+ ...options,
2415
+ provider: member.provider,
2416
+ model: member.model
2417
+ }, member.account)[Symbol.asyncIterator]();
2418
+ let first;
2419
+ try {
2420
+ first = await iterator.next();
2421
+ if (first.done === true) throw new LlmError(`${memberLabel(member)} returned an empty stream`, EMPTY_RESPONSE_CODE);
2422
+ } catch (error) {
2423
+ const classification = classifyPoolFailure(error, member.provider);
2424
+ if (classification.action === "throw") throw error;
2425
+ if ("cooldownMs" in classification) {
2426
+ this.options.health.markUnavailable(classification.scope === "account" ? accountKey(member.provider, member.account) : memberKey(member.provider, member.account, member.model), classification.cooldownMs, classification.reason);
2427
+ if (classification.reason === QUOTA_EXCEEDED_CODE || classification.reason === "RATE_LIMIT") this.options.usage.invalidate(member.provider, member.account);
2428
+ }
2429
+ this.options.onWarn(`pool "${options.model}": ${memberLabel(member)} failed before any output (${error instanceof Error ? error.message : String(error)}); trying the next member`);
2430
+ lastError = error;
2431
+ continue;
2432
+ }
2433
+ this.remember(options.model, options.sessionId, member);
2434
+ try {
2435
+ yield first.value;
2436
+ for (let next = await iterator.next(); next.done !== true; next = await iterator.next()) yield next.value;
2437
+ } finally {
2438
+ try {
2439
+ await iterator.return?.();
2440
+ } catch {}
2441
+ }
2442
+ return;
2443
+ }
2444
+ throw this.exhausted(options.model, members, lastError);
2445
+ }
2446
+ /**
2447
+ * Order the candidates for one request. Health filters both strategies;
2448
+ * `quota_aware` then ranks by urgency (members without telemetry, e.g.
2449
+ * copilot, score zero and sink to the bottom of their class), while
2450
+ * quota-exhausted members stay as a last-resort tail in pool order. The
2451
+ * sticky member keeps its lead unless a challenger out-scores it by
2452
+ * `switchMargin`.
2453
+ */
2454
+ async select(poolId, members, sessionId) {
2455
+ const usable = members.filter((member) => this.options.adapters[member.provider] !== void 0 && this.options.health.isMemberAvailable(member.provider, member.account, member.model));
2456
+ if (usable.length === 0) return [];
2457
+ const stickyMember = sessionId === void 0 ? void 0 : usable.find((member) => memberKey(member.provider, member.account, member.model) === this.sticky.get(stickyKey(poolId, sessionId)));
2458
+ if (this.options.strategy === "priority") return stickyMember === void 0 ? usable : [stickyMember, ...usable.filter((member) => member !== stickyMember)];
2459
+ const quotas = new Map(await Promise.all(usable.map(async (member) => [member, await this.options.usage.quotaFor(member)])));
2460
+ const scored = usable.filter((member) => quotas.get(member)?.available === true);
2461
+ const quotaFull = usable.filter((member) => quotas.get(member)?.available === false);
2462
+ scored.sort((a, b) => (quotas.get(b)?.urgency ?? 0) - (quotas.get(a)?.urgency ?? 0));
2463
+ if (stickyMember !== void 0 && scored.includes(stickyMember)) {
2464
+ const best = scored[0];
2465
+ const stickyUrgency = quotas.get(stickyMember)?.urgency ?? 0;
2466
+ const bestUrgency = quotas.get(best)?.urgency ?? 0;
2467
+ if (best === stickyMember || bestUrgency <= stickyUrgency * this.options.switchMargin) {
2468
+ scored.splice(scored.indexOf(stickyMember), 1);
2469
+ scored.unshift(stickyMember);
2470
+ }
2471
+ }
2472
+ return [...scored, ...quotaFull];
2473
+ }
2474
+ /** Pin the serving member to the session (with bounded memory). */
2475
+ remember(poolId, sessionId, member) {
2476
+ if (sessionId === void 0) return;
2477
+ const key = stickyKey(poolId, sessionId);
2478
+ this.sticky.delete(key);
2479
+ if (this.sticky.size >= STICKY_SESSION_LIMIT) {
2480
+ const oldest = this.sticky.keys().next();
2481
+ if (oldest.done !== true) this.sticky.delete(oldest.value);
2482
+ }
2483
+ this.sticky.set(key, memberKey(member.provider, member.account, member.model));
2484
+ }
2485
+ /**
2486
+ * The error for an exhausted pool, carrying the earliest recovery hint of
2487
+ * THIS pool's members (the health registry is shared across pools, so the
2488
+ * hint is scoped to the keys this pool can actually recover through).
2489
+ */
2490
+ exhausted(model, pool, cause) {
2491
+ const keys = /* @__PURE__ */ new Set();
2492
+ for (const member of pool) {
2493
+ keys.add(memberKey(member.provider, member.account, member.model));
2494
+ keys.add(accountKey(member.provider, member.account));
2495
+ }
2496
+ const recovery = this.options.health.earliestRecovery(keys);
2497
+ const retryAfterMs$1 = recovery === void 0 ? void 0 : Math.max(recovery - Date.now(), 1);
2498
+ return new LlmError(`pool "${model}" exhausted: every member is unavailable or failed`, "RATE_LIMIT", {
2499
+ ...retryAfterMs$1 === void 0 ? {} : { providerRetryAfterMs: retryAfterMs$1 },
2500
+ ...cause === void 0 ? {} : { cause }
2501
+ });
2502
+ }
2503
+ };
2504
+ function stickyKey(poolId, sessionId) {
2505
+ return `${String(sessionId)}|${poolId}`;
2506
+ }
2507
+ function isNumber(value) {
2508
+ return value !== void 0;
2509
+ }
2510
+ /** Reasoning efforts every member supports (id intersection, first member's order). */
2511
+ function intersectReasoning(resolved) {
2512
+ const [first, ...rest] = resolved;
2513
+ if (first?.reasoning === void 0) return void 0;
2514
+ const efforts = first.reasoning.efforts.filter((effort) => rest.every((info) => info.reasoning?.efforts.some((other) => other.id === effort.id) === true));
2515
+ if (efforts.length === 0) return void 0;
2516
+ const defaultEffort = first.reasoning.defaultEffort !== void 0 && efforts.some((effort) => effort.id === first.reasoning?.defaultEffort) ? first.reasoning.defaultEffort : void 0;
2517
+ return {
2518
+ efforts,
2519
+ ...defaultEffort === void 0 ? {} : { defaultEffort }
2520
+ };
2521
+ }
2522
+ /** Modalities all members accept; undefined when any member leaves it unknown. */
2523
+ function intersectModalities(resolved) {
2524
+ const [first, ...rest] = resolved;
2525
+ if (first?.inputModalities === void 0) return void 0;
2526
+ const modalities = first.inputModalities.filter((modality) => rest.every((info) => info.inputModalities?.includes(modality) === true));
2527
+ return modalities.length === 0 ? void 0 : modalities;
2528
+ }
2529
+
2530
+ //#endregion
2531
+ //#region src/providers/pool-usage.ts
2532
+ /** A member is taken out of rotation once any window crosses this fill level. */
2533
+ const QUOTA_FULL_PERCENT = 95;
2534
+ /** How long a usage snapshot is trusted before a background refresh. */
2535
+ const USAGE_TTL_MS = 5 * 6e4;
2536
+ /** Assumed window length when the provider discloses no `resetsAt`. */
2537
+ const FALLBACK_HORIZON_MS = {
2538
+ session: 300 * 6e4,
2539
+ weekly: 10080 * 6e4,
2540
+ other: 720 * 60 * 6e4
2541
+ };
2542
+ /**
2543
+ * Per-ACCOUNT usage snapshots with in-flight dedupe and
2544
+ * stale-while-revalidate refresh. Providers without a usage endpoint
2545
+ * (copilot) resolve no fetcher and score a constant zero urgency — which
2546
+ * naturally ranks them behind every measured member. Fetchers are resolved
2547
+ * lazily per (provider, account) so accounts added after startup join
2548
+ * tracking on their first score.
2549
+ */
2550
+ var PoolUsageTracker = class {
2551
+ entries = /* @__PURE__ */ new Map();
2552
+ inflight = /* @__PURE__ */ new Map();
2553
+ constructor(fetcherFor, ttlMs = USAGE_TTL_MS) {
2554
+ this.fetcherFor = fetcherFor;
2555
+ this.ttlMs = ttlMs;
2556
+ }
2557
+ /**
2558
+ * The quota view of one member. A cold cache awaits the first fetch; a
2559
+ * stale one answers immediately while the refresh serves the NEXT call
2560
+ * (member selection must never block on the network mid-conversation).
2561
+ * @param member - the pool member to score (account resolved).
2562
+ * @returns availability plus the urgency score.
2563
+ */
2564
+ async quotaFor(member) {
2565
+ const key = `${member.provider}/${member.account}`;
2566
+ const fetcher = this.fetcherFor(member.provider, member.account);
2567
+ if (fetcher === void 0) return {
2568
+ available: true,
2569
+ urgency: 0,
2570
+ fetchedAt: 0
2571
+ };
2572
+ const entry = this.entries.get(key);
2573
+ if (entry !== void 0 && Date.now() - entry.at < this.ttlMs) return this.score(member, entry);
2574
+ if (entry !== void 0) {
2575
+ this.refresh(key, fetcher).catch(() => void 0);
2576
+ return this.score(member, entry);
2577
+ }
2578
+ try {
2579
+ const snapshot = await this.refresh(key, fetcher);
2580
+ return this.score(member, {
2581
+ snapshot,
2582
+ at: Date.now()
2583
+ });
2584
+ } catch (error) {
2585
+ return isMissingOrInvalidCredential(error) ? {
2586
+ available: false,
2587
+ urgency: 0,
2588
+ fetchedAt: 0
2589
+ } : {
2590
+ available: true,
2591
+ urgency: 0,
2592
+ fetchedAt: 0
2593
+ };
2594
+ }
2595
+ }
2596
+ /** Drop cached snapshots: one account, or a whole provider when `account` is omitted. */
2597
+ invalidate(provider, account) {
2598
+ if (account !== void 0) {
2599
+ this.entries.delete(`${provider}/${account}`);
2600
+ return;
2601
+ }
2602
+ for (const key of [...this.entries.keys()]) if (key.startsWith(`${provider}/`)) this.entries.delete(key);
2603
+ }
2604
+ /** Run (or join) the single in-flight fetch for one account key. */
2605
+ refresh(key, fetcher) {
2606
+ let pending = this.inflight.get(key);
2607
+ if (pending === void 0) {
2608
+ pending = fetcher().then((snapshot) => {
2609
+ this.entries.set(key, {
2610
+ snapshot,
2611
+ at: Date.now()
2612
+ });
2613
+ return snapshot;
2614
+ }).finally(() => {
2615
+ this.inflight.delete(key);
2616
+ });
2617
+ this.inflight.set(key, pending);
2618
+ }
2619
+ return pending;
2620
+ }
2621
+ /** Score one member against a snapshot's windows. */
2622
+ score(member, entry) {
2623
+ const windows = (entry.snapshot.windows ?? []).filter((window) => windowApplies(window, member.model));
2624
+ let available = true;
2625
+ let urgency = 0;
2626
+ for (const window of windows) {
2627
+ if (window.usedPercent >= QUOTA_FULL_PERCENT) available = false;
2628
+ urgency = Math.max(urgency, windowUrgency(window));
2629
+ }
2630
+ return {
2631
+ available,
2632
+ urgency,
2633
+ fetchedAt: entry.at
2634
+ };
2635
+ }
2636
+ };
2637
+ /**
2638
+ * Whether a window constrains this model: unscoped windows always do; a
2639
+ * model-scoped window (Claude's Opus/Sonnet lanes) applies when its scope
2640
+ * names the model family.
2641
+ */
2642
+ function windowApplies(window, model) {
2643
+ if (window.scope === void 0) return true;
2644
+ return model.toLowerCase().includes(window.scope.toLowerCase());
2645
+ }
2646
+ /** The required burn rate of one window (fraction per ms). */
2647
+ function windowUrgency(window, now = Date.now()) {
2648
+ return Math.max(0, 1 - window.usedPercent / 100) / (window.resetsAt !== void 0 ? Math.max(window.resetsAt - now, 1) : FALLBACK_HORIZON_MS[window.kind]);
2649
+ }
2650
+
1363
2651
  //#endregion
1364
2652
  //#region src/auth/jwt.ts
1365
2653
  /** Minimal JWT payload decoding for claims extraction (no signature verification). */
@@ -1960,7 +3248,7 @@ function codexSession(tokens, fallback) {
1960
3248
  * @returns the session to store.
1961
3249
  */
1962
3250
  async function exchangeCodexCode(code, verifier, redirectUri) {
1963
- const response = await fetch(CODEX_TOKEN_URL, {
3251
+ const response = await proxiedFetch(CODEX_TOKEN_URL, {
1964
3252
  method: "POST",
1965
3253
  headers: { "content-type": "application/x-www-form-urlencoded" },
1966
3254
  body: new URLSearchParams({
@@ -1980,7 +3268,7 @@ async function exchangeCodexCode(code, verifier, redirectUri) {
1980
3268
  * @returns the fresh session to store.
1981
3269
  */
1982
3270
  async function refreshCodex(session) {
1983
- const response = await fetch(CODEX_TOKEN_URL, {
3271
+ const response = await proxiedFetch(CODEX_TOKEN_URL, {
1984
3272
  method: "POST",
1985
3273
  headers: { "content-type": "application/json" },
1986
3274
  body: JSON.stringify({
@@ -2048,7 +3336,7 @@ function codexUsageWindow(value, fallbackKind) {
2048
3336
  * @param signal - caller cancellation from the RPC transport.
2049
3337
  * @returns the mapped usage snapshot.
2050
3338
  */
2051
- async function fetchCodexUsage(session, fetchFn = fetch, signal) {
3339
+ async function fetchCodexUsage(session, fetchFn = proxiedFetch, signal) {
2052
3340
  const response = await fetchFn(CODEX_USAGE_URL, {
2053
3341
  headers: {
2054
3342
  "authorization": `Bearer ${session.accessToken}`,
@@ -2096,16 +3384,20 @@ function supportsFastTier(entry) {
2096
3384
  * Fetch the live codex model catalog with the session's auth headers.
2097
3385
  * @param session - the stored session (used as-is; never refreshed here).
2098
3386
  * @param fetchFn - fetch implementation (injectable for tests).
3387
+ * @param signal - caller cancellation (pool-assembly timeout).
2099
3388
  * @returns discovered models: hidden entries dropped, sorted by priority.
2100
3389
  */
2101
- async function fetchCodexModels(session, fetchFn = fetch) {
2102
- const response = await fetchFn(`${CODEX_MODELS_URL}?client_version=${CODEX_CLIENT_VERSION}`, { headers: {
2103
- "authorization": `Bearer ${session.accessToken}`,
2104
- "chatgpt-account-id": session.accountId,
2105
- "originator": "codex_cli_rs",
2106
- "accept": "application/json",
2107
- ...attributionHeaders()
2108
- } });
3390
+ async function fetchCodexModels(session, fetchFn = proxiedFetch, signal) {
3391
+ const response = await fetchFn(`${CODEX_MODELS_URL}?client_version=${CODEX_CLIENT_VERSION}`, {
3392
+ headers: {
3393
+ "authorization": `Bearer ${session.accessToken}`,
3394
+ "chatgpt-account-id": session.accountId,
3395
+ "originator": "codex_cli_rs",
3396
+ "accept": "application/json",
3397
+ ...attributionHeaders()
3398
+ },
3399
+ ...signal === void 0 ? {} : { signal }
3400
+ });
2109
3401
  if (!response.ok) throw await oauthEndpointError(response, "codex models");
2110
3402
  const payload = await response.json();
2111
3403
  if (!Array.isArray(payload.models)) throw new Error("codex models endpoint returned no models array");
@@ -2209,14 +3501,43 @@ function codexRequestBody(options, resolved, fast) {
2209
3501
  /** Codex wire adapter: one instance serves the `codex` provider route. */
2210
3502
  var CodexAdapter = class extends LlmAdapter {
2211
3503
  catalog;
3504
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
3505
+ accountCatalogs = /* @__PURE__ */ new Map();
3506
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
3507
+ catalogOwner;
2212
3508
  constructor(options) {
2213
3509
  super();
2214
3510
  this.options = options;
2215
3511
  this.catalog = new ModelCatalogCache(options.catalogStore);
2216
3512
  }
2217
3513
  /** Discovery fetcher: resolves the session through the refresh-aware path. */
2218
- async fetchCatalog() {
2219
- return fetchCodexModels(await this.options.tokens.session(), this.options.fetchFn);
3514
+ async fetchCatalog(account, signal) {
3515
+ return fetchCodexModels(await this.options.tokens.session(account), this.options.fetchFn, signal);
3516
+ }
3517
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
3518
+ clearAccountCatalog(account) {
3519
+ if (account === void 0) this.accountCatalogs.clear();
3520
+ else this.accountCatalogs.delete(account);
3521
+ if (account === void 0 || this.catalogOwner === account || this.catalogOwner === void 0) {
3522
+ this.catalogOwner = void 0;
3523
+ this.catalog.invalidate();
3524
+ }
3525
+ }
3526
+ /** Persisted cache for the default account; a throwaway cache for any other. */
3527
+ async catalogFor(account) {
3528
+ const defaultKey = await this.options.tokens.defaultAccount();
3529
+ const key = account ?? defaultKey;
3530
+ if (key === void 0 || key === defaultKey) {
3531
+ if (this.catalogOwner !== void 0 && this.catalogOwner !== defaultKey) this.catalog.invalidate();
3532
+ this.catalogOwner = defaultKey;
3533
+ return this.catalog;
3534
+ }
3535
+ let cache = this.accountCatalogs.get(key);
3536
+ if (cache === void 0) {
3537
+ cache = new ModelCatalogCache();
3538
+ this.accountCatalogs.set(key, cache);
3539
+ }
3540
+ return cache;
2220
3541
  }
2221
3542
  providerInfo(provider) {
2222
3543
  return {
@@ -2233,17 +3554,37 @@ var CodexAdapter = class extends LlmAdapter {
2233
3554
  }));
2234
3555
  }
2235
3556
  async listModels(provider) {
2236
- if (await this.options.tokens.peek() === void 0) return [];
3557
+ const own = await this.listOwnModels(provider);
3558
+ const pool = this.options.pool?.();
3559
+ if (pool === void 0) return own;
3560
+ const extra = await pool.modelsForProvider(provider);
3561
+ const seen = new Set(own.map((model) => model.id));
3562
+ return [...own, ...extra.filter((model) => !seen.has(model.id))];
3563
+ }
3564
+ /** The provider's own catalog: union of every account, or one account when named. */
3565
+ async listOwnModels(provider, account, signal) {
3566
+ if (account === void 0) {
3567
+ const accounts = (await this.options.tokens.list()).map((entry) => entry.key);
3568
+ if (accounts.length === 0) return [];
3569
+ return unionAccountCatalogs(accounts, (key, accountSignal) => this.listOwnModels(provider, key, accountSignal), {
3570
+ timeoutMs: this.options.discoveryTimeoutMs ?? DISCOVERY_TIMEOUT_MS,
3571
+ ...signal === void 0 ? {} : { signal }
3572
+ });
3573
+ }
3574
+ if (!await this.options.tokens.hasSession(account)) return [];
2237
3575
  if (!this.options.discovery) return this.staticModels(provider);
3576
+ const catalog = await this.catalogFor(account);
2238
3577
  try {
2239
- return (await discoverOrRetryAuth((force) => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog()))).map((model) => ({
3578
+ return (await discoverOrRetryAuth((force) => this.options.tokens.session(account, force), catalog, () => catalog.get(() => this.fetchCatalog(account, signal)))).map((model) => ({
2240
3579
  provider,
2241
3580
  id: model.id,
2242
3581
  name: model.name,
2243
3582
  ...model.description === void 0 ? {} : { description: model.description },
2244
- inputModalities: CODEX_MODALITIES
3583
+ inputModalities: CODEX_MODALITIES,
3584
+ ...model.priority === void 0 ? {} : { priority: model.priority }
2245
3585
  }));
2246
3586
  } catch (error) {
3587
+ if (isDiscoveryAborted(error, signal)) throw error;
2247
3588
  if (isMissingOrInvalidCredential(error)) return [];
2248
3589
  this.options.onWarn?.(`codex model discovery failed; using the built-in catalog (${errorChain(error)})`);
2249
3590
  return this.staticModels(provider);
@@ -2258,7 +3599,9 @@ var CodexAdapter = class extends LlmAdapter {
2258
3599
  */
2259
3600
  async discovered(model) {
2260
3601
  if (!this.options.discovery) return void 0;
2261
- return (await this.catalog.resolve(() => this.fetchCatalog()))?.find((entry) => entry.id === model);
3602
+ return discoverAcrossAccounts((await this.options.tokens.list()).map((entry) => entry.key), async (account) => {
3603
+ return (await (await this.catalogFor(account)).resolve(() => this.fetchCatalog(account)))?.find((entry) => entry.id === model);
3604
+ });
2262
3605
  }
2263
3606
  /** Whether the discovered catalog advertises a fast tier for this model. */
2264
3607
  async supportsFastTier(model) {
@@ -2267,10 +3610,27 @@ var CodexAdapter = class extends LlmAdapter {
2267
3610
  /** Ids of every discovered model with a fast tier (the Speed toggle's visibility list). */
2268
3611
  async fastCapableModels() {
2269
3612
  if (!this.options.discovery) return [];
2270
- if (await this.options.tokens.peek() === void 0) return [];
2271
- return (await this.catalog.resolve(() => this.fetchCatalog()) ?? []).filter((model) => model.fastTier === true).map((model) => model.id);
3613
+ const accounts = (await this.options.tokens.list()).map((entry) => entry.key);
3614
+ if (accounts.length === 0) return [];
3615
+ const seen = /* @__PURE__ */ new Set();
3616
+ const ids = [];
3617
+ for (const account of accounts) try {
3618
+ const models = await (await this.catalogFor(account)).resolve(() => this.fetchCatalog(account));
3619
+ for (const model of models ?? []) {
3620
+ if (model.fastTier !== true || seen.has(model.id)) continue;
3621
+ seen.add(model.id);
3622
+ ids.push(model.id);
3623
+ }
3624
+ } catch {}
3625
+ return ids;
2272
3626
  }
2273
3627
  async resolveModel(provider, model) {
3628
+ const pool = this.options.pool?.();
3629
+ if (pool !== void 0 && await pool.owns(provider, model)) return pool.resolveModel(provider, model);
3630
+ return this.resolveOwnModel(provider, model);
3631
+ }
3632
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
3633
+ async resolveOwnModel(provider, model) {
2274
3634
  const discovered = await this.discovered(model);
2275
3635
  const configured = this.options.models.find((entry) => entry.id === model);
2276
3636
  return {
@@ -2288,12 +3648,24 @@ var CodexAdapter = class extends LlmAdapter {
2288
3648
  };
2289
3649
  }
2290
3650
  async *stream(options) {
3651
+ const pool = this.options.pool?.();
3652
+ if (pool !== void 0 && await pool.owns(options.provider, options.model)) {
3653
+ yield* pool.stream(options);
3654
+ return;
3655
+ }
3656
+ yield* this.streamCore(options);
3657
+ }
3658
+ /** Pool seam: stream through one specific account instead of the default. */
3659
+ streamAccount(options, account) {
3660
+ return this.streamCore(options, account);
3661
+ }
3662
+ async *streamCore(options, account) {
2291
3663
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
2292
3664
  try {
2293
- let session = await this.options.tokens.session();
3665
+ let session = await this.options.tokens.session(account);
2294
3666
  let response = await this.request(options, session, watchdog.signal);
2295
3667
  if (response.status === 401) {
2296
- session = await this.options.tokens.session(true);
3668
+ session = await this.options.tokens.session(account, true);
2297
3669
  response = await this.request(options, session, watchdog.signal);
2298
3670
  }
2299
3671
  if (!response.ok) throw await httpLlmError(response, "codex API");
@@ -2311,7 +3683,7 @@ var CodexAdapter = class extends LlmAdapter {
2311
3683
  const messages = await resolveImages(options.messages, this.options.resolveAttachments?.(), signal);
2312
3684
  const fast = this.options.speedFor !== void 0 && await this.options.speedFor(options.sessionId, options.model);
2313
3685
  const body = codexRequestBody(options, toResponsesInput(messages, options.system), fast);
2314
- return fetch(CODEX_API_URL, {
3686
+ return proxiedFetch(CODEX_API_URL, {
2315
3687
  method: "POST",
2316
3688
  headers: {
2317
3689
  "authorization": `Bearer ${session.accessToken}`,
@@ -2846,7 +4218,7 @@ const claudeFlow = {
2846
4218
  /** Best-effort account profile; login must not fail when this does. */
2847
4219
  async function fetchClaudeProfile(accessToken) {
2848
4220
  try {
2849
- const response = await fetch(CLAUDE_PROFILE_URL, { headers: { authorization: `Bearer ${accessToken}` } });
4221
+ const response = await proxiedFetch(CLAUDE_PROFILE_URL, { headers: { authorization: `Bearer ${accessToken}` } });
2850
4222
  if (!response.ok) return {};
2851
4223
  const profile = await response.json();
2852
4224
  const account = typeof profile.account === "object" && profile.account !== null ? profile.account : {};
@@ -2884,7 +4256,7 @@ async function claudeSession(tokens, fallbackRefreshToken, withProfile) {
2884
4256
  * @returns the session to store.
2885
4257
  */
2886
4258
  async function exchangeClaudeCode(code, verifier, redirectUri, state) {
2887
- const response = await fetch(CLAUDE_TOKEN_URL, {
4259
+ const response = await proxiedFetch(CLAUDE_TOKEN_URL, {
2888
4260
  method: "POST",
2889
4261
  headers: { "content-type": "application/json" },
2890
4262
  body: JSON.stringify({
@@ -2905,7 +4277,7 @@ async function exchangeClaudeCode(code, verifier, redirectUri, state) {
2905
4277
  * @returns the fresh session to store.
2906
4278
  */
2907
4279
  async function refreshClaude(session) {
2908
- const response = await fetch(CLAUDE_TOKEN_URL, {
4280
+ const response = await proxiedFetch(CLAUDE_TOKEN_URL, {
2909
4281
  method: "POST",
2910
4282
  headers: { "content-type": "application/json" },
2911
4283
  body: JSON.stringify({
@@ -2980,7 +4352,7 @@ function claudeLimitsWindows(value) {
2980
4352
  * @param signal - caller cancellation from the RPC transport.
2981
4353
  * @returns the mapped usage snapshot.
2982
4354
  */
2983
- async function fetchClaudeUsage(session, fetchFn = fetch, signal) {
4355
+ async function fetchClaudeUsage(session, fetchFn = proxiedFetch, signal) {
2984
4356
  const response = await fetchFn(CLAUDE_USAGE_URL, {
2985
4357
  headers: {
2986
4358
  "authorization": `Bearer ${session.accessToken}`,
@@ -3032,15 +4404,18 @@ function claudeReasoning(capabilities) {
3032
4404
  }));
3033
4405
  return efforts.length > 0 ? { efforts } : void 0;
3034
4406
  }
3035
- /** Fetch the live model catalog from the subscription endpoint. */
3036
- async function fetchClaudeModels(session, fetchFn = fetch) {
3037
- const response = await fetchFn(CLAUDE_MODELS_URL, { headers: {
3038
- "authorization": `Bearer ${session.accessToken}`,
3039
- "anthropic-version": "2023-06-01",
3040
- "user-agent": getClaudeCliUserAgent(),
3041
- "anthropic-dangerous-direct-browser-access": "true",
3042
- "accept": "application/json"
3043
- } });
4407
+ /** Fetch the live model catalog from the subscription endpoint. `signal` cancels the request. */
4408
+ async function fetchClaudeModels(session, fetchFn = proxiedFetch, signal) {
4409
+ const response = await fetchFn(CLAUDE_MODELS_URL, {
4410
+ headers: {
4411
+ "authorization": `Bearer ${session.accessToken}`,
4412
+ "anthropic-version": "2023-06-01",
4413
+ "user-agent": getClaudeCliUserAgent(),
4414
+ "anthropic-dangerous-direct-browser-access": "true",
4415
+ "accept": "application/json"
4416
+ },
4417
+ ...signal === void 0 ? {} : { signal }
4418
+ });
3044
4419
  if (!response.ok) throw await httpLlmError(response, "claude models API");
3045
4420
  const payload = await response.json();
3046
4421
  if (!Array.isArray(payload.data)) throw new Error("claude models API returned an invalid catalog");
@@ -3100,17 +4475,48 @@ function claudeRequestBody(options, messages, maxTokens, thinking, effort) {
3100
4475
  /** Claude wire adapter: one instance serves the `claude` provider route. */
3101
4476
  var ClaudeAdapter = class extends LlmAdapter {
3102
4477
  catalog;
4478
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
4479
+ accountCatalogs = /* @__PURE__ */ new Map();
4480
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
4481
+ catalogOwner;
3103
4482
  constructor(options) {
3104
4483
  super();
3105
4484
  this.options = options;
3106
4485
  this.catalog = new ModelCatalogCache(options.catalogStore);
3107
4486
  }
3108
- async fetchCatalog() {
3109
- return fetchClaudeModels(await this.options.tokens.session(), this.options.fetchFn);
4487
+ async fetchCatalog(account, signal) {
4488
+ return fetchClaudeModels(await this.options.tokens.session(account), this.options.fetchFn, signal);
4489
+ }
4490
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
4491
+ clearAccountCatalog(account) {
4492
+ if (account === void 0) this.accountCatalogs.clear();
4493
+ else this.accountCatalogs.delete(account);
4494
+ if (account === void 0 || this.catalogOwner === account || this.catalogOwner === void 0) {
4495
+ this.catalogOwner = void 0;
4496
+ this.catalog.invalidate();
4497
+ }
4498
+ }
4499
+ /** Persisted cache for the default account; a throwaway cache for any other. */
4500
+ async catalogFor(account) {
4501
+ const defaultKey = await this.options.tokens.defaultAccount();
4502
+ const key = account ?? defaultKey;
4503
+ if (key === void 0 || key === defaultKey) {
4504
+ if (this.catalogOwner !== void 0 && this.catalogOwner !== defaultKey) this.catalog.invalidate();
4505
+ this.catalogOwner = defaultKey;
4506
+ return this.catalog;
4507
+ }
4508
+ let cache = this.accountCatalogs.get(key);
4509
+ if (cache === void 0) {
4510
+ cache = new ModelCatalogCache();
4511
+ this.accountCatalogs.set(key, cache);
4512
+ }
4513
+ return cache;
3110
4514
  }
3111
4515
  async discovered(model) {
3112
4516
  if (!this.options.discovery) return void 0;
3113
- return (await this.catalog.resolve(() => this.fetchCatalog()))?.find((entry) => entry.id === model);
4517
+ return discoverAcrossAccounts((await this.options.tokens.list()).map((entry) => entry.key), async (account) => {
4518
+ return (await (await this.catalogFor(account)).resolve(() => this.fetchCatalog(account)))?.find((entry) => entry.id === model);
4519
+ });
3114
4520
  }
3115
4521
  staticModels(provider) {
3116
4522
  return this.options.models.map((model) => ({
@@ -3139,22 +4545,47 @@ var ClaudeAdapter = class extends LlmAdapter {
3139
4545
  }, `claude: provider "${provider}" retryPolicy`);
3140
4546
  }
3141
4547
  async listModels(provider) {
3142
- if (await this.options.tokens.peek() === void 0) return [];
4548
+ const own = await this.listOwnModels(provider);
4549
+ const pool = this.options.pool?.();
4550
+ if (pool === void 0) return own;
4551
+ const extra = await pool.modelsForProvider(provider);
4552
+ const seen = new Set(own.map((model) => model.id));
4553
+ return [...own, ...extra.filter((model) => !seen.has(model.id))];
4554
+ }
4555
+ /** The provider's own catalog: union of every account, or one account when named. */
4556
+ async listOwnModels(provider, account, signal) {
4557
+ if (account === void 0) {
4558
+ const accounts = (await this.options.tokens.list()).map((entry) => entry.key);
4559
+ if (accounts.length === 0) return [];
4560
+ return unionAccountCatalogs(accounts, (key, accountSignal) => this.listOwnModels(provider, key, accountSignal), {
4561
+ timeoutMs: DISCOVERY_TIMEOUT_MS,
4562
+ ...signal === void 0 ? {} : { signal }
4563
+ });
4564
+ }
4565
+ if (!await this.options.tokens.hasSession(account)) return [];
3143
4566
  if (!this.options.discovery) return this.staticModels(provider);
4567
+ const catalog = await this.catalogFor(account);
3144
4568
  try {
3145
- return (await discoverOrRetryAuth((force) => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog()))).map((model) => ({
4569
+ return (await discoverOrRetryAuth((force) => this.options.tokens.session(account, force), catalog, () => catalog.get(() => this.fetchCatalog(account, signal)))).map((model) => ({
3146
4570
  provider,
3147
4571
  id: model.id,
3148
4572
  name: model.name,
3149
4573
  inputModalities: CLAUDE_MODALITIES
3150
4574
  }));
3151
4575
  } catch (error) {
4576
+ if (isDiscoveryAborted(error, signal)) throw error;
3152
4577
  if (isMissingOrInvalidCredential(error)) return [];
3153
4578
  this.options.onWarn?.(`claude model discovery failed; using the built-in catalog (${errorChain(error)})`);
3154
4579
  return this.staticModels(provider);
3155
4580
  }
3156
4581
  }
3157
4582
  async resolveModel(provider, model) {
4583
+ const pool = this.options.pool?.();
4584
+ if (pool !== void 0 && await pool.owns(provider, model)) return pool.resolveModel(provider, model);
4585
+ return this.resolveOwnModel(provider, model);
4586
+ }
4587
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
4588
+ async resolveOwnModel(provider, model) {
3158
4589
  const disc = await this.discovered(model);
3159
4590
  const configured = this.options.models.find((entry) => entry.id === model);
3160
4591
  const reasoning = disc?.reasoning;
@@ -3169,12 +4600,24 @@ var ClaudeAdapter = class extends LlmAdapter {
3169
4600
  };
3170
4601
  }
3171
4602
  async *stream(options) {
4603
+ const pool = this.options.pool?.();
4604
+ if (pool !== void 0 && await pool.owns(options.provider, options.model)) {
4605
+ yield* pool.stream(options);
4606
+ return;
4607
+ }
4608
+ yield* this.streamCore(options);
4609
+ }
4610
+ /** Pool seam: stream through one specific account instead of the default. */
4611
+ streamAccount(options, account) {
4612
+ return this.streamCore(options, account);
4613
+ }
4614
+ async *streamCore(options, account) {
3172
4615
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
3173
4616
  try {
3174
- let session = await this.options.tokens.session();
4617
+ let session = await this.options.tokens.session(account);
3175
4618
  let response = await this.request(options, session, watchdog.signal);
3176
4619
  if (response.status === 401) {
3177
- session = await this.options.tokens.session(true);
4620
+ session = await this.options.tokens.session(account, true);
3178
4621
  response = await this.request(options, session, watchdog.signal);
3179
4622
  }
3180
4623
  if (!response.ok) throw await httpLlmError(response, "claude API");
@@ -3215,7 +4658,7 @@ var ClaudeAdapter = class extends LlmAdapter {
3215
4658
  const maxTokens = options.maxTokens ?? this.options.models.find((entry) => entry.id === options.model)?.maxTokens ?? CLAUDE_DEFAULT_MAX_TOKENS;
3216
4659
  const disc = await this.discovered(options.model);
3217
4660
  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, {
4661
+ return proxiedFetch(CLAUDE_API_URL, {
3219
4662
  method: "POST",
3220
4663
  headers: {
3221
4664
  "authorization": `Bearer ${session.accessToken}`,
@@ -3262,7 +4705,7 @@ let discoveryCache;
3262
4705
  */
3263
4706
  async function grokDiscovery() {
3264
4707
  if (discoveryCache !== void 0) return discoveryCache;
3265
- const response = await fetch(GROK_DISCOVERY_URL);
4708
+ const response = await proxiedFetch(GROK_DISCOVERY_URL);
3266
4709
  if (!response.ok) throw await oauthEndpointError(response, "grok OIDC discovery");
3267
4710
  const document = await response.json();
3268
4711
  if (typeof document.authorization_endpoint !== "string" || typeof document.token_endpoint !== "string") throw new Error("grok OIDC discovery document is missing endpoints");
@@ -3363,7 +4806,7 @@ function grokSession(tokens, tokenEndpoint, fallbackRefreshToken) {
3363
4806
  */
3364
4807
  async function exchangeGrokCode(code, verifier, redirectUri, challenge) {
3365
4808
  const discovery = await grokDiscovery();
3366
- const response = await fetch(discovery.tokenEndpoint, {
4809
+ const response = await proxiedFetch(discovery.tokenEndpoint, {
3367
4810
  method: "POST",
3368
4811
  headers: { "content-type": "application/x-www-form-urlencoded" },
3369
4812
  body: new URLSearchParams({
@@ -3386,7 +4829,7 @@ async function exchangeGrokCode(code, verifier, redirectUri, challenge) {
3386
4829
  * @returns the fresh session to store.
3387
4830
  */
3388
4831
  async function refreshGrok(session) {
3389
- const response = await fetch(session.tokenEndpoint, {
4832
+ const response = await proxiedFetch(session.tokenEndpoint, {
3390
4833
  method: "POST",
3391
4834
  headers: { "content-type": "application/x-www-form-urlencoded" },
3392
4835
  body: new URLSearchParams({
@@ -3433,7 +4876,7 @@ function grokResetsAt(value) {
3433
4876
  * @param signal - caller cancellation from the RPC transport.
3434
4877
  * @returns the mapped usage snapshot.
3435
4878
  */
3436
- async function fetchGrokUsage(session, fetchFn = fetch, signal) {
4879
+ async function fetchGrokUsage(session, fetchFn = proxiedFetch, signal) {
3437
4880
  const response = await fetchFn(GROK_BILLING_URL, {
3438
4881
  headers: {
3439
4882
  "authorization": `Bearer ${session.accessToken}`,
@@ -3506,15 +4949,19 @@ function grokCliReasoning(entry) {
3506
4949
  * Fetch the CLI catalog and index its per-model metadata by model id.
3507
4950
  * @param session - the stored session (used as-is; never refreshed here).
3508
4951
  * @param fetchFn - fetch implementation (injectable for tests).
4952
+ * @param signal - caller cancellation (pool-assembly timeout).
3509
4953
  * @returns model id → contributed metadata.
3510
4954
  */
3511
- async function fetchGrokCliCatalog(session, fetchFn = fetch) {
3512
- const response = await fetchFn(GROK_CLI_MODELS_URL, { headers: {
3513
- "authorization": `Bearer ${session.accessToken}`,
3514
- "x-xai-token-auth": "xai-grok-cli",
3515
- "accept": "application/json",
3516
- ...attributionHeaders()
3517
- } });
4955
+ async function fetchGrokCliCatalog(session, fetchFn = proxiedFetch, signal) {
4956
+ const response = await fetchFn(GROK_CLI_MODELS_URL, {
4957
+ headers: {
4958
+ "authorization": `Bearer ${session.accessToken}`,
4959
+ "x-xai-token-auth": "xai-grok-cli",
4960
+ "accept": "application/json",
4961
+ ...attributionHeaders()
4962
+ },
4963
+ ...signal === void 0 ? {} : { signal }
4964
+ });
3518
4965
  if (!response.ok) throw await oauthEndpointError(response, "grok CLI catalog");
3519
4966
  const payload = await response.json();
3520
4967
  if (!Array.isArray(payload.data)) throw new Error("grok CLI catalog returned no data array");
@@ -3567,15 +5014,20 @@ function grokPriorMeta(prior) {
3567
5014
  * @param onWarn - warning sink for a failed CLI catalog fetch.
3568
5015
  * @param previous - last-known catalog used to keep enrichment when the CLI
3569
5016
  * catalog is down or omits a model.
5017
+ * @param signal - caller cancellation (pool-assembly timeout).
3570
5018
  * @returns discovered chat models in endpoint order.
3571
5019
  */
3572
- async function fetchGrokModels(session, fetchFn = fetch, onWarn, previous) {
5020
+ async function fetchGrokModels(session, fetchFn = proxiedFetch, onWarn, previous, signal) {
3573
5021
  const previousById = previous === void 0 || previous.length === 0 ? void 0 : new Map(previous.map((model) => [model.id, model]));
3574
- const [response, cliCatalog] = await Promise.all([fetchFn(GROK_MODELS_URL, { headers: {
3575
- "authorization": `Bearer ${session.accessToken}`,
3576
- "accept": "application/json",
3577
- ...attributionHeaders()
3578
- } }), fetchGrokCliCatalog(session, fetchFn).catch((error) => {
5022
+ const [response, cliCatalog] = await Promise.all([fetchFn(GROK_MODELS_URL, {
5023
+ headers: {
5024
+ "authorization": `Bearer ${session.accessToken}`,
5025
+ "accept": "application/json",
5026
+ ...attributionHeaders()
5027
+ },
5028
+ ...signal === void 0 ? {} : { signal }
5029
+ }), fetchGrokCliCatalog(session, fetchFn, signal).catch((error) => {
5030
+ if (isDiscoveryAborted(error, signal)) throw error;
3579
5031
  onWarn?.(previousById === void 0 ? `grok CLI catalog fetch failed; reasoning efforts are unavailable (${errorChain(error)})` : `grok CLI catalog fetch failed; keeping last-known reasoning efforts (${errorChain(error)})`);
3580
5032
  })]);
3581
5033
  if (!response.ok) throw await oauthEndpointError(response, "grok models");
@@ -3600,14 +5052,44 @@ async function fetchGrokModels(session, fetchFn = fetch, onWarn, previous) {
3600
5052
  /** Grok wire adapter: one instance serves the `grok` provider route. */
3601
5053
  var GrokAdapter = class extends LlmAdapter {
3602
5054
  catalog;
5055
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
5056
+ accountCatalogs = /* @__PURE__ */ new Map();
5057
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
5058
+ catalogOwner;
3603
5059
  constructor(options) {
3604
5060
  super();
3605
5061
  this.options = options;
3606
5062
  this.catalog = new ModelCatalogCache(options.catalogStore);
3607
5063
  }
3608
5064
  /** Discovery fetcher: resolves the session through the refresh-aware path. */
3609
- async fetchCatalog() {
3610
- return fetchGrokModels(await this.options.tokens.session(), this.options.fetchFn, this.options.onWarn, this.catalog.lastKnown());
5065
+ async fetchCatalog(account, signal) {
5066
+ const lastKnown = account === void 0 || account === await this.options.tokens.defaultAccount() ? this.catalog.lastKnown() : this.accountCatalogs.get(account)?.lastKnown();
5067
+ return fetchGrokModels(await this.options.tokens.session(account), this.options.fetchFn, this.options.onWarn, lastKnown, signal);
5068
+ }
5069
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
5070
+ clearAccountCatalog(account) {
5071
+ if (account === void 0) this.accountCatalogs.clear();
5072
+ else this.accountCatalogs.delete(account);
5073
+ if (account === void 0 || this.catalogOwner === account || this.catalogOwner === void 0) {
5074
+ this.catalogOwner = void 0;
5075
+ this.catalog.invalidate();
5076
+ }
5077
+ }
5078
+ /** Persisted cache for the default account; a throwaway cache for any other. */
5079
+ async catalogFor(account) {
5080
+ const defaultKey = await this.options.tokens.defaultAccount();
5081
+ const key = account ?? defaultKey;
5082
+ if (key === void 0 || key === defaultKey) {
5083
+ if (this.catalogOwner !== void 0 && this.catalogOwner !== defaultKey) this.catalog.invalidate();
5084
+ this.catalogOwner = defaultKey;
5085
+ return this.catalog;
5086
+ }
5087
+ let cache = this.accountCatalogs.get(key);
5088
+ if (cache === void 0) {
5089
+ cache = new ModelCatalogCache();
5090
+ this.accountCatalogs.set(key, cache);
5091
+ }
5092
+ return cache;
3611
5093
  }
3612
5094
  listed(provider, discovered) {
3613
5095
  return discovered.map((model) => ({
@@ -3633,11 +5115,30 @@ var GrokAdapter = class extends LlmAdapter {
3633
5115
  }));
3634
5116
  }
3635
5117
  async listModels(provider) {
3636
- if (await this.options.tokens.peek() === void 0) return [];
5118
+ const own = await this.listOwnModels(provider);
5119
+ const pool = this.options.pool?.();
5120
+ if (pool === void 0) return own;
5121
+ const extra = await pool.modelsForProvider(provider);
5122
+ const seen = new Set(own.map((model) => model.id));
5123
+ return [...own, ...extra.filter((model) => !seen.has(model.id))];
5124
+ }
5125
+ /** The provider's own catalog: union of every account, or one account when named. */
5126
+ async listOwnModels(provider, account, signal) {
5127
+ if (account === void 0) {
5128
+ const accounts = (await this.options.tokens.list()).map((entry) => entry.key);
5129
+ if (accounts.length === 0) return [];
5130
+ return unionAccountCatalogs(accounts, (key, accountSignal) => this.listOwnModels(provider, key, accountSignal), {
5131
+ timeoutMs: DISCOVERY_TIMEOUT_MS,
5132
+ ...signal === void 0 ? {} : { signal }
5133
+ });
5134
+ }
5135
+ if (!await this.options.tokens.hasSession(account)) return [];
3637
5136
  if (!this.options.discovery) return this.staticModels(provider);
5137
+ const catalog = await this.catalogFor(account);
3638
5138
  try {
3639
- return this.listed(provider, await discoverOrRetryAuth((force) => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog())));
5139
+ return this.listed(provider, await discoverOrRetryAuth((force) => this.options.tokens.session(account, force), catalog, () => catalog.get(() => this.fetchCatalog(account, signal))));
3640
5140
  } catch (error) {
5141
+ if (isDiscoveryAborted(error, signal)) throw error;
3641
5142
  if (isMissingOrInvalidCredential(error)) return [];
3642
5143
  this.options.onWarn?.(`grok model discovery failed; using the built-in catalog (${errorChain(error)})`);
3643
5144
  return this.staticModels(provider);
@@ -3653,9 +5154,17 @@ var GrokAdapter = class extends LlmAdapter {
3653
5154
  */
3654
5155
  async discovered(model) {
3655
5156
  if (!this.options.discovery) return void 0;
3656
- return (await this.catalog.resolve(() => this.fetchCatalog()))?.find((entry) => entry.id === model);
5157
+ return discoverAcrossAccounts((await this.options.tokens.list()).map((entry) => entry.key), async (account) => {
5158
+ return (await (await this.catalogFor(account)).resolve(() => this.fetchCatalog(account)))?.find((entry) => entry.id === model);
5159
+ });
3657
5160
  }
3658
5161
  async resolveModel(provider, model) {
5162
+ const pool = this.options.pool?.();
5163
+ if (pool !== void 0 && await pool.owns(provider, model)) return pool.resolveModel(provider, model);
5164
+ return this.resolveOwnModel(provider, model);
5165
+ }
5166
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
5167
+ async resolveOwnModel(provider, model) {
3659
5168
  const discovered = await this.discovered(model);
3660
5169
  const configured = this.options.models.find((entry) => entry.id === model);
3661
5170
  return {
@@ -3670,12 +5179,24 @@ var GrokAdapter = class extends LlmAdapter {
3670
5179
  };
3671
5180
  }
3672
5181
  async *stream(options) {
5182
+ const pool = this.options.pool?.();
5183
+ if (pool !== void 0 && await pool.owns(options.provider, options.model)) {
5184
+ yield* pool.stream(options);
5185
+ return;
5186
+ }
5187
+ yield* this.streamCore(options);
5188
+ }
5189
+ /** Pool seam: stream through one specific account instead of the default. */
5190
+ streamAccount(options, account) {
5191
+ return this.streamCore(options, account);
5192
+ }
5193
+ async *streamCore(options, account) {
3673
5194
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
3674
5195
  try {
3675
- let session = await this.options.tokens.session();
5196
+ let session = await this.options.tokens.session(account);
3676
5197
  let response = await this.request(options, session, watchdog.signal);
3677
5198
  if (response.status === 401) {
3678
- session = await this.options.tokens.session(true);
5199
+ session = await this.options.tokens.session(account, true);
3679
5200
  response = await this.request(options, session, watchdog.signal);
3680
5201
  }
3681
5202
  if (!response.ok) throw await httpLlmError(response, "grok API");
@@ -3703,7 +5224,7 @@ var GrokAdapter = class extends LlmAdapter {
3703
5224
  store: false,
3704
5225
  stream: true
3705
5226
  };
3706
- return fetch(GROK_API_URL, {
5227
+ return proxiedFetch(GROK_API_URL, {
3707
5228
  method: "POST",
3708
5229
  headers: {
3709
5230
  "authorization": `Bearer ${session.accessToken}`,
@@ -4117,7 +5638,7 @@ let vscodeVersionInflight;
4117
5638
  * @param forceRefresh - bypass the cache (a 401 `IDE token expired` retry).
4118
5639
  * @returns a `major.minor.patch` version string.
4119
5640
  */
4120
- async function latestVsCodeVersion(fetchFn = fetch, forceRefresh = false) {
5641
+ async function latestVsCodeVersion(fetchFn = proxiedFetch, forceRefresh = false) {
4121
5642
  if (!forceRefresh && vscodeVersionCache !== void 0 && Date.now() - vscodeVersionCache.at < VSCODE_VERSION_TTL_MS) return vscodeVersionCache.version;
4122
5643
  vscodeVersionInflight ??= (async () => {
4123
5644
  try {
@@ -4177,7 +5698,7 @@ function copilotHeaders(hasVision = false, vscodeVersion = FALLBACK_VSCODE_VERSI
4177
5698
  * @param fetchFn - fetch implementation (injectable for tests).
4178
5699
  * @returns the Copilot API token and its expiry.
4179
5700
  */
4180
- async function exchangeCopilotToken(githubToken, fetchFn = fetch) {
5701
+ async function exchangeCopilotToken(githubToken, fetchFn = proxiedFetch) {
4181
5702
  const response = await fetchFn(COPILOT_TOKEN_URL, { headers: {
4182
5703
  "authorization": `Bearer ${githubToken}`,
4183
5704
  "accept": "application/json",
@@ -4198,7 +5719,7 @@ async function exchangeCopilotToken(githubToken, fetchFn = fetch) {
4198
5719
  * @param fetchFn - fetch implementation (injectable for tests).
4199
5720
  * @returns the session to store.
4200
5721
  */
4201
- async function completeCopilotLogin(githubToken, fetchFn = fetch) {
5722
+ async function completeCopilotLogin(githubToken, fetchFn = proxiedFetch) {
4202
5723
  const pair = await exchangeCopilotToken(githubToken, fetchFn);
4203
5724
  let account;
4204
5725
  try {
@@ -4226,7 +5747,7 @@ async function completeCopilotLogin(githubToken, fetchFn = fetch) {
4226
5747
  * @param fetchFn - fetch implementation (injectable for tests).
4227
5748
  * @returns the fresh session to store.
4228
5749
  */
4229
- async function refreshCopilot(session, fetchFn = fetch) {
5750
+ async function refreshCopilot(session, fetchFn = proxiedFetch) {
4230
5751
  const pair = await exchangeCopilotToken(session.refreshToken, fetchFn);
4231
5752
  return {
4232
5753
  accessToken: pair.accessToken,
@@ -4282,14 +5803,18 @@ function copilotReasoning(entry) {
4282
5803
  * reasoning efforts (the endpoint discloses no default, so none is claimed).
4283
5804
  * @param session - the stored session (used as-is; never refreshed here).
4284
5805
  * @param fetchFn - fetch implementation (injectable for tests).
5806
+ * @param signal - caller cancellation (pool-assembly timeout).
4285
5807
  * @returns discovered chat models in endpoint order.
4286
5808
  */
4287
- async function fetchCopilotModels(session, fetchFn = fetch) {
4288
- const response = await fetchFn(COPILOT_MODELS_URL, { headers: {
4289
- "authorization": `Bearer ${session.accessToken}`,
4290
- "accept": "application/json",
4291
- ...copilotHeaders(false, await latestVsCodeVersion(fetchFn))
4292
- } });
5809
+ async function fetchCopilotModels(session, fetchFn = proxiedFetch, signal) {
5810
+ const response = await fetchFn(COPILOT_MODELS_URL, {
5811
+ headers: {
5812
+ "authorization": `Bearer ${session.accessToken}`,
5813
+ "accept": "application/json",
5814
+ ...copilotHeaders(false, await latestVsCodeVersion(fetchFn))
5815
+ },
5816
+ ...signal === void 0 ? {} : { signal }
5817
+ });
4293
5818
  if (!response.ok) throw await oauthEndpointError(response, "copilot models");
4294
5819
  const payload = await response.json();
4295
5820
  if (!Array.isArray(payload.data)) throw new Error("copilot models endpoint returned no data array");
@@ -4511,6 +6036,10 @@ var CopilotResponsesItemNormalizer = class {
4511
6036
  /** Copilot wire adapter: one instance serves the `copilot` provider route. */
4512
6037
  var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4513
6038
  catalog;
6039
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
6040
+ accountCatalogs = /* @__PURE__ */ new Map();
6041
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
6042
+ catalogOwner;
4514
6043
  /**
4515
6044
  * [2026-08-23]-[a reasoning model continuing a tool chain must get its
4516
6045
  * reasoning back or it restarts from scratch every tool round trip; the
@@ -4533,8 +6062,33 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4533
6062
  this.catalog = new ModelCatalogCache(options.catalogStore);
4534
6063
  }
4535
6064
  /** Discovery fetcher: resolves the session through the refresh-aware path. */
4536
- async fetchCatalog() {
4537
- return fetchCopilotModels(await this.options.tokens.session(), this.options.fetchFn);
6065
+ async fetchCatalog(account, signal) {
6066
+ return fetchCopilotModels(await this.options.tokens.session(account), this.options.fetchFn, signal);
6067
+ }
6068
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
6069
+ clearAccountCatalog(account) {
6070
+ if (account === void 0) this.accountCatalogs.clear();
6071
+ else this.accountCatalogs.delete(account);
6072
+ if (account === void 0 || this.catalogOwner === account || this.catalogOwner === void 0) {
6073
+ this.catalogOwner = void 0;
6074
+ this.catalog.invalidate();
6075
+ }
6076
+ }
6077
+ /** Persisted cache for the default account; a throwaway cache for any other. */
6078
+ async catalogFor(account) {
6079
+ const defaultKey = await this.options.tokens.defaultAccount();
6080
+ const key = account ?? defaultKey;
6081
+ if (key === void 0 || key === defaultKey) {
6082
+ if (this.catalogOwner !== void 0 && this.catalogOwner !== defaultKey) this.catalog.invalidate();
6083
+ this.catalogOwner = defaultKey;
6084
+ return this.catalog;
6085
+ }
6086
+ let cache = this.accountCatalogs.get(key);
6087
+ if (cache === void 0) {
6088
+ cache = new ModelCatalogCache();
6089
+ this.accountCatalogs.set(key, cache);
6090
+ }
6091
+ return cache;
4538
6092
  }
4539
6093
  providerInfo(provider) {
4540
6094
  return {
@@ -4551,10 +6105,28 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4551
6105
  }));
4552
6106
  }
4553
6107
  async listModels(provider) {
4554
- if (await this.options.tokens.peek() === void 0) return [];
6108
+ const own = await this.listOwnModels(provider);
6109
+ const pool = this.options.pool?.();
6110
+ if (pool === void 0) return own;
6111
+ const extra = await pool.modelsForProvider(provider);
6112
+ const seen = new Set(own.map((model) => model.id));
6113
+ return [...own, ...extra.filter((model) => !seen.has(model.id))];
6114
+ }
6115
+ /** The provider's own catalog: union of every account, or one account when named. */
6116
+ async listOwnModels(provider, account, signal) {
6117
+ if (account === void 0) {
6118
+ const accounts = (await this.options.tokens.list()).map((entry) => entry.key);
6119
+ if (accounts.length === 0) return [];
6120
+ return unionAccountCatalogs(accounts, (key, accountSignal) => this.listOwnModels(provider, key, accountSignal), {
6121
+ timeoutMs: DISCOVERY_TIMEOUT_MS,
6122
+ ...signal === void 0 ? {} : { signal }
6123
+ });
6124
+ }
6125
+ if (!await this.options.tokens.hasSession(account)) return [];
4555
6126
  if (!this.options.discovery) return this.staticModels(provider);
6127
+ const catalog = await this.catalogFor(account);
4556
6128
  try {
4557
- return (await discoverOrRetryAuth((force) => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog()))).map((model) => ({
6129
+ return (await discoverOrRetryAuth((force) => this.options.tokens.session(account, force), catalog, () => catalog.get(() => this.fetchCatalog(account, signal)))).map((model) => ({
4558
6130
  provider,
4559
6131
  id: model.id,
4560
6132
  name: model.name,
@@ -4562,6 +6134,7 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4562
6134
  ...model.inputModalities === void 0 ? {} : { inputModalities: model.inputModalities }
4563
6135
  }));
4564
6136
  } catch (error) {
6137
+ if (isDiscoveryAborted(error, signal)) throw error;
4565
6138
  if (isMissingOrInvalidCredential(error)) return [];
4566
6139
  this.options.onWarn?.(`copilot model discovery failed; using the built-in catalog (${errorChain(error)})`);
4567
6140
  return this.staticModels(provider);
@@ -4575,7 +6148,9 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4575
6148
  */
4576
6149
  async discovered(model) {
4577
6150
  if (!this.options.discovery) return void 0;
4578
- return (await this.catalog.resolve(() => this.fetchCatalog()))?.find((entry) => entry.id === model);
6151
+ return discoverAcrossAccounts((await this.options.tokens.list()).map((entry) => entry.key), async (account) => {
6152
+ return (await (await this.catalogFor(account)).resolve(() => this.fetchCatalog(account)))?.find((entry) => entry.id === model);
6153
+ });
4579
6154
  }
4580
6155
  /**
4581
6156
  * [2026-08-23]-[a manually configured responses-only model combined with
@@ -4675,6 +6250,12 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4675
6250
  this.replayByScope.clear();
4676
6251
  }
4677
6252
  async resolveModel(provider, model) {
6253
+ const pool = this.options.pool?.();
6254
+ if (pool !== void 0 && await pool.owns(provider, model)) return pool.resolveModel(provider, model);
6255
+ return this.resolveOwnModel(provider, model);
6256
+ }
6257
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
6258
+ async resolveOwnModel(provider, model) {
4678
6259
  const discovered = await this.discovered(model);
4679
6260
  const configured = this.options.models.find((entry) => entry.id === model);
4680
6261
  return {
@@ -4689,15 +6270,27 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4689
6270
  };
4690
6271
  }
4691
6272
  async *stream(options) {
6273
+ const pool = this.options.pool?.();
6274
+ if (pool !== void 0 && await pool.owns(options.provider, options.model)) {
6275
+ yield* pool.stream(options);
6276
+ return;
6277
+ }
6278
+ yield* this.streamCore(options);
6279
+ }
6280
+ /** Pool seam: stream through one specific account instead of the default. */
6281
+ streamAccount(options, account) {
6282
+ return this.streamCore(options, account);
6283
+ }
6284
+ async *streamCore(options, account) {
4692
6285
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
4693
6286
  try {
4694
6287
  const wire = copilotRequestWire(this.configuredWireEntry(options.model) ?? await this.discovered(options.model), options);
4695
- let session = await this.options.tokens.session();
6288
+ let session = await this.options.tokens.session(account);
4696
6289
  const scope = this.replayScope(session.refreshToken, options);
4697
6290
  let response = await this.request(options, session, watchdog.signal, wire, scope);
4698
6291
  if (response.status === 401) {
4699
- await latestVsCodeVersion(this.options.fetchFn ?? fetch, true);
4700
- session = await this.options.tokens.session(true);
6292
+ await latestVsCodeVersion(this.options.fetchFn ?? proxiedFetch, true);
6293
+ session = await this.options.tokens.session(account, true);
4701
6294
  response = await this.request(options, session, watchdog.signal, wire, scope);
4702
6295
  }
4703
6296
  if (!response.ok) throw await httpLlmError(response, "copilot API");
@@ -4721,13 +6314,13 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4721
6314
  const messages = await resolveImages(options.messages, this.options.resolveAttachments?.(), signal);
4722
6315
  const hasVision = messages.some((message) => message.content.some((block) => block.type === "image"));
4723
6316
  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, {
6317
+ return proxiedFetch(wire === "responses" ? COPILOT_RESPONSES_URL : COPILOT_API_URL, {
4725
6318
  method: "POST",
4726
6319
  headers: {
4727
6320
  "authorization": `Bearer ${session.accessToken}`,
4728
6321
  "accept": "text/event-stream",
4729
6322
  "content-type": "application/json",
4730
- ...copilotHeaders(hasVision, await latestVsCodeVersion(this.options.fetchFn ?? fetch))
6323
+ ...copilotHeaders(hasVision, await latestVsCodeVersion(this.options.fetchFn ?? proxiedFetch))
4731
6324
  },
4732
6325
  body: JSON.stringify(body),
4733
6326
  signal
@@ -4895,7 +6488,7 @@ function createXSearchTool(options) {
4895
6488
  async execute(args, exec) {
4896
6489
  const request = buildXSearchRequest(args);
4897
6490
  const session = await options.tokens.session();
4898
- const response = await (options.fetchFn ?? fetch)(X_SEARCH_URL, {
6491
+ const response = await (options.fetchFn ?? proxiedFetch)(X_SEARCH_URL, {
4899
6492
  method: "POST",
4900
6493
  headers: {
4901
6494
  "authorization": `Bearer ${session.accessToken}`,
@@ -5160,7 +6753,7 @@ function createImageGenerateTool(options) {
5160
6753
  content: result.content.filter((block) => block.type === "text")
5161
6754
  }),
5162
6755
  async execute(args, exec) {
5163
- const fetchFn = options.fetchFn ?? fetch;
6756
+ const fetchFn = options.fetchFn ?? proxiedFetch;
5164
6757
  const preferGrok = args.provider === "grok";
5165
6758
  const codexReady = options.codexTokens !== void 0 && await options.codexTokens.hasSession();
5166
6759
  const grokReady = options.grokTokens !== void 0 && await options.grokTokens.hasSession();
@@ -5430,7 +7023,7 @@ function createVideoGenerateTool(options) {
5430
7023
  async execute(args, exec) {
5431
7024
  const body = buildVideoGenerateBody(args);
5432
7025
  const session = await options.tokens.session();
5433
- const fetchFn = options.fetchFn ?? fetch;
7026
+ const fetchFn = options.fetchFn ?? proxiedFetch;
5434
7027
  const headers = {
5435
7028
  "authorization": `Bearer ${session.accessToken}`,
5436
7029
  "accept": "application/json"
@@ -5489,6 +7082,8 @@ const name = "dsh-plugin-subscriptions";
5489
7082
  const inject = ["llm"];
5490
7083
  /** Default maximum provider idle time while one stream read is outstanding. */
5491
7084
  const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 3e5;
7085
+ /** Bound on one pool quota poll — member selection must not hang on a usage endpoint. */
7086
+ const POOL_USAGE_TIMEOUT_MS = DISCOVERY_TIMEOUT_MS;
5492
7087
  const providerIdSchema = z.union([
5493
7088
  "codex",
5494
7089
  "claude",
@@ -5503,6 +7098,11 @@ const modelEntrySchema = z.object({
5503
7098
  inputModalities: z.array(z.union(["text", "image"])),
5504
7099
  wire: z.union(["chat-completions", "responses"])
5505
7100
  });
7101
+ const poolMemberSchema = z.object({
7102
+ provider: providerIdSchema.required(),
7103
+ account: z.string(),
7104
+ model: z.string().required()
7105
+ });
5506
7106
  const Config = z.object({
5507
7107
  providers: z.array(providerIdSchema).default([
5508
7108
  "codex",
@@ -5516,6 +7116,15 @@ const Config = z.object({
5516
7116
  claude: z.array(modelEntrySchema),
5517
7117
  grok: z.array(modelEntrySchema),
5518
7118
  copilot: z.array(modelEntrySchema)
7119
+ }),
7120
+ pool: z.object({
7121
+ enabled: z.boolean().default(true),
7122
+ strategy: z.union(["priority", "quota_aware"]).default("quota_aware"),
7123
+ switchMargin: z.number().min(1).default(2),
7124
+ autoAccounts: z.boolean().default(true),
7125
+ autoFamilies: z.boolean(),
7126
+ families: z.dict(z.array(poolMemberSchema)),
7127
+ tiers: z.dict(z.array(poolMemberSchema))
5519
7128
  })
5520
7129
  });
5521
7130
  /** Built-in catalogs used when the config does not override a provider's models. */
@@ -5622,6 +7231,15 @@ function accountOf(provider, session) {
5622
7231
  case "copilot": return session.account;
5623
7232
  }
5624
7233
  }
7234
+ /** The plan name a stored session carries, when the provider told us. */
7235
+ function planOf(provider, session) {
7236
+ switch (provider) {
7237
+ case "codex": return session.planType;
7238
+ case "claude": return session.subscriptionType;
7239
+ case "grok": return;
7240
+ case "copilot": return;
7241
+ }
7242
+ }
5625
7243
  /**
5626
7244
  * Auth operations behind the `/subscriptions-auth` RPC channel: start/complete
5627
7245
  * OAuth attempts in the background, feed pasted codes, cancel, log out, and
@@ -5663,10 +7281,10 @@ var SubscriptionsAuthController = class {
5663
7281
  this.usageFetchers = usageFetchers;
5664
7282
  this.readClaudeCreds = readClaudeCreds;
5665
7283
  }
5666
- usage(provider, signal) {
7284
+ usage(provider, account, signal) {
5667
7285
  const fetcher = this.usageFetchers[provider];
5668
7286
  if (fetcher === void 0) return Promise.resolve({ supported: false });
5669
- return fetcher(signal);
7287
+ return fetcher(account, signal);
5670
7288
  }
5671
7289
  async readImage(ref, signal) {
5672
7290
  const attachments = this.resolveAttachments();
@@ -5684,28 +7302,45 @@ var SubscriptionsAuthController = class {
5684
7302
  };
5685
7303
  }
5686
7304
  async status(provider) {
5687
- const session = await getSession(provider);
5688
- const account = accountOf(provider, session);
7305
+ const entries = await listAccounts(provider);
5689
7306
  const detail = this.lastError.get(provider);
5690
7307
  return {
5691
- loggedIn: session !== void 0,
5692
7308
  busy: this.flows.isBusy(provider) || this.deviceFlows.isBusy(provider) || this.finalizing.has(provider),
5693
- ...session === void 0 ? {} : { expiresAt: session.expiresAt },
5694
- ...account === void 0 ? {} : { account },
7309
+ accounts: entries.map(({ key, session }, index) => {
7310
+ const account = accountOf(provider, session);
7311
+ const plan = planOf(provider, session);
7312
+ return {
7313
+ key,
7314
+ isDefault: index === 0,
7315
+ expiresAt: session.expiresAt,
7316
+ ...account === void 0 ? {} : { account },
7317
+ ...plan === void 0 ? {} : { plan }
7318
+ };
7319
+ }),
5695
7320
  ...detail === void 0 ? {} : { detail }
5696
7321
  };
5697
7322
  }
5698
- async login(provider) {
5699
- if (provider === "claude") {
7323
+ async login(provider, method) {
7324
+ if (provider === "claude" && method !== "oauth") {
5700
7325
  const imported = this.readClaudeCreds();
5701
7326
  if (imported !== void 0) {
5702
7327
  this.claim("claude");
5703
7328
  this.flows.pending("claude")?.cancel();
5704
- await this.persist("claude", imported);
7329
+ const session = {
7330
+ ...imported,
7331
+ keychainBound: true
7332
+ };
7333
+ await this.persist("claude", session);
5705
7334
  this.lastError.delete("claude");
5706
- this.onAuthChanged("claude");
7335
+ this.onAuthChanged("claude", accountKeyOf("claude", session));
5707
7336
  return { authorizeUrl: "" };
5708
7337
  }
7338
+ if (method === "keychain") throw new Error("no Claude Code credentials found; run `claude` and log in first, or choose the browser flow");
7339
+ const attempt$1 = await this.flows.start("claude", claudeFlow);
7340
+ this.completions.set("claude", this.complete("claude", attempt$1, this.claim("claude")));
7341
+ return { authorizeUrl: attempt$1.authorizeUrl };
7342
+ }
7343
+ if (provider === "claude") {
5709
7344
  const attempt$1 = await this.flows.start("claude", claudeFlow);
5710
7345
  this.completions.set("claude", this.complete("claude", attempt$1, this.claim("claude")));
5711
7346
  return { authorizeUrl: attempt$1.authorizeUrl };
@@ -5747,7 +7382,7 @@ var SubscriptionsAuthController = class {
5747
7382
  if (this.claims.get(provider) !== claim) return;
5748
7383
  await this.persist(provider, session);
5749
7384
  this.lastError.delete(provider);
5750
- this.onAuthChanged(provider);
7385
+ this.onAuthChanged(provider, accountKeyOf(provider, session));
5751
7386
  } catch (error) {
5752
7387
  if (this.claims.get(provider) !== claim) return;
5753
7388
  if (!(error instanceof Error && error.message === "login cancelled")) this.lastError.set(provider, errorChain(error));
@@ -5759,7 +7394,7 @@ var SubscriptionsAuthController = class {
5759
7394
  const session = await completeCopilotLogin(await attempt.waitToken());
5760
7395
  await this.persist(provider, session);
5761
7396
  this.lastError.delete(provider);
5762
- this.onAuthChanged(provider);
7397
+ this.onAuthChanged(provider, accountKeyOf(provider, session));
5763
7398
  } catch (error) {
5764
7399
  if (!(error instanceof Error && error.message === "login cancelled")) this.lastError.set(provider, errorChain(error));
5765
7400
  } finally {
@@ -5775,12 +7410,7 @@ var SubscriptionsAuthController = class {
5775
7410
  }
5776
7411
  }
5777
7412
  persist(provider, session) {
5778
- switch (provider) {
5779
- case "codex": return saveSession("codex", session);
5780
- case "claude": return saveSession("claude", session);
5781
- case "grok": return saveSession("grok", session);
5782
- case "copilot": return saveSession("copilot", session);
5783
- }
7413
+ return saveAccountSession(provider, accountKeyOf(provider, session), session);
5784
7414
  }
5785
7415
  /**
5786
7416
  * Settle once no OAuth completion is running for a provider.
@@ -5804,13 +7434,17 @@ var SubscriptionsAuthController = class {
5804
7434
  this.deviceFlows.pending(provider)?.cancel();
5805
7435
  return Promise.resolve();
5806
7436
  }
5807
- async logout(provider) {
7437
+ async logout(provider, account) {
5808
7438
  this.claim(provider);
5809
7439
  this.flows.pending(provider)?.cancel();
5810
7440
  this.deviceFlows.pending(provider)?.cancel();
5811
- await deleteSession(provider);
7441
+ await deleteAccountSession(provider, account);
5812
7442
  this.lastError.delete(provider);
5813
- this.onAuthChanged(provider);
7443
+ this.onAuthChanged(provider, account);
7444
+ }
7445
+ async setDefault(provider, account) {
7446
+ await setDefaultAccount(provider, account);
7447
+ this.onAuthChanged(provider, account);
5814
7448
  }
5815
7449
  };
5816
7450
  function apply(ctx, config) {
@@ -5826,9 +7460,18 @@ function apply(ctx, config) {
5826
7460
  };
5827
7461
  const resolveAttachments = () => ctx.get("attachments");
5828
7462
  const handles = /* @__PURE__ */ new Map();
5829
- const authChanged = (provider) => {
7463
+ const adapters = /* @__PURE__ */ new Map();
7464
+ const accountTokens = /* @__PURE__ */ new Map();
7465
+ let poolHealth;
7466
+ let poolUsage;
7467
+ let poolAdapter;
7468
+ const authChanged = (provider, account) => {
5830
7469
  if (provider === "copilot") copilotAdapter?.clearReplayState();
5831
- handles.get(provider)?.replace([provider]);
7470
+ adapters.get(provider)?.clearAccountCatalog(account);
7471
+ poolHealth?.clear(provider, account);
7472
+ poolUsage?.invalidate(provider, account);
7473
+ poolAdapter?.invalidate();
7474
+ for (const [route, handle] of handles) handle.replace([route]);
5832
7475
  };
5833
7476
  let codexTokens;
5834
7477
  let claudeTokens;
@@ -5839,20 +7482,21 @@ function apply(ctx, config) {
5839
7482
  let copilotAdapter;
5840
7483
  for (const provider of providers) switch (provider) {
5841
7484
  case "codex": {
5842
- const tokens = new TokenManager({
7485
+ const tokens = new AccountTokenManager({
7486
+ provider: "codex",
5843
7487
  displayName: "ChatGPT (Codex)",
5844
- preemptMs: CODEX_PREEMPT_MS,
5845
- load: () => getSession("codex"),
5846
- save: (session) => saveSession("codex", session),
5847
- remove: () => deleteSession("codex"),
5848
- refresh: refreshCodex,
5849
- isPermanent: isCodexPermanentRefreshError,
5850
- onRemoved: () => {
5851
- authChanged("codex");
7488
+ makeOptions: () => ({
7489
+ preemptMs: CODEX_PREEMPT_MS,
7490
+ refresh: refreshCodex,
7491
+ isPermanent: isCodexPermanentRefreshError
7492
+ }),
7493
+ onAccountRemoved: (account) => {
7494
+ authChanged("codex", account);
5852
7495
  }
5853
7496
  });
5854
7497
  codexTokens = tokens;
5855
- usageFetchers.codex = async (signal) => fetchCodexUsage(await tokens.session(), fetch, signal);
7498
+ accountTokens.set("codex", tokens);
7499
+ usageFetchers.codex = async (account, signal) => fetchCodexUsage(await tokens.session(account), proxiedFetch, signal);
5856
7500
  let adapter;
5857
7501
  adapter = new CodexAdapter({
5858
7502
  models: catalog.codex,
@@ -5862,28 +7506,31 @@ function apply(ctx, config) {
5862
7506
  onWarn,
5863
7507
  resolveAttachments,
5864
7508
  catalogStore: catalogStore("codex"),
7509
+ pool: () => poolAdapter,
5865
7510
  speedFor: (sessionId, model) => sessionId !== void 0 && speedBySession.get(sessionId) === "fast" && adapter.supportsFastTier(model)
5866
7511
  });
5867
7512
  codexAdapter = adapter;
7513
+ adapters.set("codex", adapter);
5868
7514
  handles.set("codex", ctx.llm.registerAdapter(["codex"], adapter));
5869
7515
  break;
5870
7516
  }
5871
7517
  case "claude": {
5872
- const tokens = new TokenManager({
7518
+ const tokens = new AccountTokenManager({
7519
+ provider: "claude",
5873
7520
  displayName: "Claude (Subscription)",
5874
- preemptMs: CLAUDE_PREEMPT_MS,
5875
- load: () => getSession("claude"),
5876
- save: (session) => saveSession("claude", session),
5877
- remove: () => deleteSession("claude"),
5878
- refresh: (session) => refreshClaudeSynced(session, refreshClaude),
5879
- isPermanent: isClaudePermanentRefreshError,
5880
- onRemoved: () => {
5881
- authChanged("claude");
7521
+ makeOptions: () => ({
7522
+ preemptMs: CLAUDE_PREEMPT_MS,
7523
+ refresh: (session) => session.keychainBound === true ? refreshClaudeSynced(session, refreshClaude) : refreshClaude(session),
7524
+ isPermanent: isClaudePermanentRefreshError
7525
+ }),
7526
+ onAccountRemoved: (account) => {
7527
+ authChanged("claude", account);
5882
7528
  }
5883
7529
  });
5884
7530
  claudeTokens = tokens;
5885
- usageFetchers.claude = async (signal) => fetchClaudeUsage(await tokens.session(), fetch, signal);
5886
- handles.set("claude", ctx.llm.registerAdapter(["claude"], new ClaudeAdapter({
7531
+ accountTokens.set("claude", tokens);
7532
+ usageFetchers.claude = async (account, signal) => fetchClaudeUsage(await tokens.session(account), proxiedFetch, signal);
7533
+ const adapter = new ClaudeAdapter({
5887
7534
  models: catalog.claude,
5888
7535
  streamIdleTimeoutMs,
5889
7536
  tokens,
@@ -5891,49 +7538,57 @@ function apply(ctx, config) {
5891
7538
  onWarn,
5892
7539
  maxRetries: 10,
5893
7540
  resolveAttachments,
5894
- catalogStore: catalogStore("claude")
5895
- })));
7541
+ catalogStore: catalogStore("claude"),
7542
+ pool: () => poolAdapter
7543
+ });
7544
+ adapters.set("claude", adapter);
7545
+ handles.set("claude", ctx.llm.registerAdapter(["claude"], adapter));
5896
7546
  break;
5897
7547
  }
5898
7548
  case "grok": {
5899
- const tokens = new TokenManager({
7549
+ const tokens = new AccountTokenManager({
7550
+ provider: "grok",
5900
7551
  displayName: "Grok (Subscription)",
5901
- preemptMs: GROK_PREEMPT_MS,
5902
- load: () => getSession("grok"),
5903
- save: (session) => saveSession("grok", session),
5904
- remove: () => deleteSession("grok"),
5905
- refresh: refreshGrok,
5906
- isPermanent: isGrokPermanentRefreshError,
5907
- onRemoved: () => {
5908
- authChanged("grok");
7552
+ makeOptions: () => ({
7553
+ preemptMs: GROK_PREEMPT_MS,
7554
+ refresh: refreshGrok,
7555
+ isPermanent: isGrokPermanentRefreshError
7556
+ }),
7557
+ onAccountRemoved: (account) => {
7558
+ authChanged("grok", account);
5909
7559
  }
5910
7560
  });
5911
7561
  grokTokens = tokens;
5912
- usageFetchers.grok = async (signal) => fetchGrokUsage(await tokens.session(), fetch, signal);
5913
- handles.set("grok", ctx.llm.registerAdapter(["grok"], new GrokAdapter({
7562
+ accountTokens.set("grok", tokens);
7563
+ usageFetchers.grok = async (account, signal) => fetchGrokUsage(await tokens.session(account), proxiedFetch, signal);
7564
+ const adapter = new GrokAdapter({
5914
7565
  models: catalog.grok,
5915
7566
  streamIdleTimeoutMs,
5916
7567
  tokens,
5917
7568
  discovery: !overridden.has("grok"),
5918
7569
  onWarn,
5919
7570
  resolveAttachments,
5920
- catalogStore: catalogStore("grok")
5921
- })));
7571
+ catalogStore: catalogStore("grok"),
7572
+ pool: () => poolAdapter
7573
+ });
7574
+ adapters.set("grok", adapter);
7575
+ handles.set("grok", ctx.llm.registerAdapter(["grok"], adapter));
5922
7576
  break;
5923
7577
  }
5924
7578
  case "copilot": {
5925
- const tokens = new TokenManager({
7579
+ const tokens = new AccountTokenManager({
7580
+ provider: "copilot",
5926
7581
  displayName: "GitHub Copilot",
5927
- preemptMs: COPILOT_PREEMPT_MS,
5928
- load: () => getSession("copilot"),
5929
- save: (session) => saveSession("copilot", session),
5930
- remove: () => deleteSession("copilot"),
5931
- refresh: refreshCopilot,
5932
- isPermanent: isCopilotPermanentRefreshError,
5933
- onRemoved: () => {
5934
- authChanged("copilot");
7582
+ makeOptions: () => ({
7583
+ preemptMs: COPILOT_PREEMPT_MS,
7584
+ refresh: refreshCopilot,
7585
+ isPermanent: isCopilotPermanentRefreshError
7586
+ }),
7587
+ onAccountRemoved: (account) => {
7588
+ authChanged("copilot", account);
5935
7589
  }
5936
7590
  });
7591
+ accountTokens.set("copilot", tokens);
5937
7592
  copilotAdapter = new CopilotAdapter({
5938
7593
  models: catalog.copilot,
5939
7594
  streamIdleTimeoutMs,
@@ -5941,12 +7596,77 @@ function apply(ctx, config) {
5941
7596
  discovery: !overridden.has("copilot"),
5942
7597
  onWarn,
5943
7598
  resolveAttachments,
5944
- catalogStore: catalogStore("copilot")
7599
+ catalogStore: catalogStore("copilot"),
7600
+ pool: () => poolAdapter
5945
7601
  });
7602
+ adapters.set("copilot", copilotAdapter);
5946
7603
  handles.set("copilot", ctx.llm.registerAdapter(["copilot"], copilotAdapter));
5947
7604
  break;
5948
7605
  }
5949
7606
  }
7607
+ const poolConfig = config.pool;
7608
+ const autoAccounts = poolConfig?.autoAccounts ?? poolConfig?.autoFamilies ?? true;
7609
+ if (poolConfig?.enabled !== false && adapters.size >= 1) {
7610
+ const fetcherFor = (provider, account) => {
7611
+ switch (provider) {
7612
+ case "codex": {
7613
+ const tokens = codexTokens;
7614
+ return tokens === void 0 ? void 0 : async () => fetchCodexUsage(await tokens.session(account), proxiedFetch, AbortSignal.timeout(POOL_USAGE_TIMEOUT_MS));
7615
+ }
7616
+ case "claude": {
7617
+ const tokens = claudeTokens;
7618
+ return tokens === void 0 ? void 0 : async () => fetchClaudeUsage(await tokens.session(account), proxiedFetch, AbortSignal.timeout(POOL_USAGE_TIMEOUT_MS));
7619
+ }
7620
+ case "grok": {
7621
+ const tokens = grokTokens;
7622
+ return tokens === void 0 ? void 0 : async () => fetchGrokUsage(await tokens.session(account), proxiedFetch, AbortSignal.timeout(POOL_USAGE_TIMEOUT_MS));
7623
+ }
7624
+ case "copilot": return;
7625
+ }
7626
+ };
7627
+ poolHealth = new PoolHealthRegistry();
7628
+ poolUsage = new PoolUsageTracker(fetcherFor);
7629
+ const families = async () => {
7630
+ const pools = /* @__PURE__ */ new Map();
7631
+ if (autoAccounts) {
7632
+ const sources = {};
7633
+ await Promise.all([...adapters].map(async ([provider, adapter]) => {
7634
+ try {
7635
+ const accounts = (await accountTokens.get(provider)?.list() ?? []).map((entry) => entry.key);
7636
+ if (accounts.length < 2) return;
7637
+ const catalogs = (await Promise.all(accounts.map(async (account) => {
7638
+ const models = await withTimeout((signal) => adapter.listOwnModels(provider, account, signal), POOL_USAGE_TIMEOUT_MS);
7639
+ return models === void 0 ? void 0 : {
7640
+ account,
7641
+ models
7642
+ };
7643
+ }))).filter((entry) => entry !== void 0);
7644
+ if (catalogs.length >= 2) sources[provider] = { catalogs };
7645
+ } catch {}
7646
+ }));
7647
+ for (const [key, definition] of buildAccountPools(sources)) pools.set(key, definition);
7648
+ }
7649
+ for (const [id, members] of Object.entries(poolConfig?.families ?? {})) {
7650
+ if (members.length === 0) continue;
7651
+ const owner = members[0].provider;
7652
+ const kept = members.filter((member) => member.provider === owner);
7653
+ if (kept.length < members.length) onWarn(`pool "${id}": cross-provider members are ignored; only ${owner} accounts are pooled`);
7654
+ pools.set(poolKey(owner, id), { members: kept });
7655
+ }
7656
+ return pools;
7657
+ };
7658
+ poolAdapter = new PoolAdapter({
7659
+ adapters: Object.fromEntries(adapters),
7660
+ health: poolHealth,
7661
+ usage: poolUsage,
7662
+ strategy: poolConfig?.strategy ?? "quota_aware",
7663
+ switchMargin: poolConfig?.switchMargin ?? 2,
7664
+ defaultAccount: (provider) => accountTokens.get(provider)?.defaultAccount() ?? Promise.resolve(void 0),
7665
+ families,
7666
+ tiers: poolConfig?.tiers ?? {},
7667
+ onWarn
7668
+ });
7669
+ }
5950
7670
  registerAuthRpc(ctx, new SubscriptionsAuthController(flows, deviceFlows, authChanged, resolveAttachments, usageFetchers), {
5951
7671
  async speed(sessionId) {
5952
7672
  return {
@@ -5958,10 +7678,20 @@ function apply(ctx, config) {
5958
7678
  if (tier === "standard") speedBySession.delete(sessionId);
5959
7679
  else speedBySession.set(sessionId, tier);
5960
7680
  }
7681
+ }, {
7682
+ get: () => proxyGetConfig(),
7683
+ set: (input) => proxySetConfig(input),
7684
+ test: (payload) => proxyTestConnection(payload.url, payload.proxy)
5961
7685
  });
5962
7686
  if (claudeTokens !== void 0) {
7687
+ const tokens = claudeTokens;
5963
7688
  const syncTimer = setInterval(() => {
5964
- claudeTokens?.session().catch(() => {});
7689
+ tokens.list().then((accounts) => {
7690
+ for (const { key, session } of accounts) {
7691
+ if (session.keychainBound !== true) continue;
7692
+ tokens.session(key).catch(() => {});
7693
+ }
7694
+ }, () => void 0);
5965
7695
  }, 5 * 6e4);
5966
7696
  ctx.effect(() => () => {
5967
7697
  clearInterval(syncTimer);
@@ -5982,4 +7712,4 @@ function apply(ctx, config) {
5982
7712
  }
5983
7713
 
5984
7714
  //#endregion
5985
- export { Config, DEFAULT_STREAM_IDLE_TIMEOUT_MS, SubscriptionsAuthController, apply, inject, name };
7715
+ export { Config, DEFAULT_STREAM_IDLE_TIMEOUT_MS, POOL_USAGE_TIMEOUT_MS, SubscriptionsAuthController, apply, inject, name, withTimeout };