dsh-plugin-subscriptions 0.5.2 → 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 (41) hide show
  1. package/README.md +36 -1
  2. package/README.zh.md +36 -1
  3. package/lib/auth/rpc.d.ts +29 -12
  4. package/lib/auth/rpc.js +29 -6
  5. package/lib/auth/store.d.ts +75 -17
  6. package/lib/auth/store.js +148 -27
  7. package/lib/client/SubscriptionsSection.d.ts +9 -3
  8. package/lib/client/SubscriptionsSection.js +93 -65
  9. package/lib/client/locales.d.ts +18 -10
  10. package/lib/client/locales.js +18 -10
  11. package/lib/client.js +250 -127
  12. package/lib/client.js.map +1 -1
  13. package/lib/index.d.ts +21 -0
  14. package/lib/index.js +1482 -168
  15. package/lib/providers/accounts.d.ts +102 -0
  16. package/lib/providers/accounts.js +123 -0
  17. package/lib/providers/claude.d.ts +22 -4
  18. package/lib/providers/claude.js +91 -11
  19. package/lib/providers/codex.d.ts +24 -3
  20. package/lib/providers/codex.js +116 -17
  21. package/lib/providers/common.d.ts +17 -0
  22. package/lib/providers/common.js +67 -3
  23. package/lib/providers/copilot.d.ts +22 -3
  24. package/lib/providers/copilot.js +91 -12
  25. package/lib/providers/grok.d.ts +24 -4
  26. package/lib/providers/grok.js +100 -14
  27. package/lib/providers/pool-family.d.ts +56 -0
  28. package/lib/providers/pool-family.js +45 -0
  29. package/lib/providers/pool-health.d.ts +74 -0
  30. package/lib/providers/pool-health.js +148 -0
  31. package/lib/providers/pool-usage.d.ts +57 -0
  32. package/lib/providers/pool-usage.js +130 -0
  33. package/lib/providers/pool.d.ts +107 -0
  34. package/lib/providers/pool.js +371 -0
  35. package/lib/tools/image-generate.d.ts +3 -3
  36. package/lib/tools/image-generate.js +2 -1
  37. package/lib/tools/video-generate.d.ts +2 -2
  38. package/lib/tools/video-generate.js +2 -1
  39. package/lib/tools/x-search.d.ts +2 -2
  40. package/lib/tools/x-search.js +2 -1
  41. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -939,6 +939,31 @@ const PROVIDER_IDS = [
939
939
  "copilot"
940
940
  ];
941
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
+ /**
942
967
  * Absolute path of the auth store file.
943
968
  * @returns `dshHomePath('plugins', 'subscriptions', 'auth.json')`.
944
969
  */
@@ -949,16 +974,17 @@ function authFilePath() {
949
974
  function legacyAuthFilePath() {
950
975
  return dshHomePath("plugins", "router", "auth.json");
951
976
  }
952
- /** Check that one durable entry carries the fields every session needs. */
953
- function assertSessionShape(provider, value) {
954
- 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`);
955
980
  const entry = value;
956
- 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`);
957
982
  }
958
983
  /**
959
984
  * Read the whole store. A missing file is an empty store; malformed JSON or a
960
985
  * malformed entry throws, because silently discarding tokens would strand the
961
- * user without a diagnosis.
986
+ * user without a diagnosis. Single-account entries are migrated in memory;
987
+ * the next write persists the new shape.
962
988
  * @param path - store file path; defaults to {@link authFilePath}.
963
989
  * @returns the parsed session map.
964
990
  */
@@ -982,7 +1008,7 @@ async function loadStore(path = authFilePath()) {
982
1008
  }
983
1009
  return parseStore(text, path);
984
1010
  }
985
- /** Parse and validate store JSON read from `path`. */
1011
+ /** Parse, validate, and migrate store JSON read from `path`. */
986
1012
  function parseStore(text, path) {
987
1013
  let parsed;
988
1014
  try {
@@ -991,10 +1017,28 @@ function parseStore(text, path) {
991
1017
  throw new Error(`subscriptions auth store at ${path} is not valid JSON; fix or delete the file`);
992
1018
  }
993
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`);
994
- const store = parsed;
1020
+ const raw = parsed;
1021
+ const store = {};
995
1022
  for (const provider of PROVIDER_IDS) {
996
- const entry = store[provider];
997
- 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;
998
1042
  }
999
1043
  return store;
1000
1044
  }
@@ -1014,8 +1058,8 @@ async function writeStore(store, path) {
1014
1058
  /**
1015
1059
  * One write chain per store path. Every mutation is a read-modify-write of a
1016
1060
  * single JSON file, and the plugin has several independent writers — a login,
1017
- * a logout, and one token refresh per provider adapter, each on its own
1018
- * 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
1019
1063
  * store first its entry.
1020
1064
  *
1021
1065
  * A chain is dropped once nothing is queued behind it, so the map holds an
@@ -1040,37 +1084,94 @@ async function serialize(path, action) {
1040
1084
  }
1041
1085
  }
1042
1086
  /**
1043
- * Read one provider's session.
1087
+ * List one provider's accounts, default first.
1044
1088
  * @param provider - the provider route.
1045
1089
  * @param path - store file path; defaults to {@link authFilePath}.
1046
- * @returns the stored session, or `undefined` when logged out.
1090
+ * @returns the account entries in stable order (empty when logged out).
1091
+ */
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;
1101
+ }
1102
+ /**
1103
+ * Read one account's session.
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.
1047
1108
  */
1048
- async function getSession(provider, path = authFilePath()) {
1049
- return (await loadStore(path))[provider];
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];
1050
1115
  }
1051
1116
  /**
1052
- * Write one provider's session, preserving the others.
1117
+ * Write one account's session, preserving the others. The first account of a
1118
+ * provider becomes its default.
1053
1119
  * @param provider - the provider route.
1120
+ * @param account - the account key (see {@link accountKeyOf}).
1054
1121
  * @param session - the fresh session from a login or refresh.
1055
1122
  * @param path - store file path; defaults to {@link authFilePath}.
1056
1123
  */
1057
- async function saveSession(provider, session, path = authFilePath()) {
1124
+ async function saveAccountSession(provider, account, session, path = authFilePath()) {
1058
1125
  return serialize(path, async () => {
1059
1126
  const store = await loadStore(path);
1060
- 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
+ };
1061
1135
  await writeStore(store, path);
1062
1136
  });
1063
1137
  }
1064
1138
  /**
1065
- * 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.
1066
1141
  * @param provider - the provider route.
1142
+ * @param account - the account key.
1067
1143
  * @param path - store file path; defaults to {@link authFilePath}.
1068
1144
  */
1069
- async function deleteSession(provider, path = authFilePath()) {
1145
+ async function deleteAccountSession(provider, account, path = authFilePath()) {
1070
1146
  return serialize(path, async () => {
1071
1147
  const store = await loadStore(path);
1072
- if (store[provider] === void 0) return;
1073
- 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
+ };
1074
1175
  await writeStore(store, path);
1075
1176
  });
1076
1177
  }
@@ -1126,6 +1227,14 @@ function readString(payload, field) {
1126
1227
  if (typeof value !== "string" || value.length === 0) throw new BadRequest(`payload.${field} must be a non-empty string`);
1127
1228
  return value;
1128
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
+ }
1129
1238
  /** Validate the `setSpeed` endpoint's tier. */
1130
1239
  function readSpeedTier(payload) {
1131
1240
  const tier = payload.tier;
@@ -1243,7 +1352,10 @@ async function dispatch(controller, speed, proxy, endpoint, payload, signal) {
1243
1352
  const entries = await Promise.all(PROVIDER_IDS.map(async (provider) => [provider, await controller.status(provider)]));
1244
1353
  return ok({ providers: Object.fromEntries(entries) });
1245
1354
  }
1246
- 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
+ }
1247
1359
  case "manual": {
1248
1360
  const provider = readProvider(payload);
1249
1361
  await controller.manual(provider, readString(payload, "input"));
@@ -1252,10 +1364,20 @@ async function dispatch(controller, speed, proxy, endpoint, payload, signal) {
1252
1364
  case "cancel":
1253
1365
  await controller.cancel(readProvider(payload));
1254
1366
  return ok({ ok: true });
1255
- case "logout":
1256
- 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"));
1257
1375
  return ok({ ok: true });
1258
- 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
+ }
1259
1381
  case "image": return ok(await controller.readImage(readImageRef(payload), signal));
1260
1382
  case "video": return ok(await controller.readVideo(readVideoName(payload), signal));
1261
1383
  case "speed": return ok(await speed.speed(readSessionId(payload)));
@@ -1494,6 +1616,34 @@ var TokenManager = class {
1494
1616
  return next;
1495
1617
  }
1496
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
+ }
1497
1647
  /** How long a discovered catalog is trusted before re-fetching. */
1498
1648
  const DISCOVERY_TTL_MS = 5 * 6e4;
1499
1649
  /**
@@ -1515,6 +1665,8 @@ var ModelCatalogCache = class {
1515
1665
  seeded;
1516
1666
  /** Set by {@link invalidate} so an in-flight disk read cannot resurrect dropped state. */
1517
1667
  seedDisabled = false;
1668
+ /** Bumped by {@link invalidate} so a loser in-flight fetch cannot write back. */
1669
+ generation = 0;
1518
1670
  constructor(persistence, ttlMs = DISCOVERY_TTL_MS) {
1519
1671
  this.persistence = persistence;
1520
1672
  this.ttlMs = ttlMs;
@@ -1545,7 +1697,10 @@ var ModelCatalogCache = class {
1545
1697
  }
1546
1698
  /** Run (or join) the single in-flight fetch, updating memory and disk on success. */
1547
1699
  refresh(fetcher) {
1548
- 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;
1549
1704
  const snapshot = {
1550
1705
  at: Date.now(),
1551
1706
  models
@@ -1554,9 +1709,10 @@ var ModelCatalogCache = class {
1554
1709
  this.persistence?.save(snapshot).catch(() => void 0);
1555
1710
  return models;
1556
1711
  }).finally(() => {
1557
- this.inflight = void 0;
1712
+ if (this.generation === gen) this.inflight = void 0;
1558
1713
  });
1559
- return this.inflight;
1714
+ this.inflight = pending;
1715
+ return pending;
1560
1716
  }
1561
1717
  /**
1562
1718
  * Return the cached catalog when fresh, otherwise fetch and cache it.
@@ -1594,7 +1750,9 @@ var ModelCatalogCache = class {
1594
1750
  }
1595
1751
  /** Drop the cached catalog (e.g. after a 401 proved the credential changed). */
1596
1752
  invalidate() {
1753
+ this.generation += 1;
1597
1754
  this.entry = void 0;
1755
+ this.inflight = void 0;
1598
1756
  this.seedDisabled = true;
1599
1757
  this.persistence?.clear().catch(() => void 0);
1600
1758
  }
@@ -1603,6 +1761,11 @@ var ModelCatalogCache = class {
1603
1761
  function isMissingOrInvalidCredential(error) {
1604
1762
  return error instanceof LlmError && (error.code === "MISSING_CREDENTIAL" || error.code === "INVALID_CREDENTIAL");
1605
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
+ }
1606
1769
  /** Whether discovery failed because the access token was rejected. */
1607
1770
  function isDiscoveryAuthFailure(error) {
1608
1771
  return error instanceof OAuthEndpointError && error.status === 401 || error instanceof LlmError && error.code === "AUTH";
@@ -1628,6 +1791,109 @@ async function discoverOrRetryAuth(session, catalog, run) {
1628
1791
  }
1629
1792
  }
1630
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
+
1631
1897
  //#endregion
1632
1898
  //#region src/providers/catalog-store.ts
1633
1899
  /**
@@ -1772,6 +2038,616 @@ function catalogStore(provider, path = modelsFilePath()) {
1772
2038
  };
1773
2039
  }
1774
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
+
1775
2651
  //#endregion
1776
2652
  //#region src/auth/jwt.ts
1777
2653
  /** Minimal JWT payload decoding for claims extraction (no signature verification). */
@@ -2508,16 +3384,20 @@ function supportsFastTier(entry) {
2508
3384
  * Fetch the live codex model catalog with the session's auth headers.
2509
3385
  * @param session - the stored session (used as-is; never refreshed here).
2510
3386
  * @param fetchFn - fetch implementation (injectable for tests).
3387
+ * @param signal - caller cancellation (pool-assembly timeout).
2511
3388
  * @returns discovered models: hidden entries dropped, sorted by priority.
2512
3389
  */
2513
- async function fetchCodexModels(session, fetchFn = proxiedFetch) {
2514
- const response = await fetchFn(`${CODEX_MODELS_URL}?client_version=${CODEX_CLIENT_VERSION}`, { headers: {
2515
- "authorization": `Bearer ${session.accessToken}`,
2516
- "chatgpt-account-id": session.accountId,
2517
- "originator": "codex_cli_rs",
2518
- "accept": "application/json",
2519
- ...attributionHeaders()
2520
- } });
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
+ });
2521
3401
  if (!response.ok) throw await oauthEndpointError(response, "codex models");
2522
3402
  const payload = await response.json();
2523
3403
  if (!Array.isArray(payload.models)) throw new Error("codex models endpoint returned no models array");
@@ -2621,14 +3501,43 @@ function codexRequestBody(options, resolved, fast) {
2621
3501
  /** Codex wire adapter: one instance serves the `codex` provider route. */
2622
3502
  var CodexAdapter = class extends LlmAdapter {
2623
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;
2624
3508
  constructor(options) {
2625
3509
  super();
2626
3510
  this.options = options;
2627
3511
  this.catalog = new ModelCatalogCache(options.catalogStore);
2628
3512
  }
2629
3513
  /** Discovery fetcher: resolves the session through the refresh-aware path. */
2630
- async fetchCatalog() {
2631
- 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;
2632
3541
  }
2633
3542
  providerInfo(provider) {
2634
3543
  return {
@@ -2645,17 +3554,37 @@ var CodexAdapter = class extends LlmAdapter {
2645
3554
  }));
2646
3555
  }
2647
3556
  async listModels(provider) {
2648
- 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 [];
2649
3575
  if (!this.options.discovery) return this.staticModels(provider);
3576
+ const catalog = await this.catalogFor(account);
2650
3577
  try {
2651
- 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) => ({
2652
3579
  provider,
2653
3580
  id: model.id,
2654
3581
  name: model.name,
2655
3582
  ...model.description === void 0 ? {} : { description: model.description },
2656
- inputModalities: CODEX_MODALITIES
3583
+ inputModalities: CODEX_MODALITIES,
3584
+ ...model.priority === void 0 ? {} : { priority: model.priority }
2657
3585
  }));
2658
3586
  } catch (error) {
3587
+ if (isDiscoveryAborted(error, signal)) throw error;
2659
3588
  if (isMissingOrInvalidCredential(error)) return [];
2660
3589
  this.options.onWarn?.(`codex model discovery failed; using the built-in catalog (${errorChain(error)})`);
2661
3590
  return this.staticModels(provider);
@@ -2670,7 +3599,9 @@ var CodexAdapter = class extends LlmAdapter {
2670
3599
  */
2671
3600
  async discovered(model) {
2672
3601
  if (!this.options.discovery) return void 0;
2673
- 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
+ });
2674
3605
  }
2675
3606
  /** Whether the discovered catalog advertises a fast tier for this model. */
2676
3607
  async supportsFastTier(model) {
@@ -2679,10 +3610,27 @@ var CodexAdapter = class extends LlmAdapter {
2679
3610
  /** Ids of every discovered model with a fast tier (the Speed toggle's visibility list). */
2680
3611
  async fastCapableModels() {
2681
3612
  if (!this.options.discovery) return [];
2682
- if (await this.options.tokens.peek() === void 0) return [];
2683
- 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;
2684
3626
  }
2685
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) {
2686
3634
  const discovered = await this.discovered(model);
2687
3635
  const configured = this.options.models.find((entry) => entry.id === model);
2688
3636
  return {
@@ -2700,12 +3648,24 @@ var CodexAdapter = class extends LlmAdapter {
2700
3648
  };
2701
3649
  }
2702
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) {
2703
3663
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
2704
3664
  try {
2705
- let session = await this.options.tokens.session();
3665
+ let session = await this.options.tokens.session(account);
2706
3666
  let response = await this.request(options, session, watchdog.signal);
2707
3667
  if (response.status === 401) {
2708
- session = await this.options.tokens.session(true);
3668
+ session = await this.options.tokens.session(account, true);
2709
3669
  response = await this.request(options, session, watchdog.signal);
2710
3670
  }
2711
3671
  if (!response.ok) throw await httpLlmError(response, "codex API");
@@ -3444,15 +4404,18 @@ function claudeReasoning(capabilities) {
3444
4404
  }));
3445
4405
  return efforts.length > 0 ? { efforts } : void 0;
3446
4406
  }
3447
- /** Fetch the live model catalog from the subscription endpoint. */
3448
- async function fetchClaudeModels(session, fetchFn = proxiedFetch) {
3449
- const response = await fetchFn(CLAUDE_MODELS_URL, { headers: {
3450
- "authorization": `Bearer ${session.accessToken}`,
3451
- "anthropic-version": "2023-06-01",
3452
- "user-agent": getClaudeCliUserAgent(),
3453
- "anthropic-dangerous-direct-browser-access": "true",
3454
- "accept": "application/json"
3455
- } });
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
+ });
3456
4419
  if (!response.ok) throw await httpLlmError(response, "claude models API");
3457
4420
  const payload = await response.json();
3458
4421
  if (!Array.isArray(payload.data)) throw new Error("claude models API returned an invalid catalog");
@@ -3512,17 +4475,48 @@ function claudeRequestBody(options, messages, maxTokens, thinking, effort) {
3512
4475
  /** Claude wire adapter: one instance serves the `claude` provider route. */
3513
4476
  var ClaudeAdapter = class extends LlmAdapter {
3514
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;
3515
4482
  constructor(options) {
3516
4483
  super();
3517
4484
  this.options = options;
3518
4485
  this.catalog = new ModelCatalogCache(options.catalogStore);
3519
4486
  }
3520
- async fetchCatalog() {
3521
- 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;
3522
4514
  }
3523
4515
  async discovered(model) {
3524
4516
  if (!this.options.discovery) return void 0;
3525
- 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
+ });
3526
4520
  }
3527
4521
  staticModels(provider) {
3528
4522
  return this.options.models.map((model) => ({
@@ -3551,22 +4545,47 @@ var ClaudeAdapter = class extends LlmAdapter {
3551
4545
  }, `claude: provider "${provider}" retryPolicy`);
3552
4546
  }
3553
4547
  async listModels(provider) {
3554
- 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 [];
3555
4566
  if (!this.options.discovery) return this.staticModels(provider);
4567
+ const catalog = await this.catalogFor(account);
3556
4568
  try {
3557
- 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) => ({
3558
4570
  provider,
3559
4571
  id: model.id,
3560
4572
  name: model.name,
3561
4573
  inputModalities: CLAUDE_MODALITIES
3562
4574
  }));
3563
4575
  } catch (error) {
4576
+ if (isDiscoveryAborted(error, signal)) throw error;
3564
4577
  if (isMissingOrInvalidCredential(error)) return [];
3565
4578
  this.options.onWarn?.(`claude model discovery failed; using the built-in catalog (${errorChain(error)})`);
3566
4579
  return this.staticModels(provider);
3567
4580
  }
3568
4581
  }
3569
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) {
3570
4589
  const disc = await this.discovered(model);
3571
4590
  const configured = this.options.models.find((entry) => entry.id === model);
3572
4591
  const reasoning = disc?.reasoning;
@@ -3581,12 +4600,24 @@ var ClaudeAdapter = class extends LlmAdapter {
3581
4600
  };
3582
4601
  }
3583
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) {
3584
4615
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
3585
4616
  try {
3586
- let session = await this.options.tokens.session();
4617
+ let session = await this.options.tokens.session(account);
3587
4618
  let response = await this.request(options, session, watchdog.signal);
3588
4619
  if (response.status === 401) {
3589
- session = await this.options.tokens.session(true);
4620
+ session = await this.options.tokens.session(account, true);
3590
4621
  response = await this.request(options, session, watchdog.signal);
3591
4622
  }
3592
4623
  if (!response.ok) throw await httpLlmError(response, "claude API");
@@ -3918,15 +4949,19 @@ function grokCliReasoning(entry) {
3918
4949
  * Fetch the CLI catalog and index its per-model metadata by model id.
3919
4950
  * @param session - the stored session (used as-is; never refreshed here).
3920
4951
  * @param fetchFn - fetch implementation (injectable for tests).
4952
+ * @param signal - caller cancellation (pool-assembly timeout).
3921
4953
  * @returns model id → contributed metadata.
3922
4954
  */
3923
- async function fetchGrokCliCatalog(session, fetchFn = proxiedFetch) {
3924
- const response = await fetchFn(GROK_CLI_MODELS_URL, { headers: {
3925
- "authorization": `Bearer ${session.accessToken}`,
3926
- "x-xai-token-auth": "xai-grok-cli",
3927
- "accept": "application/json",
3928
- ...attributionHeaders()
3929
- } });
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
+ });
3930
4965
  if (!response.ok) throw await oauthEndpointError(response, "grok CLI catalog");
3931
4966
  const payload = await response.json();
3932
4967
  if (!Array.isArray(payload.data)) throw new Error("grok CLI catalog returned no data array");
@@ -3979,15 +5014,20 @@ function grokPriorMeta(prior) {
3979
5014
  * @param onWarn - warning sink for a failed CLI catalog fetch.
3980
5015
  * @param previous - last-known catalog used to keep enrichment when the CLI
3981
5016
  * catalog is down or omits a model.
5017
+ * @param signal - caller cancellation (pool-assembly timeout).
3982
5018
  * @returns discovered chat models in endpoint order.
3983
5019
  */
3984
- async function fetchGrokModels(session, fetchFn = proxiedFetch, onWarn, previous) {
5020
+ async function fetchGrokModels(session, fetchFn = proxiedFetch, onWarn, previous, signal) {
3985
5021
  const previousById = previous === void 0 || previous.length === 0 ? void 0 : new Map(previous.map((model) => [model.id, model]));
3986
- const [response, cliCatalog] = await Promise.all([fetchFn(GROK_MODELS_URL, { headers: {
3987
- "authorization": `Bearer ${session.accessToken}`,
3988
- "accept": "application/json",
3989
- ...attributionHeaders()
3990
- } }), 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;
3991
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)})`);
3992
5032
  })]);
3993
5033
  if (!response.ok) throw await oauthEndpointError(response, "grok models");
@@ -4012,14 +5052,44 @@ async function fetchGrokModels(session, fetchFn = proxiedFetch, onWarn, previous
4012
5052
  /** Grok wire adapter: one instance serves the `grok` provider route. */
4013
5053
  var GrokAdapter = class extends LlmAdapter {
4014
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;
4015
5059
  constructor(options) {
4016
5060
  super();
4017
5061
  this.options = options;
4018
5062
  this.catalog = new ModelCatalogCache(options.catalogStore);
4019
5063
  }
4020
5064
  /** Discovery fetcher: resolves the session through the refresh-aware path. */
4021
- async fetchCatalog() {
4022
- 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;
4023
5093
  }
4024
5094
  listed(provider, discovered) {
4025
5095
  return discovered.map((model) => ({
@@ -4045,11 +5115,30 @@ var GrokAdapter = class extends LlmAdapter {
4045
5115
  }));
4046
5116
  }
4047
5117
  async listModels(provider) {
4048
- 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 [];
4049
5136
  if (!this.options.discovery) return this.staticModels(provider);
5137
+ const catalog = await this.catalogFor(account);
4050
5138
  try {
4051
- 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))));
4052
5140
  } catch (error) {
5141
+ if (isDiscoveryAborted(error, signal)) throw error;
4053
5142
  if (isMissingOrInvalidCredential(error)) return [];
4054
5143
  this.options.onWarn?.(`grok model discovery failed; using the built-in catalog (${errorChain(error)})`);
4055
5144
  return this.staticModels(provider);
@@ -4065,9 +5154,17 @@ var GrokAdapter = class extends LlmAdapter {
4065
5154
  */
4066
5155
  async discovered(model) {
4067
5156
  if (!this.options.discovery) return void 0;
4068
- 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
+ });
4069
5160
  }
4070
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) {
4071
5168
  const discovered = await this.discovered(model);
4072
5169
  const configured = this.options.models.find((entry) => entry.id === model);
4073
5170
  return {
@@ -4082,12 +5179,24 @@ var GrokAdapter = class extends LlmAdapter {
4082
5179
  };
4083
5180
  }
4084
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) {
4085
5194
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
4086
5195
  try {
4087
- let session = await this.options.tokens.session();
5196
+ let session = await this.options.tokens.session(account);
4088
5197
  let response = await this.request(options, session, watchdog.signal);
4089
5198
  if (response.status === 401) {
4090
- session = await this.options.tokens.session(true);
5199
+ session = await this.options.tokens.session(account, true);
4091
5200
  response = await this.request(options, session, watchdog.signal);
4092
5201
  }
4093
5202
  if (!response.ok) throw await httpLlmError(response, "grok API");
@@ -4694,14 +5803,18 @@ function copilotReasoning(entry) {
4694
5803
  * reasoning efforts (the endpoint discloses no default, so none is claimed).
4695
5804
  * @param session - the stored session (used as-is; never refreshed here).
4696
5805
  * @param fetchFn - fetch implementation (injectable for tests).
5806
+ * @param signal - caller cancellation (pool-assembly timeout).
4697
5807
  * @returns discovered chat models in endpoint order.
4698
5808
  */
4699
- async function fetchCopilotModels(session, fetchFn = proxiedFetch) {
4700
- const response = await fetchFn(COPILOT_MODELS_URL, { headers: {
4701
- "authorization": `Bearer ${session.accessToken}`,
4702
- "accept": "application/json",
4703
- ...copilotHeaders(false, await latestVsCodeVersion(fetchFn))
4704
- } });
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
+ });
4705
5818
  if (!response.ok) throw await oauthEndpointError(response, "copilot models");
4706
5819
  const payload = await response.json();
4707
5820
  if (!Array.isArray(payload.data)) throw new Error("copilot models endpoint returned no data array");
@@ -4923,6 +6036,10 @@ var CopilotResponsesItemNormalizer = class {
4923
6036
  /** Copilot wire adapter: one instance serves the `copilot` provider route. */
4924
6037
  var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4925
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;
4926
6043
  /**
4927
6044
  * [2026-08-23]-[a reasoning model continuing a tool chain must get its
4928
6045
  * reasoning back or it restarts from scratch every tool round trip; the
@@ -4945,8 +6062,33 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4945
6062
  this.catalog = new ModelCatalogCache(options.catalogStore);
4946
6063
  }
4947
6064
  /** Discovery fetcher: resolves the session through the refresh-aware path. */
4948
- async fetchCatalog() {
4949
- 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;
4950
6092
  }
4951
6093
  providerInfo(provider) {
4952
6094
  return {
@@ -4963,10 +6105,28 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4963
6105
  }));
4964
6106
  }
4965
6107
  async listModels(provider) {
4966
- 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 [];
4967
6126
  if (!this.options.discovery) return this.staticModels(provider);
6127
+ const catalog = await this.catalogFor(account);
4968
6128
  try {
4969
- 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) => ({
4970
6130
  provider,
4971
6131
  id: model.id,
4972
6132
  name: model.name,
@@ -4974,6 +6134,7 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4974
6134
  ...model.inputModalities === void 0 ? {} : { inputModalities: model.inputModalities }
4975
6135
  }));
4976
6136
  } catch (error) {
6137
+ if (isDiscoveryAborted(error, signal)) throw error;
4977
6138
  if (isMissingOrInvalidCredential(error)) return [];
4978
6139
  this.options.onWarn?.(`copilot model discovery failed; using the built-in catalog (${errorChain(error)})`);
4979
6140
  return this.staticModels(provider);
@@ -4987,7 +6148,9 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4987
6148
  */
4988
6149
  async discovered(model) {
4989
6150
  if (!this.options.discovery) return void 0;
4990
- 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
+ });
4991
6154
  }
4992
6155
  /**
4993
6156
  * [2026-08-23]-[a manually configured responses-only model combined with
@@ -5087,6 +6250,12 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
5087
6250
  this.replayByScope.clear();
5088
6251
  }
5089
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) {
5090
6259
  const discovered = await this.discovered(model);
5091
6260
  const configured = this.options.models.find((entry) => entry.id === model);
5092
6261
  return {
@@ -5101,15 +6270,27 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
5101
6270
  };
5102
6271
  }
5103
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) {
5104
6285
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
5105
6286
  try {
5106
6287
  const wire = copilotRequestWire(this.configuredWireEntry(options.model) ?? await this.discovered(options.model), options);
5107
- let session = await this.options.tokens.session();
6288
+ let session = await this.options.tokens.session(account);
5108
6289
  const scope = this.replayScope(session.refreshToken, options);
5109
6290
  let response = await this.request(options, session, watchdog.signal, wire, scope);
5110
6291
  if (response.status === 401) {
5111
6292
  await latestVsCodeVersion(this.options.fetchFn ?? proxiedFetch, true);
5112
- session = await this.options.tokens.session(true);
6293
+ session = await this.options.tokens.session(account, true);
5113
6294
  response = await this.request(options, session, watchdog.signal, wire, scope);
5114
6295
  }
5115
6296
  if (!response.ok) throw await httpLlmError(response, "copilot API");
@@ -5901,6 +7082,8 @@ const name = "dsh-plugin-subscriptions";
5901
7082
  const inject = ["llm"];
5902
7083
  /** Default maximum provider idle time while one stream read is outstanding. */
5903
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;
5904
7087
  const providerIdSchema = z.union([
5905
7088
  "codex",
5906
7089
  "claude",
@@ -5915,6 +7098,11 @@ const modelEntrySchema = z.object({
5915
7098
  inputModalities: z.array(z.union(["text", "image"])),
5916
7099
  wire: z.union(["chat-completions", "responses"])
5917
7100
  });
7101
+ const poolMemberSchema = z.object({
7102
+ provider: providerIdSchema.required(),
7103
+ account: z.string(),
7104
+ model: z.string().required()
7105
+ });
5918
7106
  const Config = z.object({
5919
7107
  providers: z.array(providerIdSchema).default([
5920
7108
  "codex",
@@ -5928,6 +7116,15 @@ const Config = z.object({
5928
7116
  claude: z.array(modelEntrySchema),
5929
7117
  grok: z.array(modelEntrySchema),
5930
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))
5931
7128
  })
5932
7129
  });
5933
7130
  /** Built-in catalogs used when the config does not override a provider's models. */
@@ -6034,6 +7231,15 @@ function accountOf(provider, session) {
6034
7231
  case "copilot": return session.account;
6035
7232
  }
6036
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
+ }
6037
7243
  /**
6038
7244
  * Auth operations behind the `/subscriptions-auth` RPC channel: start/complete
6039
7245
  * OAuth attempts in the background, feed pasted codes, cancel, log out, and
@@ -6075,10 +7281,10 @@ var SubscriptionsAuthController = class {
6075
7281
  this.usageFetchers = usageFetchers;
6076
7282
  this.readClaudeCreds = readClaudeCreds;
6077
7283
  }
6078
- usage(provider, signal) {
7284
+ usage(provider, account, signal) {
6079
7285
  const fetcher = this.usageFetchers[provider];
6080
7286
  if (fetcher === void 0) return Promise.resolve({ supported: false });
6081
- return fetcher(signal);
7287
+ return fetcher(account, signal);
6082
7288
  }
6083
7289
  async readImage(ref, signal) {
6084
7290
  const attachments = this.resolveAttachments();
@@ -6096,28 +7302,45 @@ var SubscriptionsAuthController = class {
6096
7302
  };
6097
7303
  }
6098
7304
  async status(provider) {
6099
- const session = await getSession(provider);
6100
- const account = accountOf(provider, session);
7305
+ const entries = await listAccounts(provider);
6101
7306
  const detail = this.lastError.get(provider);
6102
7307
  return {
6103
- loggedIn: session !== void 0,
6104
7308
  busy: this.flows.isBusy(provider) || this.deviceFlows.isBusy(provider) || this.finalizing.has(provider),
6105
- ...session === void 0 ? {} : { expiresAt: session.expiresAt },
6106
- ...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
+ }),
6107
7320
  ...detail === void 0 ? {} : { detail }
6108
7321
  };
6109
7322
  }
6110
- async login(provider) {
6111
- if (provider === "claude") {
7323
+ async login(provider, method) {
7324
+ if (provider === "claude" && method !== "oauth") {
6112
7325
  const imported = this.readClaudeCreds();
6113
7326
  if (imported !== void 0) {
6114
7327
  this.claim("claude");
6115
7328
  this.flows.pending("claude")?.cancel();
6116
- await this.persist("claude", imported);
7329
+ const session = {
7330
+ ...imported,
7331
+ keychainBound: true
7332
+ };
7333
+ await this.persist("claude", session);
6117
7334
  this.lastError.delete("claude");
6118
- this.onAuthChanged("claude");
7335
+ this.onAuthChanged("claude", accountKeyOf("claude", session));
6119
7336
  return { authorizeUrl: "" };
6120
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") {
6121
7344
  const attempt$1 = await this.flows.start("claude", claudeFlow);
6122
7345
  this.completions.set("claude", this.complete("claude", attempt$1, this.claim("claude")));
6123
7346
  return { authorizeUrl: attempt$1.authorizeUrl };
@@ -6159,7 +7382,7 @@ var SubscriptionsAuthController = class {
6159
7382
  if (this.claims.get(provider) !== claim) return;
6160
7383
  await this.persist(provider, session);
6161
7384
  this.lastError.delete(provider);
6162
- this.onAuthChanged(provider);
7385
+ this.onAuthChanged(provider, accountKeyOf(provider, session));
6163
7386
  } catch (error) {
6164
7387
  if (this.claims.get(provider) !== claim) return;
6165
7388
  if (!(error instanceof Error && error.message === "login cancelled")) this.lastError.set(provider, errorChain(error));
@@ -6171,7 +7394,7 @@ var SubscriptionsAuthController = class {
6171
7394
  const session = await completeCopilotLogin(await attempt.waitToken());
6172
7395
  await this.persist(provider, session);
6173
7396
  this.lastError.delete(provider);
6174
- this.onAuthChanged(provider);
7397
+ this.onAuthChanged(provider, accountKeyOf(provider, session));
6175
7398
  } catch (error) {
6176
7399
  if (!(error instanceof Error && error.message === "login cancelled")) this.lastError.set(provider, errorChain(error));
6177
7400
  } finally {
@@ -6187,12 +7410,7 @@ var SubscriptionsAuthController = class {
6187
7410
  }
6188
7411
  }
6189
7412
  persist(provider, session) {
6190
- switch (provider) {
6191
- case "codex": return saveSession("codex", session);
6192
- case "claude": return saveSession("claude", session);
6193
- case "grok": return saveSession("grok", session);
6194
- case "copilot": return saveSession("copilot", session);
6195
- }
7413
+ return saveAccountSession(provider, accountKeyOf(provider, session), session);
6196
7414
  }
6197
7415
  /**
6198
7416
  * Settle once no OAuth completion is running for a provider.
@@ -6216,13 +7434,17 @@ var SubscriptionsAuthController = class {
6216
7434
  this.deviceFlows.pending(provider)?.cancel();
6217
7435
  return Promise.resolve();
6218
7436
  }
6219
- async logout(provider) {
7437
+ async logout(provider, account) {
6220
7438
  this.claim(provider);
6221
7439
  this.flows.pending(provider)?.cancel();
6222
7440
  this.deviceFlows.pending(provider)?.cancel();
6223
- await deleteSession(provider);
7441
+ await deleteAccountSession(provider, account);
6224
7442
  this.lastError.delete(provider);
6225
- this.onAuthChanged(provider);
7443
+ this.onAuthChanged(provider, account);
7444
+ }
7445
+ async setDefault(provider, account) {
7446
+ await setDefaultAccount(provider, account);
7447
+ this.onAuthChanged(provider, account);
6226
7448
  }
6227
7449
  };
6228
7450
  function apply(ctx, config) {
@@ -6238,9 +7460,18 @@ function apply(ctx, config) {
6238
7460
  };
6239
7461
  const resolveAttachments = () => ctx.get("attachments");
6240
7462
  const handles = /* @__PURE__ */ new Map();
6241
- 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) => {
6242
7469
  if (provider === "copilot") copilotAdapter?.clearReplayState();
6243
- 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]);
6244
7475
  };
6245
7476
  let codexTokens;
6246
7477
  let claudeTokens;
@@ -6251,20 +7482,21 @@ function apply(ctx, config) {
6251
7482
  let copilotAdapter;
6252
7483
  for (const provider of providers) switch (provider) {
6253
7484
  case "codex": {
6254
- const tokens = new TokenManager({
7485
+ const tokens = new AccountTokenManager({
7486
+ provider: "codex",
6255
7487
  displayName: "ChatGPT (Codex)",
6256
- preemptMs: CODEX_PREEMPT_MS,
6257
- load: () => getSession("codex"),
6258
- save: (session) => saveSession("codex", session),
6259
- remove: () => deleteSession("codex"),
6260
- refresh: refreshCodex,
6261
- isPermanent: isCodexPermanentRefreshError,
6262
- onRemoved: () => {
6263
- authChanged("codex");
7488
+ makeOptions: () => ({
7489
+ preemptMs: CODEX_PREEMPT_MS,
7490
+ refresh: refreshCodex,
7491
+ isPermanent: isCodexPermanentRefreshError
7492
+ }),
7493
+ onAccountRemoved: (account) => {
7494
+ authChanged("codex", account);
6264
7495
  }
6265
7496
  });
6266
7497
  codexTokens = tokens;
6267
- usageFetchers.codex = async (signal) => fetchCodexUsage(await tokens.session(), proxiedFetch, signal);
7498
+ accountTokens.set("codex", tokens);
7499
+ usageFetchers.codex = async (account, signal) => fetchCodexUsage(await tokens.session(account), proxiedFetch, signal);
6268
7500
  let adapter;
6269
7501
  adapter = new CodexAdapter({
6270
7502
  models: catalog.codex,
@@ -6274,28 +7506,31 @@ function apply(ctx, config) {
6274
7506
  onWarn,
6275
7507
  resolveAttachments,
6276
7508
  catalogStore: catalogStore("codex"),
7509
+ pool: () => poolAdapter,
6277
7510
  speedFor: (sessionId, model) => sessionId !== void 0 && speedBySession.get(sessionId) === "fast" && adapter.supportsFastTier(model)
6278
7511
  });
6279
7512
  codexAdapter = adapter;
7513
+ adapters.set("codex", adapter);
6280
7514
  handles.set("codex", ctx.llm.registerAdapter(["codex"], adapter));
6281
7515
  break;
6282
7516
  }
6283
7517
  case "claude": {
6284
- const tokens = new TokenManager({
7518
+ const tokens = new AccountTokenManager({
7519
+ provider: "claude",
6285
7520
  displayName: "Claude (Subscription)",
6286
- preemptMs: CLAUDE_PREEMPT_MS,
6287
- load: () => getSession("claude"),
6288
- save: (session) => saveSession("claude", session),
6289
- remove: () => deleteSession("claude"),
6290
- refresh: (session) => refreshClaudeSynced(session, refreshClaude),
6291
- isPermanent: isClaudePermanentRefreshError,
6292
- onRemoved: () => {
6293
- 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);
6294
7528
  }
6295
7529
  });
6296
7530
  claudeTokens = tokens;
6297
- usageFetchers.claude = async (signal) => fetchClaudeUsage(await tokens.session(), proxiedFetch, signal);
6298
- 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({
6299
7534
  models: catalog.claude,
6300
7535
  streamIdleTimeoutMs,
6301
7536
  tokens,
@@ -6303,49 +7538,57 @@ function apply(ctx, config) {
6303
7538
  onWarn,
6304
7539
  maxRetries: 10,
6305
7540
  resolveAttachments,
6306
- catalogStore: catalogStore("claude")
6307
- })));
7541
+ catalogStore: catalogStore("claude"),
7542
+ pool: () => poolAdapter
7543
+ });
7544
+ adapters.set("claude", adapter);
7545
+ handles.set("claude", ctx.llm.registerAdapter(["claude"], adapter));
6308
7546
  break;
6309
7547
  }
6310
7548
  case "grok": {
6311
- const tokens = new TokenManager({
7549
+ const tokens = new AccountTokenManager({
7550
+ provider: "grok",
6312
7551
  displayName: "Grok (Subscription)",
6313
- preemptMs: GROK_PREEMPT_MS,
6314
- load: () => getSession("grok"),
6315
- save: (session) => saveSession("grok", session),
6316
- remove: () => deleteSession("grok"),
6317
- refresh: refreshGrok,
6318
- isPermanent: isGrokPermanentRefreshError,
6319
- onRemoved: () => {
6320
- authChanged("grok");
7552
+ makeOptions: () => ({
7553
+ preemptMs: GROK_PREEMPT_MS,
7554
+ refresh: refreshGrok,
7555
+ isPermanent: isGrokPermanentRefreshError
7556
+ }),
7557
+ onAccountRemoved: (account) => {
7558
+ authChanged("grok", account);
6321
7559
  }
6322
7560
  });
6323
7561
  grokTokens = tokens;
6324
- usageFetchers.grok = async (signal) => fetchGrokUsage(await tokens.session(), proxiedFetch, signal);
6325
- 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({
6326
7565
  models: catalog.grok,
6327
7566
  streamIdleTimeoutMs,
6328
7567
  tokens,
6329
7568
  discovery: !overridden.has("grok"),
6330
7569
  onWarn,
6331
7570
  resolveAttachments,
6332
- catalogStore: catalogStore("grok")
6333
- })));
7571
+ catalogStore: catalogStore("grok"),
7572
+ pool: () => poolAdapter
7573
+ });
7574
+ adapters.set("grok", adapter);
7575
+ handles.set("grok", ctx.llm.registerAdapter(["grok"], adapter));
6334
7576
  break;
6335
7577
  }
6336
7578
  case "copilot": {
6337
- const tokens = new TokenManager({
7579
+ const tokens = new AccountTokenManager({
7580
+ provider: "copilot",
6338
7581
  displayName: "GitHub Copilot",
6339
- preemptMs: COPILOT_PREEMPT_MS,
6340
- load: () => getSession("copilot"),
6341
- save: (session) => saveSession("copilot", session),
6342
- remove: () => deleteSession("copilot"),
6343
- refresh: refreshCopilot,
6344
- isPermanent: isCopilotPermanentRefreshError,
6345
- onRemoved: () => {
6346
- authChanged("copilot");
7582
+ makeOptions: () => ({
7583
+ preemptMs: COPILOT_PREEMPT_MS,
7584
+ refresh: refreshCopilot,
7585
+ isPermanent: isCopilotPermanentRefreshError
7586
+ }),
7587
+ onAccountRemoved: (account) => {
7588
+ authChanged("copilot", account);
6347
7589
  }
6348
7590
  });
7591
+ accountTokens.set("copilot", tokens);
6349
7592
  copilotAdapter = new CopilotAdapter({
6350
7593
  models: catalog.copilot,
6351
7594
  streamIdleTimeoutMs,
@@ -6353,12 +7596,77 @@ function apply(ctx, config) {
6353
7596
  discovery: !overridden.has("copilot"),
6354
7597
  onWarn,
6355
7598
  resolveAttachments,
6356
- catalogStore: catalogStore("copilot")
7599
+ catalogStore: catalogStore("copilot"),
7600
+ pool: () => poolAdapter
6357
7601
  });
7602
+ adapters.set("copilot", copilotAdapter);
6358
7603
  handles.set("copilot", ctx.llm.registerAdapter(["copilot"], copilotAdapter));
6359
7604
  break;
6360
7605
  }
6361
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
+ }
6362
7670
  registerAuthRpc(ctx, new SubscriptionsAuthController(flows, deviceFlows, authChanged, resolveAttachments, usageFetchers), {
6363
7671
  async speed(sessionId) {
6364
7672
  return {
@@ -6376,8 +7684,14 @@ function apply(ctx, config) {
6376
7684
  test: (payload) => proxyTestConnection(payload.url, payload.proxy)
6377
7685
  });
6378
7686
  if (claudeTokens !== void 0) {
7687
+ const tokens = claudeTokens;
6379
7688
  const syncTimer = setInterval(() => {
6380
- 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);
6381
7695
  }, 5 * 6e4);
6382
7696
  ctx.effect(() => () => {
6383
7697
  clearInterval(syncTimer);
@@ -6398,4 +7712,4 @@ function apply(ctx, config) {
6398
7712
  }
6399
7713
 
6400
7714
  //#endregion
6401
- 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 };