dsh-plugin-subscriptions 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +79 -5
  2. package/README.zh.md +78 -4
  3. package/lib/auth/rpc.d.ts +64 -13
  4. package/lib/auth/rpc.js +75 -10
  5. package/lib/auth/store.d.ts +75 -17
  6. package/lib/auth/store.js +148 -27
  7. package/lib/client/ImageGenerateToolview.d.ts +1 -1
  8. package/lib/client/SpeedSelect.d.ts +25 -2
  9. package/lib/client/SpeedSelect.js +10 -6
  10. package/lib/client/SubscriptionsSection.d.ts +83 -3
  11. package/lib/client/SubscriptionsSection.js +411 -62
  12. package/lib/client/VideoGenerateToolview.d.ts +1 -1
  13. package/lib/client/index.d.ts +1 -9
  14. package/lib/client/index.js +7 -4
  15. package/lib/client/locales.d.ts +46 -10
  16. package/lib/client/locales.js +46 -10
  17. package/lib/client.js +703 -132
  18. package/lib/client.js.map +1 -1
  19. package/lib/compat.d.ts +36 -0
  20. package/lib/compat.js +20 -0
  21. package/lib/index.d.ts +26 -1
  22. package/lib/index.js +2377 -309
  23. package/lib/model-defaults.d.ts +23 -0
  24. package/lib/model-defaults.js +237 -0
  25. package/lib/providers/accounts.d.ts +102 -0
  26. package/lib/providers/accounts.js +123 -0
  27. package/lib/providers/claude.d.ts +46 -7
  28. package/lib/providers/claude.js +125 -34
  29. package/lib/providers/codex.d.ts +45 -3
  30. package/lib/providers/codex.js +152 -26
  31. package/lib/providers/common.d.ts +87 -6
  32. package/lib/providers/common.js +185 -22
  33. package/lib/providers/copilot.d.ts +32 -3
  34. package/lib/providers/copilot.js +111 -19
  35. package/lib/providers/grok.d.ts +45 -4
  36. package/lib/providers/grok.js +136 -20
  37. package/lib/providers/pool-family.d.ts +56 -0
  38. package/lib/providers/pool-family.js +45 -0
  39. package/lib/providers/pool-health.d.ts +74 -0
  40. package/lib/providers/pool-health.js +148 -0
  41. package/lib/providers/pool-usage.d.ts +78 -0
  42. package/lib/providers/pool-usage.js +185 -0
  43. package/lib/providers/pool.d.ts +107 -0
  44. package/lib/providers/pool.js +371 -0
  45. package/lib/providers/rate-limit.d.ts +192 -0
  46. package/lib/providers/rate-limit.js +338 -0
  47. package/lib/tools/image-generate.d.ts +3 -3
  48. package/lib/tools/image-generate.js +2 -1
  49. package/lib/tools/video-generate.d.ts +2 -2
  50. package/lib/tools/video-generate.js +2 -1
  51. package/lib/tools/x-search.d.ts +2 -2
  52. package/lib/tools/x-search.js +2 -1
  53. package/lib/translate/anthropic.js +5 -4
  54. package/lib/translate/chat-completions.js +5 -4
  55. package/lib/translate/responses.js +5 -4
  56. package/package.json +21 -21
  57. package/lib/providers/antigravity.d.ts +0 -90
  58. package/lib/providers/antigravity.js +0 -392
  59. package/lib/translate/antigravity.d.ts +0 -110
  60. package/lib/translate/antigravity.js +0 -303
package/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
- import { CONTEXT_WINDOW_EXCEEDED_CODE, CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, ReasoningEffortId, attributionHeaders, errorChain, isContextWindowExceededError, isQuotaExceededError, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
2
+ import * as llm from "@deepseek-ai/dsh-llm";
3
+ import { CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, ReasoningEffortId, attributionHeaders, errorChain, isContextWindowExceededError, isQuotaExceededError, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
3
4
  import { createServer } from "node:http";
4
5
  import { createHash, randomBytes, randomUUID } from "node:crypto";
5
6
  import { ProxyAgent, fetch as fetch$1 } from "undici";
@@ -271,13 +272,13 @@ const DISABLED = {
271
272
  bypass: []
272
273
  };
273
274
  /** Current config; updated by every load/apply/save. */
274
- let current = DISABLED;
275
+ let current$1 = DISABLED;
275
276
  /** The live dispatcher, or undefined when proxies are off/errored. */
276
277
  let agent;
277
278
  /** Last load/apply failure, surfaced by the config view. */
278
279
  let configError;
279
280
  /** One lazy load of the on-disk config (module-import cheap; file read once). */
280
- let ready;
281
+ let ready$1;
281
282
  /** Absolute path of the proxy config file. */
282
283
  function proxyFilePath() {
283
284
  return dshHomePath("plugins", "subscriptions", "proxy.json");
@@ -392,7 +393,7 @@ async function applyConfig(cfg) {
392
393
  withError(error);
393
394
  next = void 0;
394
395
  }
395
- current = cfg;
396
+ current$1 = cfg;
396
397
  }
397
398
  const previous = agent;
398
399
  agent = next;
@@ -432,16 +433,16 @@ async function loadConfigFile(path) {
432
433
  });
433
434
  }
434
435
  /** Resolve the module state once from disk; failures disable the proxy. */
435
- async function ensureReady() {
436
- ready ??= loadConfigFile(proxyFilePath()).then(async (cfg) => {
436
+ async function ensureReady$1() {
437
+ ready$1 ??= loadConfigFile(proxyFilePath()).then(async (cfg) => {
437
438
  await applyConfig(cfg);
438
- return current;
439
+ return current$1;
439
440
  }, async (error) => {
440
441
  withError(error);
441
442
  await applyConfig(void 0);
442
- return current;
443
+ return current$1;
443
444
  });
444
- return ready;
445
+ return ready$1;
445
446
  }
446
447
  /** Persist a config atomically with owner-only permissions, then apply it. */
447
448
  async function persistConfig(cfg, path) {
@@ -462,13 +463,13 @@ async function persistConfig(cfg, path) {
462
463
  * load/apply failure when the stored config is unusable.
463
464
  */
464
465
  async function proxyGetConfig() {
465
- await ensureReady();
466
+ await ensureReady$1();
466
467
  return {
467
- enabled: current.enabled,
468
- url: current.url,
469
- ...current.username === void 0 ? {} : { username: current.username },
470
- passwordSet: current.password !== void 0 && current.password !== "",
471
- bypass: [...current.bypass],
468
+ enabled: current$1.enabled,
469
+ url: current$1.url,
470
+ ...current$1.username === void 0 ? {} : { username: current$1.username },
471
+ passwordSet: current$1.password !== void 0 && current$1.password !== "",
472
+ bypass: [...current$1.bypass],
472
473
  ...configError === void 0 ? {} : { error: configError }
473
474
  };
474
475
  }
@@ -479,14 +480,14 @@ async function proxyGetConfig() {
479
480
  * @returns the resulting view (secrets omitted).
480
481
  */
481
482
  async function proxySetConfig(input) {
482
- await ensureReady();
483
- const password = input.password === void 0 ? current.password : input.password === null || input.password === "" ? void 0 : input.password;
483
+ await ensureReady$1();
484
+ const password = input.password === void 0 ? current$1.password : input.password === null || input.password === "" ? void 0 : input.password;
484
485
  const next = normalizeConfig({
485
486
  enabled: input.enabled,
486
487
  url: input.url,
487
488
  ...input.username === void 0 ? {} : { username: input.username },
488
489
  ...password === void 0 ? {} : { password },
489
- bypass: input.bypass ?? current.bypass
490
+ bypass: input.bypass ?? current$1.bypass
490
491
  });
491
492
  await persistConfig(next, proxyFilePath());
492
493
  await applyConfig(next);
@@ -502,16 +503,16 @@ async function proxySetConfig(input) {
502
503
  * host's global fetch.
503
504
  */
504
505
  async function proxiedFetch(input, init = {}) {
505
- await ensureReady();
506
+ await ensureReady$1();
506
507
  let dispatcher;
507
- if (current.enabled && agent !== void 0) {
508
+ if (current$1.enabled && agent !== void 0) {
508
509
  let hostname = "";
509
510
  try {
510
511
  hostname = (typeof input === "string" ? new URL(input) : input instanceof URL ? input : new URL(input.url)).hostname;
511
512
  } catch {
512
513
  hostname = "";
513
514
  }
514
- if (!matchesBypass(hostname, current.bypass)) dispatcher = agent;
515
+ if (!matchesBypass(hostname, current$1.bypass)) dispatcher = agent;
515
516
  }
516
517
  if (dispatcher === void 0) return fetch(input, init);
517
518
  return dispatchFetch(input, {
@@ -544,7 +545,7 @@ async function proxyTestConnection(target = DEFAULT_PROXY_TEST_URL, draft) {
544
545
  error: errorMessage(error)
545
546
  };
546
547
  }
547
- await ensureReady();
548
+ await ensureReady$1();
548
549
  let probeAgent;
549
550
  let viaProxy;
550
551
  let closeProbe = false;
@@ -566,7 +567,7 @@ async function proxyTestConnection(target = DEFAULT_PROXY_TEST_URL, draft) {
566
567
  };
567
568
  }
568
569
  else {
569
- viaProxy = current.enabled && agent !== void 0 && !matchesBypass(parsed.hostname, current.bypass);
570
+ viaProxy = current$1.enabled && agent !== void 0 && !matchesBypass(parsed.hostname, current$1.bypass);
570
571
  probeAgent = viaProxy ? agent : void 0;
571
572
  }
572
573
  const started = Date.now();
@@ -939,6 +940,31 @@ const PROVIDER_IDS = [
939
940
  "copilot"
940
941
  ];
941
942
  /**
943
+ * The stable identity of one session's account: codex keys on the always
944
+ * present `accountId` claim, the others on their display identity, falling
945
+ * back to a refresh-token hash for sessions stored before identity fields
946
+ * existed. Logging the same account in again lands on the same key, so a
947
+ * re-login updates in place instead of duplicating. (The hash fallback can
948
+ * miss that dedup once for a legacy session re-logged with a now-known
949
+ * identity — the duplicate is visible on the Settings page and can simply
950
+ * be logged out.)
951
+ * @param provider - the provider route.
952
+ * @param session - the session to key.
953
+ * @returns the account map key.
954
+ */
955
+ function accountKeyOf(provider, session) {
956
+ switch (provider) {
957
+ case "codex": return session.accountId;
958
+ case "claude": return session.emailAddress ?? tokenHash(session.refreshToken);
959
+ case "grok": return session.account ?? tokenHash(session.refreshToken);
960
+ case "copilot": return session.account ?? tokenHash(session.refreshToken);
961
+ }
962
+ }
963
+ /** Short stable hash for sessions without an identity field. */
964
+ function tokenHash(refreshToken) {
965
+ return `token-${createHash("sha256").update(refreshToken).digest("hex").slice(0, 16)}`;
966
+ }
967
+ /**
942
968
  * Absolute path of the auth store file.
943
969
  * @returns `dshHomePath('plugins', 'subscriptions', 'auth.json')`.
944
970
  */
@@ -949,16 +975,17 @@ function authFilePath() {
949
975
  function legacyAuthFilePath() {
950
976
  return dshHomePath("plugins", "router", "auth.json");
951
977
  }
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`);
978
+ /** Check that one durable session carries the fields every session needs. */
979
+ function assertSessionShape(provider, account, value) {
980
+ 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
981
  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`);
982
+ 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
983
  }
958
984
  /**
959
985
  * Read the whole store. A missing file is an empty store; malformed JSON or a
960
986
  * malformed entry throws, because silently discarding tokens would strand the
961
- * user without a diagnosis.
987
+ * user without a diagnosis. Single-account entries are migrated in memory;
988
+ * the next write persists the new shape.
962
989
  * @param path - store file path; defaults to {@link authFilePath}.
963
990
  * @returns the parsed session map.
964
991
  */
@@ -982,7 +1009,7 @@ async function loadStore(path = authFilePath()) {
982
1009
  }
983
1010
  return parseStore(text, path);
984
1011
  }
985
- /** Parse and validate store JSON read from `path`. */
1012
+ /** Parse, validate, and migrate store JSON read from `path`. */
986
1013
  function parseStore(text, path) {
987
1014
  let parsed;
988
1015
  try {
@@ -991,10 +1018,28 @@ function parseStore(text, path) {
991
1018
  throw new Error(`subscriptions auth store at ${path} is not valid JSON; fix or delete the file`);
992
1019
  }
993
1020
  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;
1021
+ const raw = parsed;
1022
+ const store = {};
995
1023
  for (const provider of PROVIDER_IDS) {
996
- const entry = store[provider];
997
- if (entry !== void 0) assertSessionShape(provider, entry);
1024
+ const entry = raw[provider];
1025
+ if (entry === void 0) continue;
1026
+ 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`);
1027
+ const record = entry;
1028
+ if (typeof record.accessToken === "string") {
1029
+ assertSessionShape(provider, "(legacy)", record);
1030
+ const session = record;
1031
+ const key = accountKeyOf(provider, session);
1032
+ store[provider] = {
1033
+ default: key,
1034
+ accounts: { [key]: session }
1035
+ };
1036
+ continue;
1037
+ }
1038
+ const accounts = record.accounts;
1039
+ 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`);
1040
+ 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`);
1041
+ for (const [account, session] of Object.entries(accounts)) assertSessionShape(provider, account, session);
1042
+ store[provider] = record;
998
1043
  }
999
1044
  return store;
1000
1045
  }
@@ -1014,8 +1059,8 @@ async function writeStore(store, path) {
1014
1059
  /**
1015
1060
  * One write chain per store path. Every mutation is a read-modify-write of a
1016
1061
  * 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
1062
+ * a logout, and one token refresh per provider account, each on its own
1063
+ * schedule. Overlapping them unserialized costs whichever account read the
1019
1064
  * store first its entry.
1020
1065
  *
1021
1066
  * A chain is dropped once nothing is queued behind it, so the map holds an
@@ -1040,37 +1085,94 @@ async function serialize(path, action) {
1040
1085
  }
1041
1086
  }
1042
1087
  /**
1043
- * Read one provider's session.
1088
+ * List one provider's accounts, default first.
1089
+ * @param provider - the provider route.
1090
+ * @param path - store file path; defaults to {@link authFilePath}.
1091
+ * @returns the account entries in stable order (empty when logged out).
1092
+ */
1093
+ async function listAccounts(provider, path = authFilePath()) {
1094
+ const entry = (await loadStore(path))[provider];
1095
+ if (entry === void 0) return [];
1096
+ const accounts = Object.entries(entry.accounts).map(([key, session]) => ({
1097
+ key,
1098
+ session
1099
+ }));
1100
+ accounts.sort((a, b) => Number(b.key === entry.default) - Number(a.key === entry.default));
1101
+ return accounts;
1102
+ }
1103
+ /**
1104
+ * Read one account's session.
1044
1105
  * @param provider - the provider route.
1106
+ * @param account - the account key; defaults to the provider's default account.
1045
1107
  * @param path - store file path; defaults to {@link authFilePath}.
1046
- * @returns the stored session, or `undefined` when logged out.
1108
+ * @returns the stored session, or `undefined` when absent.
1047
1109
  */
1048
- async function getSession(provider, path = authFilePath()) {
1049
- return (await loadStore(path))[provider];
1110
+ async function getAccountSession(provider, account, path = authFilePath()) {
1111
+ const entry = (await loadStore(path))[provider];
1112
+ if (entry === void 0) return void 0;
1113
+ const key = account ?? entry.default;
1114
+ if (key === void 0) return void 0;
1115
+ return entry.accounts[key];
1050
1116
  }
1051
1117
  /**
1052
- * Write one provider's session, preserving the others.
1118
+ * Write one account's session, preserving the others. The first account of a
1119
+ * provider becomes its default.
1053
1120
  * @param provider - the provider route.
1121
+ * @param account - the account key (see {@link accountKeyOf}).
1054
1122
  * @param session - the fresh session from a login or refresh.
1055
1123
  * @param path - store file path; defaults to {@link authFilePath}.
1056
1124
  */
1057
- async function saveSession(provider, session, path = authFilePath()) {
1125
+ async function saveAccountSession(provider, account, session, path = authFilePath()) {
1058
1126
  return serialize(path, async () => {
1059
1127
  const store = await loadStore(path);
1060
- store[provider] = session;
1128
+ const entry = store[provider];
1129
+ store[provider] = {
1130
+ default: entry?.default ?? account,
1131
+ accounts: {
1132
+ ...entry?.accounts,
1133
+ [account]: session
1134
+ }
1135
+ };
1136
+ await writeStore(store, path);
1137
+ });
1138
+ }
1139
+ /**
1140
+ * Delete one account's session (logout). Deleting the default moves the badge
1141
+ * to the next remaining account.
1142
+ * @param provider - the provider route.
1143
+ * @param account - the account key.
1144
+ * @param path - store file path; defaults to {@link authFilePath}.
1145
+ */
1146
+ async function deleteAccountSession(provider, account, path = authFilePath()) {
1147
+ return serialize(path, async () => {
1148
+ const store = await loadStore(path);
1149
+ const entry = store[provider];
1150
+ if (entry === void 0 || !(account in entry.accounts)) return;
1151
+ const accounts = { ...entry.accounts };
1152
+ delete accounts[account];
1153
+ if (Object.keys(accounts).length === 0) delete store[provider];
1154
+ else store[provider] = {
1155
+ ...entry.default === account ? { default: Object.keys(accounts)[0] } : { default: entry.default },
1156
+ accounts
1157
+ };
1061
1158
  await writeStore(store, path);
1062
1159
  });
1063
1160
  }
1064
1161
  /**
1065
- * Delete one provider's session (logout).
1162
+ * Pin the account direct (non-pool) routes serve.
1066
1163
  * @param provider - the provider route.
1164
+ * @param account - the account key; must exist.
1067
1165
  * @param path - store file path; defaults to {@link authFilePath}.
1068
1166
  */
1069
- async function deleteSession(provider, path = authFilePath()) {
1167
+ async function setDefaultAccount(provider, account, path = authFilePath()) {
1070
1168
  return serialize(path, async () => {
1071
1169
  const store = await loadStore(path);
1072
- if (store[provider] === void 0) return;
1073
- delete store[provider];
1170
+ const entry = store[provider];
1171
+ if (entry === void 0 || !(account in entry.accounts)) throw new Error(`no ${provider} account "${account}" is logged in`);
1172
+ store[provider] = {
1173
+ ...entry,
1174
+ default: account
1175
+ };
1074
1176
  await writeStore(store, path);
1075
1177
  });
1076
1178
  }
@@ -1126,6 +1228,30 @@ function readString(payload, field) {
1126
1228
  if (typeof value !== "string" || value.length === 0) throw new BadRequest(`payload.${field} must be a non-empty string`);
1127
1229
  return value;
1128
1230
  }
1231
+ /** Validate the `setModelDefault` endpoint's payload. */
1232
+ function readModelDefaultInput(payload) {
1233
+ const provider = readProvider(payload);
1234
+ const model = readString(payload, "model");
1235
+ const record = payload;
1236
+ let effort;
1237
+ if (record.effort !== void 0) {
1238
+ if (typeof record.effort !== "string" || record.effort.length === 0) throw new BadRequest("payload.effort must be a non-empty string when present");
1239
+ effort = record.effort;
1240
+ }
1241
+ return {
1242
+ provider,
1243
+ model,
1244
+ ...effort === void 0 ? {} : { effort }
1245
+ };
1246
+ }
1247
+ /** Validate the optional Claude login method. */
1248
+ function readLoginMethod(payload, provider) {
1249
+ const method = payload.method;
1250
+ if (method === void 0) return void 0;
1251
+ if (provider !== "claude") throw new BadRequest("payload.method is only valid for claude");
1252
+ if (method !== "oauth" && method !== "keychain") throw new BadRequest("payload.method must be \"oauth\" or \"keychain\"");
1253
+ return method;
1254
+ }
1129
1255
  /** Validate the `setSpeed` endpoint's tier. */
1130
1256
  function readSpeedTier(payload) {
1131
1257
  const tier = payload.tier;
@@ -1170,6 +1296,14 @@ function readVideoName(payload) {
1170
1296
  if (typeof name$1 !== "string" || !VIDEO_NAME_PATTERN.test(name$1)) throw new BadRequest("payload.name must be a bare .mp4 file name");
1171
1297
  return name$1;
1172
1298
  }
1299
+ /** Validate the `usage` endpoint's optional force flag. */
1300
+ function readForce(payload) {
1301
+ if (typeof payload !== "object" || payload === null) return false;
1302
+ const force = payload.force;
1303
+ if (force === void 0) return false;
1304
+ if (typeof force !== "boolean") throw new BadRequest("payload.force must be a boolean when present");
1305
+ return force;
1306
+ }
1173
1307
  /** Validate the session id both speed endpoints carry. */
1174
1308
  function readSessionId(payload) {
1175
1309
  if (typeof payload !== "object" || payload === null) throw new BadRequest("payload must be an object");
@@ -1237,13 +1371,16 @@ function readProxyTestPayload(payload) {
1237
1371
  ...proxy === void 0 ? {} : { proxy }
1238
1372
  };
1239
1373
  }
1240
- async function dispatch(controller, speed, proxy, endpoint, payload, signal) {
1374
+ async function dispatch(controller, speed, proxy, modelDefaults, endpoint, payload, signal) {
1241
1375
  switch (endpoint) {
1242
1376
  case "status": {
1243
1377
  const entries = await Promise.all(PROVIDER_IDS.map(async (provider) => [provider, await controller.status(provider)]));
1244
1378
  return ok({ providers: Object.fromEntries(entries) });
1245
1379
  }
1246
- case "login": return ok(await controller.login(readProvider(payload)));
1380
+ case "login": {
1381
+ const provider = readProvider(payload);
1382
+ return ok(await controller.login(provider, readLoginMethod(payload, provider)));
1383
+ }
1247
1384
  case "manual": {
1248
1385
  const provider = readProvider(payload);
1249
1386
  await controller.manual(provider, readString(payload, "input"));
@@ -1252,10 +1389,20 @@ async function dispatch(controller, speed, proxy, endpoint, payload, signal) {
1252
1389
  case "cancel":
1253
1390
  await controller.cancel(readProvider(payload));
1254
1391
  return ok({ ok: true });
1255
- case "logout":
1256
- await controller.logout(readProvider(payload));
1392
+ case "logout": {
1393
+ const provider = readProvider(payload);
1394
+ await controller.logout(provider, readString(payload, "account"));
1395
+ return ok({ ok: true });
1396
+ }
1397
+ case "setDefault": {
1398
+ const provider = readProvider(payload);
1399
+ await controller.setDefault(provider, readString(payload, "account"));
1257
1400
  return ok({ ok: true });
1258
- case "usage": return ok(await controller.usage(readProvider(payload), signal));
1401
+ }
1402
+ case "usage": {
1403
+ const provider = readProvider(payload);
1404
+ return ok(await controller.usage(provider, readString(payload, "account"), signal, readForce(payload)));
1405
+ }
1259
1406
  case "image": return ok(await controller.readImage(readImageRef(payload), signal));
1260
1407
  case "video": return ok(await controller.readVideo(readVideoName(payload), signal));
1261
1408
  case "speed": return ok(await speed.speed(readSessionId(payload)));
@@ -1271,6 +1418,16 @@ async function dispatch(controller, speed, proxy, endpoint, payload, signal) {
1271
1418
  case "proxyTest":
1272
1419
  if (proxy === void 0) throw new BadRequest("proxy configuration is unavailable");
1273
1420
  return ok(await proxy.test(readProxyTestPayload(payload)));
1421
+ case "modelDefaults":
1422
+ if (modelDefaults === void 0) throw new BadRequest("model defaults are unavailable");
1423
+ return ok(await modelDefaults.catalog());
1424
+ case "setModelDefault":
1425
+ if (modelDefaults === void 0) throw new BadRequest("model defaults are unavailable");
1426
+ {
1427
+ const input = readModelDefaultInput(payload);
1428
+ await modelDefaults.set(input.provider, input.model, input.effort);
1429
+ }
1430
+ return ok({ ok: true });
1274
1431
  default: throw new BadRequest(`unknown /subscriptions-auth endpoint "${endpoint}"`);
1275
1432
  }
1276
1433
  }
@@ -1280,13 +1437,14 @@ async function dispatch(controller, speed, proxy, endpoint, payload, signal) {
1280
1437
  * @param controller - the auth operations backing the endpoints.
1281
1438
  * @param speed - the per-session speed-tier state backing the Speed toggle.
1282
1439
  * @param proxy - optional proxy-config controller backing `proxyGet`/`proxySet`/`proxyTest`.
1440
+ * @param modelDefaults - optional per-model default-effort state backing `modelDefaults`/`setModelDefault`.
1283
1441
  */
1284
- function registerAuthRpc(ctx, controller, speed, proxy = void 0) {
1442
+ function registerAuthRpc(ctx, controller, speed, proxy = void 0, modelDefaults = void 0) {
1285
1443
  ctx.inject(["connection"], (ctx$1) => {
1286
1444
  const connection = ctx$1.get("connection");
1287
1445
  ctx$1.effect(() => connection.rpc.handle(SUBSCRIPTIONS_AUTH_CHANNEL, async (endpoint, payload, signal) => {
1288
1446
  try {
1289
- return await dispatch(controller, speed, proxy, endpoint, payload, signal);
1447
+ return await dispatch(controller, speed, proxy, modelDefaults, endpoint, payload, signal);
1290
1448
  } catch (error) {
1291
1449
  return failure(error);
1292
1450
  }
@@ -1294,6 +1452,442 @@ function registerAuthRpc(ctx, controller, speed, proxy = void 0) {
1294
1452
  });
1295
1453
  }
1296
1454
 
1455
+ //#endregion
1456
+ //#region src/model-defaults.ts
1457
+ /** Absolute path of the defaults file. */
1458
+ function modelDefaultsFilePath() {
1459
+ return dshHomePath("plugins", "subscriptions", "model-defaults.json");
1460
+ }
1461
+ const EMPTY = Object.freeze({});
1462
+ /** In-memory snapshot read by every consumer (adapters, RPC). */
1463
+ let current = EMPTY;
1464
+ /** One lazy load of the on-disk file (read once per process). */
1465
+ let ready;
1466
+ /** Last load failure, surfaced to callers that care; defaults stay empty. */
1467
+ let loadError;
1468
+ /**
1469
+ * Serialises every write: the read-modify-write sequence must not interleave,
1470
+ * or a fast second save would compute its snapshot from the stale `current`
1471
+ * and silently drop the first update (the UI disables only the row being
1472
+ * saved, so overlaps are reachable).
1473
+ */
1474
+ let writeChain = Promise.resolve();
1475
+ /**
1476
+ * Validate one persisted provider section: a string→string map, or undefined.
1477
+ * Malformed *entries* are skipped, not the whole section: one bad value (a
1478
+ * hand edit losing its quotes) must not silently un-configure every model in
1479
+ * that provider. What was dropped is reported so the caller can surface it
1480
+ * instead of the loss disappearing.
1481
+ */
1482
+ function sanitizeProvider(value, dropped) {
1483
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
1484
+ const entries = {};
1485
+ for (const [model, effort] of Object.entries(value)) {
1486
+ if (typeof effort !== "string" || effort.length === 0) {
1487
+ dropped.push(model);
1488
+ continue;
1489
+ }
1490
+ entries[model] = effort;
1491
+ }
1492
+ if (Object.keys(entries).length === 0) return void 0;
1493
+ return Object.freeze(entries);
1494
+ }
1495
+ /** Validate the raw document: only known providers, malformed sections dropped. */
1496
+ function sanitizeDefaults(value) {
1497
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return {
1498
+ defaults: EMPTY,
1499
+ dropped: []
1500
+ };
1501
+ const record = value;
1502
+ const result = {};
1503
+ const dropped = [];
1504
+ for (const provider of PROVIDER_IDS) {
1505
+ const section = sanitizeProvider(record[provider], dropped);
1506
+ if (section !== void 0) result[provider] = section;
1507
+ }
1508
+ return {
1509
+ defaults: Object.freeze(result),
1510
+ dropped
1511
+ };
1512
+ }
1513
+ /** Read and validate the on-disk file; a missing file reads as empty. */
1514
+ async function loadFile(path) {
1515
+ let text;
1516
+ try {
1517
+ text = await readFile(path, "utf8");
1518
+ } catch (error) {
1519
+ if (error.code === "ENOENT") return EMPTY;
1520
+ throw error;
1521
+ }
1522
+ try {
1523
+ const { defaults, dropped } = sanitizeDefaults(JSON.parse(text));
1524
+ if (dropped.length > 0) loadError = /* @__PURE__ */ new Error(`subscriptions model defaults: ${dropped.length} malformed entr${dropped.length === 1 ? "y" : "ies"} skipped (${dropped.join(", ")}); fix or delete the file`);
1525
+ return defaults;
1526
+ } catch {
1527
+ throw new Error(`subscriptions model defaults at ${path} are not valid JSON; fix or delete the file`);
1528
+ }
1529
+ }
1530
+ /** Resolve the module state once from disk; failures leave the defaults empty. */
1531
+ async function ensureReady() {
1532
+ ready ??= loadFile(modelDefaultsFilePath()).then((loaded) => {
1533
+ current = loaded;
1534
+ }, (error) => {
1535
+ loadError = error;
1536
+ current = EMPTY;
1537
+ });
1538
+ return ready;
1539
+ }
1540
+ /** Persist a snapshot atomically with owner-only permissions. */
1541
+ async function atomicPersist(defaults, path) {
1542
+ await mkdir(dirname(path), { recursive: true });
1543
+ const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
1544
+ try {
1545
+ await writeFile(tmp, JSON.stringify(defaults, null, 2), { mode: 384 });
1546
+ await chmod(tmp, 384);
1547
+ await rename(tmp, path);
1548
+ } catch (error) {
1549
+ await rm(tmp, { force: true });
1550
+ throw error;
1551
+ }
1552
+ }
1553
+ let persistDefaults = atomicPersist;
1554
+ /**
1555
+ * Clone one provider section, or undefined when nothing is configured for it.
1556
+ * The clone is prototype-less: model ids are provider-supplied catalog data
1557
+ * used as object keys, and consumers index the section directly (the RPC
1558
+ * catalog in index.ts does), so an id like `toString` would otherwise yield an
1559
+ * inherited *function* where a string is declared.
1560
+ */
1561
+ function sectionOf(defaults, provider) {
1562
+ const section = defaults[provider];
1563
+ if (section === void 0) return void 0;
1564
+ return Object.assign(Object.create(null), section);
1565
+ }
1566
+ /**
1567
+ * Ready the defaults store.
1568
+ * @internal Exported for tests; index.ts calls it at apply time so every
1569
+ * later synchronous read sees the persisted state.
1570
+ */
1571
+ async function loadModelDefaults() {
1572
+ await ensureReady();
1573
+ }
1574
+ /**
1575
+ * The configured default effort for one model, or undefined when none (the
1576
+ * picker then follows the provider's own default).
1577
+ * @internal Exported for the adapters' `defaultEffortOf` options.
1578
+ */
1579
+ function defaultEffortOf(provider, model) {
1580
+ const section = current[provider];
1581
+ if (section === void 0) return void 0;
1582
+ return Object.prototype.hasOwnProperty.call(section, model) ? section[model] : void 0;
1583
+ }
1584
+ /**
1585
+ * Set or clear one model's configured default effort, then persist. The
1586
+ * memory snapshot updates only after the atomic write succeeds, so a failed
1587
+ * write never leaves the live state ahead of the file.
1588
+ * @param provider - the subscription provider route.
1589
+ * @param model - the wire model id.
1590
+ * @param effort - the effort id, or undefined to clear the override.
1591
+ */
1592
+ function setDefaultEffort(provider, model, effort) {
1593
+ const run = writeChain.then(async () => {
1594
+ await ensureReady();
1595
+ const section = { ...sectionOf(current, provider) ?? {} };
1596
+ if (effort === void 0) delete section[model];
1597
+ else section[model] = effort;
1598
+ const next = { ...current };
1599
+ if (Object.keys(section).length === 0) delete next[provider];
1600
+ else next[provider] = Object.freeze(section);
1601
+ const frozen = Object.freeze(next);
1602
+ await persistDefaults(frozen, modelDefaultsFilePath());
1603
+ current = frozen;
1604
+ });
1605
+ writeChain = run.catch(() => void 0);
1606
+ return run;
1607
+ }
1608
+
1609
+ //#endregion
1610
+ //#region src/providers/rate-limit.ts
1611
+ /**
1612
+ * Extra time added to every provider-disclosed wait. Absorbs clock skew
1613
+ * between the harness and the provider, so a retry does not land a moment
1614
+ * before the window actually reopens and burn an attempt on a second 429.
1615
+ */
1616
+ const RESET_GRACE_MS = 2e3;
1617
+ /** Shortest wait ever scheduled, including for a reset instant already in the past. */
1618
+ const MIN_WAIT_MS = 1e3;
1619
+ /** Below this a bare number is a delay in seconds rather than an epoch stamp. */
1620
+ const EPOCH_SECONDS_FLOOR = 1e9;
1621
+ /** At or above this a bare epoch stamp is already in milliseconds. */
1622
+ const EPOCH_MILLIS_FLOOR = 0xe8d4a51000;
1623
+ /** Node's maximum timer delay; a longer wait cannot be scheduled at all. */
1624
+ const MAX_TIMER_DELAY_MS = 2147483647;
1625
+ /** Default ceiling on a rate-limit wait: six hours covers a five-hour session window with slack. */
1626
+ const DEFAULT_RATE_LIMIT_MAX_WAIT_MS = 360 * 60 * 1e3;
1627
+ /**
1628
+ * Interpret a bare numeric rate-limit value, which providers write in three
1629
+ * shapes: epoch milliseconds, epoch seconds, or a delay in seconds. The
1630
+ * magnitude separates them unambiguously for any plausible value — an epoch in
1631
+ * seconds is ~1.8e9 today, while a delay of even a full week is ~6e5.
1632
+ * @param value - the raw numeric value.
1633
+ * @param now - the current epoch milliseconds.
1634
+ * @returns epoch milliseconds of the reset, or undefined when the value is unusable.
1635
+ */
1636
+ function resetInstantFromNumber(value, now) {
1637
+ if (!Number.isFinite(value) || value <= 0) return void 0;
1638
+ if (value >= EPOCH_MILLIS_FLOOR) return value;
1639
+ if (value >= EPOCH_SECONDS_FLOOR) return value * 1e3;
1640
+ return now + value * 1e3;
1641
+ }
1642
+ /**
1643
+ * Parse a Go-style duration (`6m0s`, `1h2m3.5s`, `150ms`) into milliseconds —
1644
+ * the form OpenAI-compatible `x-ratelimit-reset-*` headers use.
1645
+ * @param text - the raw header value.
1646
+ * @returns the duration in milliseconds, or undefined when the text is not one.
1647
+ */
1648
+ function durationMs(text) {
1649
+ const trimmed = text.trim();
1650
+ if (trimmed.length === 0) return void 0;
1651
+ const pattern = /(\d+(?:\.\d+)?)(ms|h|m|s)/y;
1652
+ const units = {
1653
+ h: 36e5,
1654
+ m: 6e4,
1655
+ s: 1e3,
1656
+ ms: 1
1657
+ };
1658
+ let total = 0;
1659
+ let matched = false;
1660
+ let index = 0;
1661
+ let previousUnit = Number.POSITIVE_INFINITY;
1662
+ for (;;) {
1663
+ pattern.lastIndex = index;
1664
+ const match = pattern.exec(trimmed);
1665
+ if (match === null) break;
1666
+ const unit = units[match[2]];
1667
+ if (unit >= previousUnit) return void 0;
1668
+ previousUnit = unit;
1669
+ total += Number(match[1]) * unit;
1670
+ index = pattern.lastIndex;
1671
+ matched = true;
1672
+ }
1673
+ if (!matched || index !== trimmed.length) return void 0;
1674
+ return total > 0 ? total : void 0;
1675
+ }
1676
+ /**
1677
+ * Interpret any single rate-limit value — a number, a numeric string, a
1678
+ * duration (`6m0s`), or a date — as the instant a window reopens. One reader
1679
+ * for every shape, so a provider that changes the encoding of a field it
1680
+ * already sends does not need a code change here.
1681
+ * @param value - the raw header value or JSON field.
1682
+ * @param now - the current epoch milliseconds.
1683
+ * @returns epoch milliseconds of the reset, or undefined when the value is unusable.
1684
+ */
1685
+ function resetInstantFromValue(value, now) {
1686
+ if (typeof value === "number") return resetInstantFromNumber(value, now);
1687
+ if (typeof value !== "string") return void 0;
1688
+ const trimmed = value.trim();
1689
+ if (trimmed.length === 0) return void 0;
1690
+ const numeric = Number(trimmed);
1691
+ if (Number.isFinite(numeric)) return resetInstantFromNumber(numeric, now);
1692
+ const duration = durationMs(trimmed);
1693
+ if (duration !== void 0) return now + duration;
1694
+ const parsed = Date.parse(trimmed);
1695
+ return Number.isFinite(parsed) ? parsed : void 0;
1696
+ }
1697
+ /**
1698
+ * Read a header carrying any of the {@link resetInstantFromValue} shapes.
1699
+ * @param response - the failed response.
1700
+ * @param name - the header to read.
1701
+ * @param now - the current epoch milliseconds.
1702
+ * @returns epoch milliseconds of the reset, or undefined when absent or unusable.
1703
+ */
1704
+ function resetInstantFromHeader(response, name$1, now) {
1705
+ return resetInstantFromValue(response.headers.get(name$1), now);
1706
+ }
1707
+ /**
1708
+ * Read the RFC 7231 `retry-after` header in both its forms: a delay in seconds
1709
+ * (never an epoch stamp, whatever its magnitude) or an HTTP-date.
1710
+ * @param response - the failed response.
1711
+ * @param now - the current epoch milliseconds.
1712
+ * @returns epoch milliseconds of the reset, or undefined when absent or unusable.
1713
+ */
1714
+ function retryAfterInstant(response, now) {
1715
+ const raw = response.headers.get("retry-after");
1716
+ if (raw === null) return void 0;
1717
+ const trimmed = raw.trim();
1718
+ if (trimmed.length === 0) return void 0;
1719
+ const seconds = Number(trimmed);
1720
+ if (Number.isFinite(seconds)) return seconds > 0 ? now + seconds * 1e3 : void 0;
1721
+ const parsed = Date.parse(trimmed);
1722
+ return Number.isFinite(parsed) ? parsed : void 0;
1723
+ }
1724
+ /**
1725
+ * Parse a response body as JSON without throwing on the non-JSON bodies
1726
+ * providers occasionally return under load (an HTML gateway page, say).
1727
+ * @param body - the complete response body.
1728
+ * @returns the parsed value, or undefined when the body is not JSON.
1729
+ */
1730
+ function jsonBody(body) {
1731
+ if (body.length === 0) return void 0;
1732
+ try {
1733
+ return JSON.parse(body);
1734
+ } catch {
1735
+ return;
1736
+ }
1737
+ }
1738
+ /** How deep {@link resetFromFields} walks; every observed payload nests one or two levels. */
1739
+ const MAX_BODY_DEPTH = 4;
1740
+ /**
1741
+ * Find a reset instant under any of the named keys, anywhere in a parsed body.
1742
+ *
1743
+ * The search is by key rather than by path on purpose: providers move the same
1744
+ * field between containers (`detail`, `error`, top level) across endpoints and
1745
+ * versions, and a path-shaped reader silently stops working when they do. Only
1746
+ * the key list is provider-specific.
1747
+ * @param value - the parsed body, or any nested value.
1748
+ * @param keys - field names this provider uses for a reset or delay.
1749
+ * @param now - the current epoch milliseconds.
1750
+ * @param depth - remaining recursion depth.
1751
+ * @returns the earliest instant found, or undefined when no key matched.
1752
+ */
1753
+ function resetFromFields(value, keys, now, depth = MAX_BODY_DEPTH) {
1754
+ if (depth <= 0 || value === null || typeof value !== "object") return void 0;
1755
+ let earliest;
1756
+ const consider = (candidate) => {
1757
+ if (candidate !== void 0 && (earliest === void 0 || candidate < earliest)) earliest = candidate;
1758
+ };
1759
+ if (Array.isArray(value)) {
1760
+ for (const item of value) consider(resetFromFields(item, keys, now, depth - 1));
1761
+ return earliest;
1762
+ }
1763
+ for (const [key, nested] of Object.entries(value)) if (keys.includes(key)) consider(resetInstantFromValue(nested, now));
1764
+ else consider(resetFromFields(nested, keys, now, depth - 1));
1765
+ return earliest;
1766
+ }
1767
+ /**
1768
+ * The earliest of several candidate reset instants, ignoring absent ones. The
1769
+ * earliest is the one that matters: it is the first moment any of the reported
1770
+ * limits allows a request again.
1771
+ * @param candidates - reset instants in no particular order.
1772
+ * @returns the earliest instant, or undefined when every candidate is absent.
1773
+ */
1774
+ function earliestReset(...candidates) {
1775
+ let earliest;
1776
+ for (const candidate of candidates) {
1777
+ if (candidate === void 0) continue;
1778
+ if (earliest === void 0 || candidate < earliest) earliest = candidate;
1779
+ }
1780
+ return earliest;
1781
+ }
1782
+ /**
1783
+ * Turn a reset instant into the wait to report as `providerRetryAfterMs`.
1784
+ *
1785
+ * Deliberately not capped: a reset beyond the policy's `maxDelayMs` makes the
1786
+ * retry plugin delegate immediately, failing the turn at once with the real
1787
+ * reset in the message, rather than clamping the wait down and burning the
1788
+ * retry budget against a window that is still closed.
1789
+ * @param instant - epoch milliseconds the window reopens.
1790
+ * @param now - the current epoch milliseconds.
1791
+ * @returns the wait in milliseconds, never below {@link MIN_WAIT_MS}.
1792
+ */
1793
+ function waitFromReset(instant, now) {
1794
+ return Math.max(MIN_WAIT_MS, instant - now + RESET_GRACE_MS);
1795
+ }
1796
+ /** Header names worth showing when a 429 disclosed no reset this code recognizes. */
1797
+ const DIAGNOSTIC_HEADER = /rate-?limit|retry|reset|^x-codex-/i;
1798
+ /**
1799
+ * Render the rate-limit-shaped headers and the head of the body of a 429 whose
1800
+ * reset instant nothing parsed. Emitted through the adapter's `onWarn`, this is
1801
+ * how an unrecognized provider field gets named from live traffic instead of
1802
+ * being guessed at.
1803
+ *
1804
+ * It is also where the per-bucket rollover snapshots land by design — no reader
1805
+ * parks a turn on one, because on a 429 they cannot say which bucket refused —
1806
+ * so the operator still sees what the provider disclosed.
1807
+ * @param response - the failed response.
1808
+ * @param body - the complete response body.
1809
+ * @returns a one-line diagnostic.
1810
+ */
1811
+ function rateLimitDiagnostics(response, body) {
1812
+ const headers = [];
1813
+ response.headers.forEach((value, key) => {
1814
+ if (DIAGNOSTIC_HEADER.test(key)) headers.push(`${key}: ${value}`);
1815
+ });
1816
+ headers.sort();
1817
+ const rendered = headers.length > 0 ? headers.join("; ") : "(none)";
1818
+ const head = body.slice(0, 200);
1819
+ return `429 disclosed no reset time; headers [${rendered}]; body ${head.length > 0 ? head : "(empty)"}`;
1820
+ }
1821
+ /**
1822
+ * The retry shape every subscription route starts from: Claude Code's own SDK
1823
+ * numbers — ten retries after the first attempt, exponential backoff from 1s
1824
+ * doubling per attempt, capped at 60s, plus 20% jitter.
1825
+ *
1826
+ * Shared across all four routes rather than kept to claude, because what these
1827
+ * numbers are tuned for is the shape of a subscription endpoint — a consumer
1828
+ * plan behind a session window, which sheds load in bursts and rewards an
1829
+ * attempt that outlasts them — and that is the same on all four. The dsh-llm
1830
+ * defaults (5 retries from 500ms to 10s) give up after about fifteen seconds,
1831
+ * which is short for that.
1832
+ *
1833
+ * The 60s cap governs local backoff only: a disclosed rate-limit reset is
1834
+ * accepted up to the configured wait ceiling instead.
1835
+ */
1836
+ const DEFAULT_RETRY = Object.freeze({
1837
+ maxRetries: 10,
1838
+ initialDelayMs: 1e3,
1839
+ maxDelayMs: 6e4,
1840
+ jitterRatio: .2
1841
+ });
1842
+ /** Waiting behavior a route falls back to when the plugin passed none (waiting on, six-hour ceiling). */
1843
+ const DEFAULT_RATE_LIMIT_WAIT = Object.freeze({
1844
+ wait: true,
1845
+ maxWaitMs: DEFAULT_RATE_LIMIT_MAX_WAIT_MS
1846
+ });
1847
+ /**
1848
+ * Validate and default the rate-limit waiting config.
1849
+ * @param config - the raw plugin config section, when present.
1850
+ * @param path - diagnostic path naming the config that owns the value.
1851
+ * @returns the resolved, immutable behavior.
1852
+ */
1853
+ function resolveRateLimitWait(config, path) {
1854
+ const wait = config?.wait ?? true;
1855
+ const maxWaitMs = config?.maxWaitMs ?? DEFAULT_RATE_LIMIT_MAX_WAIT_MS;
1856
+ if (!Number.isFinite(maxWaitMs) || maxWaitMs <= 0) throw new Error(`${path}.maxWaitMs must be a positive finite number of milliseconds`);
1857
+ if (maxWaitMs > MAX_TIMER_DELAY_MS) throw new Error(`${path}.maxWaitMs must be no greater than ${String(MAX_TIMER_DELAY_MS)} (the maximum schedulable delay)`);
1858
+ return Object.freeze({
1859
+ wait,
1860
+ maxWaitMs
1861
+ });
1862
+ }
1863
+ /**
1864
+ * Resolve one route's retry policy, widening the delay ceiling to the
1865
+ * configured wait so a disclosed reset hours out is accepted rather than
1866
+ * refused.
1867
+ *
1868
+ * The ceiling is shared with local exponential backoff, so widening it also
1869
+ * raises how long an unrelated transient failure may back off for. That stays
1870
+ * bounded by the finite retry budget — the claude route's ten retries reach
1871
+ * 512 s per attempt at most — and it only governs when the provider disclosed
1872
+ * nothing, which is exactly the case where a longer wait is the safer guess.
1873
+ * @param defaults - the route's retry shape.
1874
+ * @param rateLimit - resolved waiting behavior.
1875
+ * @param path - diagnostic path naming the provider route.
1876
+ * @returns the policy to report from `providerRetryPolicy`.
1877
+ */
1878
+ function subscriptionRetryPolicy(defaults, rateLimit, path) {
1879
+ const maxDelayMs = rateLimit.wait ? Math.max(defaults.maxDelayMs, rateLimit.maxWaitMs) : defaults.maxDelayMs;
1880
+ return resolveRetryPolicy({
1881
+ mode: "normal",
1882
+ maxRetries: defaults.maxRetries,
1883
+ backoff: {
1884
+ initialDelayMs: defaults.initialDelayMs,
1885
+ maxDelayMs,
1886
+ jitterRatio: defaults.jitterRatio
1887
+ }
1888
+ }, path);
1889
+ }
1890
+
1297
1891
  //#endregion
1298
1892
  //#region src/providers/common.ts
1299
1893
  /**
@@ -1324,38 +1918,56 @@ function validateModels(models, label) {
1324
1918
  });
1325
1919
  }
1326
1920
  /**
1327
- * Build an LlmError from a non-2xx provider response, reading and truncating
1328
- * the body for the message and mapping the status to a stable code.
1921
+ * Build an LlmError from a non-2xx provider response, mapping the status to a
1922
+ * stable code and, for a rate-limited request, the disclosed reset instant to
1923
+ * the `providerRetryAfterMs` the retry plugin waits out.
1924
+ *
1925
+ * A 429 classifies as `RATE_LIMIT` on the strength of the status alone, ahead
1926
+ * of the quota-wording check. On these routes there is no terminal quota to
1927
+ * distinguish: a subscription has no balance to top up, only a window that
1928
+ * reopens, and providers announce an exhausted window with wording
1929
+ * (`usage_limit_reached`) the shared classifier reads as permanent.
1329
1930
  * @param response - the failed response.
1330
1931
  * @param label - diagnostic prefix naming the provider API.
1932
+ * @param options - the calling provider's rate-limit reader and warning sink.
1331
1933
  * @returns the classified error.
1332
1934
  */
1333
- async function httpLlmError(response, label) {
1935
+ async function httpLlmError(response, label, options = {}) {
1334
1936
  let body = "";
1335
1937
  try {
1336
- body = (await response.text()).slice(0, 500);
1938
+ body = await response.text();
1337
1939
  } catch {}
1338
- const message = body.length > 0 ? `${label} error (HTTP ${String(response.status)}): ${body}` : `${label} error (HTTP ${String(response.status)})`;
1940
+ const shown = body.slice(0, 500);
1941
+ const message = shown.length > 0 ? `${label} error (HTTP ${String(response.status)}): ${shown}` : `${label} error (HTTP ${String(response.status)})`;
1339
1942
  let code;
1340
1943
  if (response.status === 401 || response.status === 403) code = "AUTH";
1341
- else if (isQuotaExceededError(body)) code = QUOTA_EXCEEDED_CODE;
1342
1944
  else if (response.status === 429) code = "RATE_LIMIT";
1343
- else if (response.status === 400 && isContextWindowExceededError(body)) code = CONTEXT_WINDOW_EXCEEDED_CODE;
1945
+ else if (isQuotaExceededError(shown)) code = QUOTA_EXCEEDED_CODE;
1946
+ else if (response.status === 400 && isContextWindowExceededError(shown)) code = CONTEXT_WINDOW_EXCEEDED_CODE;
1344
1947
  else if (response.status === 408 || response.status === 504) code = "TIMEOUT";
1345
1948
  else if (response.status >= 500) code = "SERVER";
1346
1949
  else code = `HTTP_${String(response.status)}`;
1347
- const retryAfter = response.headers.get("retry-after");
1348
- let providerRetryAfterMs;
1349
- if (retryAfter !== null) {
1350
- const seconds = Number(retryAfter);
1351
- if (Number.isFinite(seconds) && seconds > 0) providerRetryAfterMs = seconds * 1e3;
1352
- }
1950
+ const now = Date.now();
1951
+ const rateLimited = response.status === 429;
1952
+ const reset = rateLimited ? options.rateLimitReset?.(response, body, now) ?? retryAfterInstant(response, now) : retryAfterInstant(response, now);
1953
+ if (reset === void 0 && rateLimited) options.onWarn?.(`${label}: ${rateLimitDiagnostics(response, body)}`);
1353
1954
  return new LlmError(message, code, {
1354
1955
  status: response.status,
1355
- ...providerRetryAfterMs === void 0 ? {} : { providerRetryAfterMs }
1956
+ ...reset === void 0 ? {} : { providerRetryAfterMs: waitFromReset(reset, now) }
1356
1957
  });
1357
1958
  }
1358
1959
  /**
1960
+ * Parse a response's `retry-after` header (seconds) into milliseconds.
1961
+ * @param response - the failed response.
1962
+ * @returns the delay in ms, or undefined when absent/unusable.
1963
+ */
1964
+ function parseRetryAfterMs(response) {
1965
+ const retryAfter = response.headers.get("retry-after");
1966
+ if (retryAfter === null) return void 0;
1967
+ const seconds = Number(retryAfter);
1968
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
1969
+ }
1970
+ /**
1359
1971
  * Create an idle watchdog chained to the caller's signal.
1360
1972
  * @param caller - the request's own abort signal, when present.
1361
1973
  * @param timeoutMs - maximum idle interval while a stream read is outstanding.
@@ -1409,11 +2021,20 @@ var OAuthEndpointError = class extends Error {
1409
2021
  status;
1410
2022
  /** The provider's OAuth `error` code (e.g. `invalid_grant`), when present. */
1411
2023
  oauthCode;
1412
- constructor(message, status, oauthCode) {
2024
+ /**
2025
+ * The endpoint's `retry-after`, in ms, when it sent one. Usage/models
2026
+ * endpoints reuse this error type and can rate-limit progressively (each
2027
+ * hit within the window extends the next one), so a caller retrying on a
2028
+ * fixed schedule instead of honoring this can keep an account locked out
2029
+ * indefinitely.
2030
+ */
2031
+ retryAfterMs;
2032
+ constructor(message, status, oauthCode, retryAfterMs$1) {
1413
2033
  super(message);
1414
2034
  this.name = "OAuthEndpointError";
1415
2035
  this.status = status;
1416
2036
  this.oauthCode = oauthCode;
2037
+ this.retryAfterMs = retryAfterMs$1;
1417
2038
  }
1418
2039
  };
1419
2040
  /**
@@ -1430,7 +2051,7 @@ async function oauthEndpointError(response, label) {
1430
2051
  oauthCode = typeof parsed.error === "string" ? parsed.error : void 0;
1431
2052
  detail = typeof parsed.error_description === "string" ? parsed.error_description : oauthCode ?? "";
1432
2053
  } catch {}
1433
- return new OAuthEndpointError(detail.length > 0 ? `${label} token endpoint error (HTTP ${String(response.status)}): ${detail}` : `${label} token endpoint error (HTTP ${String(response.status)})`, response.status, oauthCode);
2054
+ return new OAuthEndpointError(detail.length > 0 ? `${label} token endpoint error (HTTP ${String(response.status)}): ${detail}` : `${label} token endpoint error (HTTP ${String(response.status)})`, response.status, oauthCode, parseRetryAfterMs(response));
1434
2055
  }
1435
2056
  /**
1436
2057
  * Per-provider session freshness: loads the stored session, refreshes
@@ -1487,13 +2108,94 @@ var TokenManager = class {
1487
2108
  }
1488
2109
  }
1489
2110
  async doRefresh(session) {
1490
- const current$1 = await this.options.load();
1491
- if (current$1 !== void 0 && current$1.accessToken !== session.accessToken && current$1.expiresAt - Date.now() > this.options.preemptMs) return current$1;
1492
- const next = await this.options.refresh(current$1 ?? session);
2111
+ const current$2 = await this.options.load();
2112
+ if (current$2 !== void 0 && current$2.accessToken !== session.accessToken && current$2.expiresAt - Date.now() > this.options.preemptMs) return current$2;
2113
+ const next = await this.options.refresh(current$2 ?? session);
1493
2114
  await this.options.save(next);
1494
2115
  return next;
1495
2116
  }
1496
2117
  };
2118
+ /** Bound on one account catalog fetch or usage poll — a hang must not block the picker. */
2119
+ const DISCOVERY_TIMEOUT_MS = 1e4;
2120
+ /**
2121
+ * Run `work` with an aborting signal. Resolves undefined when the timeout
2122
+ * fires (the fetch is aborted); other failures propagate.
2123
+ */
2124
+ function withTimeout(work, timeoutMs) {
2125
+ const signal = AbortSignal.timeout(timeoutMs);
2126
+ const aborted = new Promise((resolve) => {
2127
+ if (signal.aborted) resolve(void 0);
2128
+ else signal.addEventListener("abort", () => resolve(void 0), { once: true });
2129
+ });
2130
+ return Promise.race([work(signal).then((value) => signal.aborted ? void 0 : value, (error) => {
2131
+ if (signal.aborted) return void 0;
2132
+ throw error;
2133
+ }), aborted]);
2134
+ }
2135
+ /** Display name for a wire reasoning-effort identifier. */
2136
+ function effortDisplayName(effort) {
2137
+ return effort === "xhigh" ? "Extra High" : effort.charAt(0).toUpperCase() + effort.slice(1);
2138
+ }
2139
+ /**
2140
+ * Fold a configured per-model default effort into a reasoning block, keeping
2141
+ * the DSH runtime invariant `defaultEffort ∈ efforts` (the runtime rejects an
2142
+ * unknown default with `INVALID_MODEL_REASONING`).
2143
+ *
2144
+ * A configured level the base set does not advertise is *dropped*, not
2145
+ * appended: for claude/grok/copilot the base is the provider's live catalog,
2146
+ * i.e. the truth about what the model accepts, so honouring a stale override
2147
+ * would put an unsupported effort on every single request instead of letting
2148
+ * the harness reject it before provider I/O. The override then simply falls
2149
+ * back to the provider's own default until the user picks a level the catalog
2150
+ * still lists.
2151
+ *
2152
+ * `extendable` opts into the opposite rule for a base that is a *built-in
2153
+ * fallback* rather than discovered truth (codex, whose static effort list is
2154
+ * known to trail the backend): there, appending the configured level is how a
2155
+ * newly shipped tier becomes selectable at all.
2156
+ * @param configuredDefault - the user-configured default effort id, or undefined.
2157
+ * @param base - the discovered/built-in reasoning block, or undefined.
2158
+ * @param options - `extendable` marks the base as a fallback that may be extended.
2159
+ * @returns the merged block, or undefined when neither side contributes one.
2160
+ */
2161
+ function mergeReasoning(configuredDefault, base, options) {
2162
+ const detached = base === void 0 ? void 0 : {
2163
+ efforts: [...base.efforts],
2164
+ ...base.defaultEffort === void 0 ? {} : { defaultEffort: base.defaultEffort }
2165
+ };
2166
+ if (configuredDefault === void 0) return detached;
2167
+ const effort = ReasoningEffortId(configuredDefault);
2168
+ if (base === void 0) return options?.extendable === true ? {
2169
+ efforts: [{
2170
+ id: effort,
2171
+ name: effortDisplayName(effort)
2172
+ }],
2173
+ defaultEffort: effort
2174
+ } : void 0;
2175
+ if (base.efforts.some((entry) => entry.id === effort)) return {
2176
+ efforts: [...base.efforts],
2177
+ defaultEffort: effort
2178
+ };
2179
+ if (options?.extendable !== true) return detached;
2180
+ return {
2181
+ efforts: [...base.efforts, {
2182
+ id: effort,
2183
+ name: effortDisplayName(effort)
2184
+ }],
2185
+ defaultEffort: effort
2186
+ };
2187
+ }
2188
+ /**
2189
+ * First account catalog that lists `model` (callers pass default-first).
2190
+ * One failing lookup sits that account out so a sibling's metadata still
2191
+ * resolves — the same isolation as the picker catalog union.
2192
+ */
2193
+ async function discoverAcrossAccounts(accounts, lookup) {
2194
+ for (const account of accounts) try {
2195
+ const found = await lookup(account);
2196
+ if (found !== void 0) return found;
2197
+ } catch {}
2198
+ }
1497
2199
  /** How long a discovered catalog is trusted before re-fetching. */
1498
2200
  const DISCOVERY_TTL_MS = 5 * 6e4;
1499
2201
  /**
@@ -1515,6 +2217,8 @@ var ModelCatalogCache = class {
1515
2217
  seeded;
1516
2218
  /** Set by {@link invalidate} so an in-flight disk read cannot resurrect dropped state. */
1517
2219
  seedDisabled = false;
2220
+ /** Bumped by {@link invalidate} so a loser in-flight fetch cannot write back. */
2221
+ generation = 0;
1518
2222
  constructor(persistence, ttlMs = DISCOVERY_TTL_MS) {
1519
2223
  this.persistence = persistence;
1520
2224
  this.ttlMs = ttlMs;
@@ -1545,7 +2249,10 @@ var ModelCatalogCache = class {
1545
2249
  }
1546
2250
  /** Run (or join) the single in-flight fetch, updating memory and disk on success. */
1547
2251
  refresh(fetcher) {
1548
- this.inflight ??= fetcher().then((models) => {
2252
+ if (this.inflight !== void 0) return this.inflight;
2253
+ const gen = this.generation;
2254
+ const pending = fetcher().then((models) => {
2255
+ if (this.generation !== gen) return models;
1549
2256
  const snapshot = {
1550
2257
  at: Date.now(),
1551
2258
  models
@@ -1554,9 +2261,10 @@ var ModelCatalogCache = class {
1554
2261
  this.persistence?.save(snapshot).catch(() => void 0);
1555
2262
  return models;
1556
2263
  }).finally(() => {
1557
- this.inflight = void 0;
2264
+ if (this.generation === gen) this.inflight = void 0;
1558
2265
  });
1559
- return this.inflight;
2266
+ this.inflight = pending;
2267
+ return pending;
1560
2268
  }
1561
2269
  /**
1562
2270
  * Return the cached catalog when fresh, otherwise fetch and cache it.
@@ -1594,7 +2302,9 @@ var ModelCatalogCache = class {
1594
2302
  }
1595
2303
  /** Drop the cached catalog (e.g. after a 401 proved the credential changed). */
1596
2304
  invalidate() {
2305
+ this.generation += 1;
1597
2306
  this.entry = void 0;
2307
+ this.inflight = void 0;
1598
2308
  this.seedDisabled = true;
1599
2309
  this.persistence?.clear().catch(() => void 0);
1600
2310
  }
@@ -1603,6 +2313,11 @@ var ModelCatalogCache = class {
1603
2313
  function isMissingOrInvalidCredential(error) {
1604
2314
  return error instanceof LlmError && (error.code === "MISSING_CREDENTIAL" || error.code === "INVALID_CREDENTIAL");
1605
2315
  }
2316
+ /** Whether discovery stopped because the caller cancelled or the timeout fired. */
2317
+ function isDiscoveryAborted(error, signal) {
2318
+ if (signal?.aborted === true) return true;
2319
+ return signal !== void 0 && error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
2320
+ }
1606
2321
  /** Whether discovery failed because the access token was rejected. */
1607
2322
  function isDiscoveryAuthFailure(error) {
1608
2323
  return error instanceof OAuthEndpointError && error.status === 401 || error instanceof LlmError && error.code === "AUTH";
@@ -1628,6 +2343,109 @@ async function discoverOrRetryAuth(session, catalog, run) {
1628
2343
  }
1629
2344
  }
1630
2345
 
2346
+ //#endregion
2347
+ //#region src/providers/accounts.ts
2348
+ /** Catalog sort hint when the provider advertised one (Codex `priority`). */
2349
+ function catalogPriority(model) {
2350
+ const ranked = model;
2351
+ return typeof ranked.priority === "number" ? ranked.priority : Number.MAX_SAFE_INTEGER;
2352
+ }
2353
+ /**
2354
+ * Merge per-account catalogs, keeping the first occurrence of each model id.
2355
+ * Rows that carry a numeric `priority` (Codex discovery) are then ordered by
2356
+ * it so a model only the second account lists — e.g. `gpt-5.6-sol` — still
2357
+ * sits with its generation instead of being appended after the default
2358
+ * account's older ids.
2359
+ */
2360
+ async function unionAccountCatalogs(accounts, listOne, options) {
2361
+ const timeoutMs = options?.timeoutMs;
2362
+ const caller = options?.signal;
2363
+ const catalogs = await Promise.all(accounts.map(async (account) => {
2364
+ try {
2365
+ if (timeoutMs === void 0) return await listOne(account, caller);
2366
+ return await withTimeout((timeoutSignal) => listOne(account, caller === void 0 ? timeoutSignal : AbortSignal.any([timeoutSignal, caller])), timeoutMs) ?? [];
2367
+ } catch (error) {
2368
+ if (caller?.aborted === true) throw error;
2369
+ return [];
2370
+ }
2371
+ }));
2372
+ const seen = /* @__PURE__ */ new Set();
2373
+ const models = [];
2374
+ for (const catalog of catalogs) for (const model of catalog) {
2375
+ if (seen.has(model.id)) continue;
2376
+ seen.add(model.id);
2377
+ models.push(model);
2378
+ }
2379
+ models.sort((left, right) => catalogPriority(left) - catalogPriority(right));
2380
+ return models;
2381
+ }
2382
+ var AccountTokenManager = class {
2383
+ managers = /* @__PURE__ */ new Map();
2384
+ io;
2385
+ constructor(options) {
2386
+ this.options = options;
2387
+ const provider = options.provider;
2388
+ this.io = options.io ?? {
2389
+ list: () => listAccounts(provider),
2390
+ get: (account) => getAccountSession(provider, account),
2391
+ save: (account, session) => saveAccountSession(provider, account, session),
2392
+ remove: (account) => deleteAccountSession(provider, account)
2393
+ };
2394
+ }
2395
+ /** The provider's accounts, default first (straight from the store). */
2396
+ list() {
2397
+ return this.io.list();
2398
+ }
2399
+ /** The default account's key, or undefined when logged out. */
2400
+ async defaultAccount() {
2401
+ return (await this.list())[0]?.key;
2402
+ }
2403
+ /**
2404
+ * Resolve a usable session for one account (default when omitted),
2405
+ * refreshing proactively or on demand.
2406
+ * @param account - the account key; the default account when undefined.
2407
+ * @param forceRefresh - refresh regardless of expiry (used after a 401).
2408
+ * @returns the persisted session to send.
2409
+ * @throws LlmError MISSING_CREDENTIAL when the account is not logged in.
2410
+ */
2411
+ async session(account, forceRefresh = false) {
2412
+ const key = account ?? await this.defaultAccount();
2413
+ if (key === void 0) throw this.missingCredential();
2414
+ return this.tokensFor(key).session(forceRefresh);
2415
+ }
2416
+ /** Read an account's stored session without any refresh side effect. */
2417
+ peek(account) {
2418
+ return this.io.get(account);
2419
+ }
2420
+ /** Whether a session is stored for the account (cheap; never refreshes). */
2421
+ async hasSession(account) {
2422
+ return await this.peek(account) !== void 0;
2423
+ }
2424
+ /** The TokenManager bound to one account (created lazily, then cached). */
2425
+ tokensFor(account) {
2426
+ let manager = this.managers.get(account);
2427
+ if (manager === void 0) {
2428
+ const io = this.io;
2429
+ manager = new TokenManager({
2430
+ displayName: this.options.displayName,
2431
+ ...this.options.makeOptions(account),
2432
+ load: () => io.get(account),
2433
+ save: (session) => io.save(account, session),
2434
+ remove: () => io.remove(account),
2435
+ onRemoved: () => {
2436
+ this.options.onAccountRemoved?.(account);
2437
+ }
2438
+ });
2439
+ this.managers.set(account, manager);
2440
+ }
2441
+ return manager;
2442
+ }
2443
+ /** The logged-out error, mirroring TokenManager's own message. */
2444
+ missingCredential() {
2445
+ 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");
2446
+ }
2447
+ };
2448
+
1631
2449
  //#endregion
1632
2450
  //#region src/providers/catalog-store.ts
1633
2451
  /**
@@ -1717,59 +2535,722 @@ function sanitizeSnapshot(value) {
1717
2535
  models
1718
2536
  };
1719
2537
  }
1720
- /** Read the whole file; missing or unparsable reads as an empty cache. */
1721
- async function readCatalogFile(path) {
1722
- let text;
1723
- try {
1724
- text = await readFile(path, "utf8");
1725
- } catch {
1726
- return {};
2538
+ /** Read the whole file; missing or unparsable reads as an empty cache. */
2539
+ async function readCatalogFile(path) {
2540
+ let text;
2541
+ try {
2542
+ text = await readFile(path, "utf8");
2543
+ } catch {
2544
+ return {};
2545
+ }
2546
+ try {
2547
+ const parsed = JSON.parse(text);
2548
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
2549
+ return parsed;
2550
+ } catch {
2551
+ return {};
2552
+ }
2553
+ }
2554
+ /** Persist the whole file atomically (tmp file + rename). */
2555
+ async function writeCatalogFile(store, path) {
2556
+ await mkdir(dirname(path), { recursive: true });
2557
+ const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
2558
+ try {
2559
+ await writeFile(tmp, JSON.stringify(store, null, 2));
2560
+ await rename(tmp, path);
2561
+ } catch (error) {
2562
+ await rm(tmp, { force: true });
2563
+ throw error;
2564
+ }
2565
+ }
2566
+ /**
2567
+ * Build the durable half of one provider's catalog cache over the shared
2568
+ * models.json file (concurrent writers are last-writer-wins, acceptable for
2569
+ * a cache).
2570
+ * @param provider - the provider route keying the file entry.
2571
+ * @param path - store file path; defaults to {@link modelsFilePath}.
2572
+ * @returns the persistence hooks for {@link ModelCatalogCache}.
2573
+ */
2574
+ function catalogStore(provider, path = modelsFilePath()) {
2575
+ return {
2576
+ async load() {
2577
+ return sanitizeSnapshot((await readCatalogFile(path))[provider]);
2578
+ },
2579
+ async save(snapshot) {
2580
+ const store = await readCatalogFile(path);
2581
+ store[provider] = snapshot;
2582
+ await writeCatalogFile(store, path);
2583
+ },
2584
+ async clear() {
2585
+ const store = await readCatalogFile(path);
2586
+ if (store[provider] === void 0) return;
2587
+ delete store[provider];
2588
+ await writeCatalogFile(store, path);
2589
+ }
2590
+ };
2591
+ }
2592
+
2593
+ //#endregion
2594
+ //#region src/providers/pool-family.ts
2595
+ /** Map key for one provider's pool of one model (ids collide across providers). */
2596
+ function poolKey(provider, model) {
2597
+ return `${provider}/${model}`;
2598
+ }
2599
+ /**
2600
+ * Build per-provider account routes. Each model id becomes a definition of
2601
+ * the accounts that list it: two or more fail over; one is pinned to that
2602
+ * account (so a Max-only model is never sent to a Plus login). The picker
2603
+ * unions these catalogs; a logout that drops a model to one account keeps
2604
+ * the same id and pins it to whoever remains.
2605
+ * @param sources - per-account catalogs (providers with no accounts list
2606
+ * nothing and simply never join a pool).
2607
+ * @returns `provider/model` → pool definition (not listed as an extra entry).
2608
+ */
2609
+ function buildAccountPools(sources) {
2610
+ const pools = /* @__PURE__ */ new Map();
2611
+ for (const [provider, source] of Object.entries(sources)) {
2612
+ const byModel = /* @__PURE__ */ new Map();
2613
+ for (const catalog of source.catalogs) for (const model of catalog.models) {
2614
+ let entry = byModel.get(model.id);
2615
+ if (entry === void 0) {
2616
+ entry = {
2617
+ members: [],
2618
+ info: model
2619
+ };
2620
+ byModel.set(model.id, entry);
2621
+ }
2622
+ entry.members.push({
2623
+ provider,
2624
+ account: catalog.account,
2625
+ model: model.id
2626
+ });
2627
+ }
2628
+ for (const [id, { members, info }] of byModel) pools.set(poolKey(provider, id), {
2629
+ members,
2630
+ ...info.name === void 0 || info.name === id ? {} : { name: info.name },
2631
+ ...info.description === void 0 ? {} : { description: info.description }
2632
+ });
2633
+ }
2634
+ return pools;
2635
+ }
2636
+
2637
+ //#endregion
2638
+ //#region src/providers/pool-health.ts
2639
+ /** Registry key for one pool member. */
2640
+ function memberKey(provider, account, model) {
2641
+ return `${provider}/${account}/${model}`;
2642
+ }
2643
+ /** Registry key parking EVERY member of one account (account-level failures). */
2644
+ function accountKey(provider, account) {
2645
+ return `${provider}/${account}/*`;
2646
+ }
2647
+ /** Default cooldown when a quota/rate failure carries no `retry-after`. */
2648
+ const DEFAULT_QUOTA_COOLDOWN_MS = 5 * 6e4;
2649
+ /** Auth failures recheck after a day; a re-login clears the record immediately. */
2650
+ const AUTH_COOLDOWN_MS = 1440 * 6e4;
2651
+ /** Transient server-side failures cool down briefly. */
2652
+ const TRANSIENT_COOLDOWN_MS = 6e4;
2653
+ /**
2654
+ * Providers whose quota windows are model-scoped, so a quota failure on one
2655
+ * model says nothing about its siblings (Claude's Opus/Sonnet lanes). Every
2656
+ * other provider meters the account as a whole: one member hitting the wall
2657
+ * means its siblings on the SAME account would too, so the cooldown parks
2658
+ * the account (other accounts of the provider are unaffected).
2659
+ */
2660
+ const MODEL_SCOPED_QUOTA_PROVIDERS = new Set(["claude"]);
2661
+ /** The `retry-after` an adapter propagated through `httpLlmError`, when any. */
2662
+ function retryAfterMs(error) {
2663
+ return error.failure.providerRetryAfterMs;
2664
+ }
2665
+ /**
2666
+ * Classify a member failure. Quota and rate-limit failures cool down (using
2667
+ * the provider's own `retry-after` when sent, which is more accurate than
2668
+ * any fixed guess) — account-wide for account-metered providers, per-member
2669
+ * for model-scoped ones; auth failures park the account until re-login
2670
+ * (credentials are account-level); server/timeout failures get a short
2671
+ * per-member cooldown; transport failures switch without a record;
2672
+ * everything else — most importantly CONTEXT_WINDOW_EXCEEDED and ABORTED —
2673
+ * is the request's own fault and is rethrown untouched.
2674
+ * @param error - the failure thrown by a member adapter's stream.
2675
+ * @param provider - the failing member's provider (decides the quota scope).
2676
+ * @returns the action the pool should take.
2677
+ */
2678
+ function classifyPoolFailure(error, provider) {
2679
+ if (!(error instanceof LlmError)) return { action: "throw" };
2680
+ switch (error.code) {
2681
+ case QUOTA_EXCEEDED_CODE:
2682
+ case "RATE_LIMIT": return {
2683
+ action: "switch",
2684
+ cooldownMs: retryAfterMs(error) ?? DEFAULT_QUOTA_COOLDOWN_MS,
2685
+ reason: error.code,
2686
+ scope: MODEL_SCOPED_QUOTA_PROVIDERS.has(provider) ? "member" : "account"
2687
+ };
2688
+ case "AUTH":
2689
+ case "INVALID_CREDENTIAL":
2690
+ case "MISSING_CREDENTIAL": return {
2691
+ action: "switch",
2692
+ cooldownMs: AUTH_COOLDOWN_MS,
2693
+ reason: error.code,
2694
+ scope: "account"
2695
+ };
2696
+ case "SERVER":
2697
+ case "TIMEOUT":
2698
+ case "EMPTY_RESPONSE": return {
2699
+ action: "switch",
2700
+ cooldownMs: TRANSIENT_COOLDOWN_MS,
2701
+ reason: error.code,
2702
+ scope: "member"
2703
+ };
2704
+ case "TRANSPORT": return { action: "switch" };
2705
+ case "HTTP_402":
2706
+ case "HTTP_404": return {
2707
+ action: "switch",
2708
+ cooldownMs: TRANSIENT_COOLDOWN_MS,
2709
+ reason: error.code,
2710
+ scope: "member"
2711
+ };
2712
+ case CONTEXT_WINDOW_EXCEEDED_CODE:
2713
+ case "ABORTED":
2714
+ default: return { action: "throw" };
2715
+ }
2716
+ }
2717
+ /**
2718
+ * Cooldown registry keyed by {@link memberKey}. A member whose cooldown has
2719
+ * expired is simply available again — recovery is proven by the next real
2720
+ * request, not by a background probe.
2721
+ */
2722
+ var PoolHealthRegistry = class {
2723
+ records = /* @__PURE__ */ new Map();
2724
+ /** Whether a member may serve: neither it nor its whole account is cooling. */
2725
+ isMemberAvailable(provider, account, model, now = Date.now()) {
2726
+ return this.isAvailable(accountKey(provider, account), now) && this.isAvailable(memberKey(provider, account, model), now);
2727
+ }
2728
+ /** Whether one registry key is clear right now. */
2729
+ isAvailable(key, now = Date.now()) {
2730
+ const record = this.records.get(key);
2731
+ if (record === void 0) return true;
2732
+ if (record.unavailableUntil <= now) {
2733
+ this.records.delete(key);
2734
+ return true;
2735
+ }
2736
+ return false;
2737
+ }
2738
+ /** Park a member for `cooldownMs`; a longer existing cooldown wins. */
2739
+ markUnavailable(key, cooldownMs, reason, now = Date.now()) {
2740
+ const until = now + cooldownMs;
2741
+ const existing = this.records.get(key);
2742
+ if (existing !== void 0 && existing.unavailableUntil > until) return;
2743
+ this.records.set(key, {
2744
+ unavailableUntil: until,
2745
+ reason
2746
+ });
2747
+ }
2748
+ /**
2749
+ * Epoch ms at which the earliest cooling record among `keys` recovers;
2750
+ * `undefined` when none of them is cooling. The registry is shared by
2751
+ * every pool, so the caller passes the keys of ITS members (member and
2752
+ * account keys alike) — an unrelated pool's cooldown must not shape this
2753
+ * pool's retry hint. Feeds the pool-exhausted error's
2754
+ * `providerRetryAfterMs`.
2755
+ */
2756
+ earliestRecovery(keys, now = Date.now()) {
2757
+ let earliest;
2758
+ for (const [key, record] of this.records) {
2759
+ if (record.unavailableUntil <= now) {
2760
+ this.records.delete(key);
2761
+ continue;
2762
+ }
2763
+ if (!keys.has(key)) continue;
2764
+ if (earliest === void 0 || record.unavailableUntil < earliest) earliest = record.unavailableUntil;
2765
+ }
2766
+ return earliest;
2767
+ }
2768
+ /** Drop records of one provider, or of a single account when given (auth changes). */
2769
+ clear(provider, account) {
2770
+ const prefix = account === void 0 ? `${provider}/` : `${provider}/${account}/`;
2771
+ for (const key of [...this.records.keys()]) if (key.startsWith(prefix)) this.records.delete(key);
2772
+ }
2773
+ };
2774
+
2775
+ //#endregion
2776
+ //#region src/providers/pool.ts
2777
+ /** Bound on sticky-session memory; oldest entries evict past it. */
2778
+ const STICKY_SESSION_LIMIT = 1e3;
2779
+ /** Display form of one member (account shown when pinned). */
2780
+ function memberLabel(member) {
2781
+ return member.account === void 0 ? `${member.provider}/${member.model}` : `${member.provider}/${member.account}/${member.model}`;
2782
+ }
2783
+ /** How long a pools snapshot is trusted (auth changes invalidate immediately). */
2784
+ const POOLS_CACHE_TTL_MS = 5e3;
2785
+ var PoolAdapter = class extends LlmAdapter {
2786
+ /** sessionId|poolId → member key of the last member that served a chunk. */
2787
+ sticky = /* @__PURE__ */ new Map();
2788
+ /** Messages already warned about — configuration diagnostics repeat every request otherwise. */
2789
+ warned = /* @__PURE__ */ new Set();
2790
+ /**
2791
+ * Short-lived pools snapshot. `owns()` runs on every resolveModel — the
2792
+ * model picker issues one per entry — and pool assembly touches every
2793
+ * provider's catalog and account store, so recompute at most this often.
2794
+ * Auth changes bump {@link generation} so a stale snapshot cannot land.
2795
+ */
2796
+ poolsCache;
2797
+ poolsInflight;
2798
+ generation = 0;
2799
+ constructor(options) {
2800
+ super();
2801
+ this.options = options;
2802
+ }
2803
+ /** Drop the pools snapshot so the next read reflects the current accounts. */
2804
+ invalidate() {
2805
+ this.generation += 1;
2806
+ this.poolsCache = void 0;
2807
+ this.poolsInflight = void 0;
2808
+ }
2809
+ /** Warn once per distinct message (pools() runs on every request). */
2810
+ warnOnce(message) {
2811
+ if (this.warned.has(message)) return;
2812
+ this.warned.add(message);
2813
+ this.options.onWarn(message);
2814
+ }
2815
+ /** Drop members whose adapter is not registered (copy — caller state is shared). */
2816
+ usable(pools) {
2817
+ const result = new Map(pools);
2818
+ for (const [id, definition] of [...result]) {
2819
+ const kept = definition.members.filter((member) => this.options.adapters[member.provider] !== void 0);
2820
+ if (kept.length === 0) result.delete(id);
2821
+ else if (kept.length < definition.members.length) result.set(id, {
2822
+ ...definition,
2823
+ members: kept
2824
+ });
2825
+ }
2826
+ return result;
2827
+ }
2828
+ /** Account pools (auto-aggregated plus config overrides) with usable members. */
2829
+ async familyPools() {
2830
+ return this.usable(new Map(await this.options.families()));
2831
+ }
2832
+ /** All pools (account pools merged with extra tiers) with usable members. */
2833
+ async pools() {
2834
+ const cached = this.poolsCache;
2835
+ if (cached !== void 0 && Date.now() - cached.at < POOLS_CACHE_TTL_MS) return cached.pools;
2836
+ const gen = this.generation;
2837
+ this.poolsInflight ??= this.assemblePools().then((pools) => {
2838
+ if (this.generation === gen) this.poolsCache = {
2839
+ at: Date.now(),
2840
+ pools
2841
+ };
2842
+ return pools;
2843
+ }).finally(() => {
2844
+ this.poolsInflight = void 0;
2845
+ });
2846
+ return this.poolsInflight;
2847
+ }
2848
+ /** Recompute the pools snapshot (account pools merged with extra tiers). */
2849
+ async assemblePools() {
2850
+ const pools = await this.familyPools();
2851
+ for (const [id, members] of Object.entries(this.options.tiers)) {
2852
+ if (members.length === 0) continue;
2853
+ const owner = members[0].provider;
2854
+ const key = poolKey(owner, id);
2855
+ if (pools.has(key)) this.warnOnce(`tier pool "${id}" overrides the account pool of the same id under ${owner}`);
2856
+ pools.set(key, {
2857
+ members,
2858
+ extra: true
2859
+ });
2860
+ }
2861
+ return this.usable(pools);
2862
+ }
2863
+ /**
2864
+ * Extra picker rows one provider lists (configured tiers). Account pools
2865
+ * reuse the catalog entry of the same wire id, so they are not listed
2866
+ * again — the picker stays one row per model in ChatGPT / Claude / ….
2867
+ */
2868
+ async modelsForProvider(provider) {
2869
+ const pools = await this.pools();
2870
+ const models = [];
2871
+ for (const [key, definition] of pools) {
2872
+ if (definition.extra !== true) continue;
2873
+ if (!key.startsWith(`${provider}/`)) continue;
2874
+ const id = key.slice(provider.length + 1);
2875
+ models.push({
2876
+ provider,
2877
+ id,
2878
+ name: definition.name ?? id,
2879
+ ...definition.description === void 0 ? {} : { description: definition.description }
2880
+ });
2881
+ }
2882
+ return models;
2883
+ }
2884
+ /**
2885
+ * Whether `model` on `provider`'s route is served here (several accounts
2886
+ * fail over, one account is pinned, or a configured tier).
2887
+ */
2888
+ async owns(provider, model) {
2889
+ return (await this.pools()).has(poolKey(provider, model));
2890
+ }
2891
+ /**
2892
+ * Resolve every member's account (config members may omit it to mean "the
2893
+ * default account") and drop members with no resolvable login. Duplicates
2894
+ * collapse — an explicitly pinned account and the default may coincide.
2895
+ */
2896
+ async concrete(members) {
2897
+ const seen = /* @__PURE__ */ new Set();
2898
+ const resolved = [];
2899
+ for (const member of members) {
2900
+ const account = member.account ?? await this.options.defaultAccount(member.provider);
2901
+ if (account === void 0) continue;
2902
+ const key = memberKey(member.provider, account, member.model);
2903
+ if (seen.has(key)) continue;
2904
+ seen.add(key);
2905
+ resolved.push({
2906
+ provider: member.provider,
2907
+ account,
2908
+ model: member.model
2909
+ });
2910
+ }
2911
+ return resolved;
2912
+ }
2913
+ /**
2914
+ * Resolve a pool model to the conservative INTERSECTION of its members'
2915
+ * capabilities: the smallest context window and output cap, the reasoning
2916
+ * efforts every member supports, and the modalities all of them accept —
2917
+ * so a request valid for the pool stays valid after a failover. Capability
2918
+ * metadata is provider-level, so each provider resolves once regardless of
2919
+ * how many accounts it pools.
2920
+ */
2921
+ async resolveModel(provider, model) {
2922
+ const definition = (await this.pools()).get(poolKey(provider, model));
2923
+ if (definition === void 0) throw new LlmError(`unknown pool model "${model}"`, "NO_ADAPTER");
2924
+ const resolved = [];
2925
+ let lastFailure;
2926
+ const seenProviders = /* @__PURE__ */ new Set();
2927
+ for (const member of definition.members) {
2928
+ if (seenProviders.has(member.provider)) continue;
2929
+ seenProviders.add(member.provider);
2930
+ const adapter = this.options.adapters[member.provider];
2931
+ if (adapter === void 0) continue;
2932
+ try {
2933
+ resolved.push(await adapter.resolveOwnModel(member.provider, member.model));
2934
+ } catch (error) {
2935
+ lastFailure = error;
2936
+ this.warnOnce(`pool "${model}": member ${memberLabel(member)} failed to resolve (${error instanceof Error ? error.message : String(error)}); excluding it`);
2937
+ }
2938
+ }
2939
+ if (resolved.length === 0) throw new LlmError(`pool "${model}" has no usable member`, "NO_ADAPTER", { ...lastFailure === void 0 ? {} : { cause: lastFailure } });
2940
+ const contextWindows = resolved.map((info) => info.context?.contextWindow).filter(isNumber);
2941
+ const maxTokens = resolved.map((info) => info.defaultMaxTokens).filter(isNumber);
2942
+ const reasoning = intersectReasoning(resolved);
2943
+ const modalities = intersectModalities(resolved);
2944
+ return {
2945
+ provider,
2946
+ id: model,
2947
+ name: definition.name ?? model,
2948
+ ...definition.description === void 0 ? {} : { description: definition.description },
2949
+ ...contextWindows.length > 0 ? { context: { contextWindow: Math.min(...contextWindows) } } : {},
2950
+ ...maxTokens.length > 0 ? { defaultMaxTokens: Math.min(...maxTokens) } : {},
2951
+ ...reasoning === void 0 ? {} : { reasoning },
2952
+ ...modalities === void 0 ? {} : { inputModalities: modalities }
2953
+ };
2954
+ }
2955
+ async *stream(options) {
2956
+ const definition = (await this.pools()).get(poolKey(options.provider, options.model));
2957
+ if (definition === void 0) throw new LlmError(`unknown pool model "${options.model}"`, "NO_ADAPTER");
2958
+ const members = await this.concrete(definition.members);
2959
+ const candidates = await this.select(options.model, members, options.sessionId);
2960
+ if (candidates.length === 0) throw this.exhausted(options.model, members);
2961
+ let lastError;
2962
+ for (const member of candidates) {
2963
+ const adapter = this.options.adapters[member.provider];
2964
+ if (adapter === void 0) continue;
2965
+ const iterator = adapter.streamAccount({
2966
+ ...options,
2967
+ provider: member.provider,
2968
+ model: member.model
2969
+ }, member.account)[Symbol.asyncIterator]();
2970
+ let first;
2971
+ try {
2972
+ first = await iterator.next();
2973
+ if (first.done === true) throw new LlmError(`${memberLabel(member)} returned an empty stream`, EMPTY_RESPONSE_CODE);
2974
+ } catch (error) {
2975
+ const classification = classifyPoolFailure(error, member.provider);
2976
+ if (classification.action === "throw") throw error;
2977
+ if ("cooldownMs" in classification) {
2978
+ this.options.health.markUnavailable(classification.scope === "account" ? accountKey(member.provider, member.account) : memberKey(member.provider, member.account, member.model), classification.cooldownMs, classification.reason);
2979
+ if (classification.reason === QUOTA_EXCEEDED_CODE || classification.reason === "RATE_LIMIT") this.options.usage.invalidate(member.provider, member.account);
2980
+ }
2981
+ this.options.onWarn(`pool "${options.model}": ${memberLabel(member)} failed before any output (${error instanceof Error ? error.message : String(error)}); trying the next member`);
2982
+ lastError = error;
2983
+ continue;
2984
+ }
2985
+ this.remember(options.model, options.sessionId, member);
2986
+ try {
2987
+ yield first.value;
2988
+ for (let next = await iterator.next(); next.done !== true; next = await iterator.next()) yield next.value;
2989
+ } finally {
2990
+ try {
2991
+ await iterator.return?.();
2992
+ } catch {}
2993
+ }
2994
+ return;
2995
+ }
2996
+ throw this.exhausted(options.model, members, lastError);
2997
+ }
2998
+ /**
2999
+ * Order the candidates for one request. Health filters both strategies;
3000
+ * `quota_aware` then ranks by urgency (members without telemetry, e.g.
3001
+ * copilot, score zero and sink to the bottom of their class), while
3002
+ * quota-exhausted members stay as a last-resort tail in pool order. The
3003
+ * sticky member keeps its lead unless a challenger out-scores it by
3004
+ * `switchMargin`.
3005
+ */
3006
+ async select(poolId, members, sessionId) {
3007
+ const usable = members.filter((member) => this.options.adapters[member.provider] !== void 0 && this.options.health.isMemberAvailable(member.provider, member.account, member.model));
3008
+ if (usable.length === 0) return [];
3009
+ const stickyMember = sessionId === void 0 ? void 0 : usable.find((member) => memberKey(member.provider, member.account, member.model) === this.sticky.get(stickyKey(poolId, sessionId)));
3010
+ if (this.options.strategy === "priority") return stickyMember === void 0 ? usable : [stickyMember, ...usable.filter((member) => member !== stickyMember)];
3011
+ const quotas = new Map(await Promise.all(usable.map(async (member) => [member, await this.options.usage.quotaFor(member)])));
3012
+ const scored = usable.filter((member) => quotas.get(member)?.available === true);
3013
+ const quotaFull = usable.filter((member) => quotas.get(member)?.available === false);
3014
+ scored.sort((a, b) => (quotas.get(b)?.urgency ?? 0) - (quotas.get(a)?.urgency ?? 0));
3015
+ if (stickyMember !== void 0 && scored.includes(stickyMember)) {
3016
+ const best = scored[0];
3017
+ const stickyUrgency = quotas.get(stickyMember)?.urgency ?? 0;
3018
+ const bestUrgency = quotas.get(best)?.urgency ?? 0;
3019
+ if (best === stickyMember || bestUrgency <= stickyUrgency * this.options.switchMargin) {
3020
+ scored.splice(scored.indexOf(stickyMember), 1);
3021
+ scored.unshift(stickyMember);
3022
+ }
3023
+ }
3024
+ return [...scored, ...quotaFull];
3025
+ }
3026
+ /** Pin the serving member to the session (with bounded memory). */
3027
+ remember(poolId, sessionId, member) {
3028
+ if (sessionId === void 0) return;
3029
+ const key = stickyKey(poolId, sessionId);
3030
+ this.sticky.delete(key);
3031
+ if (this.sticky.size >= STICKY_SESSION_LIMIT) {
3032
+ const oldest = this.sticky.keys().next();
3033
+ if (oldest.done !== true) this.sticky.delete(oldest.value);
3034
+ }
3035
+ this.sticky.set(key, memberKey(member.provider, member.account, member.model));
3036
+ }
3037
+ /**
3038
+ * The error for an exhausted pool, carrying the earliest recovery hint of
3039
+ * THIS pool's members (the health registry is shared across pools, so the
3040
+ * hint is scoped to the keys this pool can actually recover through).
3041
+ */
3042
+ exhausted(model, pool, cause) {
3043
+ const keys = /* @__PURE__ */ new Set();
3044
+ for (const member of pool) {
3045
+ keys.add(memberKey(member.provider, member.account, member.model));
3046
+ keys.add(accountKey(member.provider, member.account));
3047
+ }
3048
+ const recovery = this.options.health.earliestRecovery(keys);
3049
+ const retryAfterMs$1 = recovery === void 0 ? void 0 : Math.max(recovery - Date.now(), 1);
3050
+ return new LlmError(`pool "${model}" exhausted: every member is unavailable or failed`, "RATE_LIMIT", {
3051
+ ...retryAfterMs$1 === void 0 ? {} : { providerRetryAfterMs: retryAfterMs$1 },
3052
+ ...cause === void 0 ? {} : { cause }
3053
+ });
3054
+ }
3055
+ };
3056
+ function stickyKey(poolId, sessionId) {
3057
+ return `${String(sessionId)}|${poolId}`;
3058
+ }
3059
+ function isNumber(value) {
3060
+ return value !== void 0;
3061
+ }
3062
+ /** Reasoning efforts every member supports (id intersection, first member's order). */
3063
+ function intersectReasoning(resolved) {
3064
+ const [first, ...rest] = resolved;
3065
+ if (first?.reasoning === void 0) return void 0;
3066
+ const efforts = first.reasoning.efforts.filter((effort) => rest.every((info) => info.reasoning?.efforts.some((other) => other.id === effort.id) === true));
3067
+ if (efforts.length === 0) return void 0;
3068
+ const defaultEffort = first.reasoning.defaultEffort !== void 0 && efforts.some((effort) => effort.id === first.reasoning?.defaultEffort) ? first.reasoning.defaultEffort : void 0;
3069
+ return {
3070
+ efforts,
3071
+ ...defaultEffort === void 0 ? {} : { defaultEffort }
3072
+ };
3073
+ }
3074
+ /** Modalities all members accept; undefined when any member leaves it unknown. */
3075
+ function intersectModalities(resolved) {
3076
+ const [first, ...rest] = resolved;
3077
+ if (first?.inputModalities === void 0) return void 0;
3078
+ const modalities = first.inputModalities.filter((modality) => rest.every((info) => info.inputModalities?.includes(modality) === true));
3079
+ return modalities.length === 0 ? void 0 : modalities;
3080
+ }
3081
+
3082
+ //#endregion
3083
+ //#region src/providers/pool-usage.ts
3084
+ /** A member is taken out of rotation once any window crosses this fill level. */
3085
+ const QUOTA_FULL_PERCENT = 95;
3086
+ /** How long a usage snapshot is trusted before a background refresh. */
3087
+ const USAGE_TTL_MS = 5 * 6e4;
3088
+ /** Assumed window length when the provider discloses no `resetsAt`. */
3089
+ const FALLBACK_HORIZON_MS = {
3090
+ session: 300 * 6e4,
3091
+ weekly: 10080 * 6e4,
3092
+ other: 720 * 60 * 6e4
3093
+ };
3094
+ /**
3095
+ * Per-ACCOUNT usage snapshots with in-flight dedupe and
3096
+ * stale-while-revalidate refresh. Providers without a usage endpoint
3097
+ * (copilot) resolve no fetcher and score a constant zero urgency — which
3098
+ * naturally ranks them behind every measured member. Fetchers are resolved
3099
+ * lazily per (provider, account) so accounts added after startup join
3100
+ * tracking on their first score.
3101
+ */
3102
+ var PoolUsageTracker = class {
3103
+ entries = /* @__PURE__ */ new Map();
3104
+ inflight = /* @__PURE__ */ new Map();
3105
+ constructor(fetcherFor, ttlMs = USAGE_TTL_MS) {
3106
+ this.fetcherFor = fetcherFor;
3107
+ this.ttlMs = ttlMs;
1727
3108
  }
1728
- try {
1729
- const parsed = JSON.parse(text);
1730
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
1731
- return parsed;
1732
- } catch {
1733
- return {};
3109
+ /**
3110
+ * The quota view of one member. A cold cache awaits the first fetch; a
3111
+ * stale one answers immediately while the refresh serves the NEXT call
3112
+ * (member selection must never block on the network mid-conversation). A
3113
+ * failure still cooling down degrades immediately with no network call.
3114
+ * @param member - the pool member to score (account resolved).
3115
+ * @returns availability plus the urgency score.
3116
+ */
3117
+ async quotaFor(member) {
3118
+ const key = `${member.provider}/${member.account}`;
3119
+ const fetcher = this.fetcherFor(member.provider, member.account);
3120
+ if (fetcher === void 0) return {
3121
+ available: true,
3122
+ urgency: 0,
3123
+ fetchedAt: 0
3124
+ };
3125
+ const entry = this.entries.get(key);
3126
+ if (entry !== void 0) {
3127
+ const fresh = Date.now() - entry.at < (entry.cooldownMs ?? this.ttlMs);
3128
+ if (entry.snapshot !== void 0) {
3129
+ if (!fresh) this.refresh(key, fetcher).catch(() => void 0);
3130
+ return this.score(member, entry);
3131
+ }
3132
+ if (fresh) return degradedQuota(entry.error);
3133
+ }
3134
+ try {
3135
+ const snapshot = await this.refresh(key, fetcher);
3136
+ return this.score(member, {
3137
+ snapshot,
3138
+ at: Date.now()
3139
+ });
3140
+ } catch (error) {
3141
+ return degradedQuota(error);
3142
+ }
1734
3143
  }
1735
- }
1736
- /** Persist the whole file atomically (tmp file + rename). */
1737
- async function writeCatalogFile(store, path) {
1738
- await mkdir(dirname(path), { recursive: true });
1739
- const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
1740
- try {
1741
- await writeFile(tmp, JSON.stringify(store, null, 2));
1742
- await rename(tmp, path);
1743
- } catch (error) {
1744
- await rm(tmp, { force: true });
1745
- throw error;
3144
+ /**
3145
+ * Same cache as {@link quotaFor}, for direct display (the Settings page):
3146
+ * the raw snapshot, or the original fetch error, instead of a routing
3147
+ * score.
3148
+ * @param provider - the account's provider.
3149
+ * @param account - the account key.
3150
+ * @param force - bypass a fresh cached SNAPSHOT for an honest re-check (the
3151
+ * manual Refresh button). A live failure cooldown is never bypassed —
3152
+ * retrying through it is exactly what turns a 429 into a permanent
3153
+ * lockout, so even a forced call still answers from the negative cache.
3154
+ * @returns `{ supported: false }` when the provider has no usage fetcher.
3155
+ */
3156
+ async snapshotFor(provider, account, force = false) {
3157
+ const fetcher = this.fetcherFor(provider, account);
3158
+ if (fetcher === void 0) return { supported: false };
3159
+ const key = `${provider}/${account}`;
3160
+ const entry = this.entries.get(key);
3161
+ if (entry !== void 0 && Date.now() - entry.at < (entry.cooldownMs ?? this.ttlMs)) if (entry.snapshot !== void 0) {
3162
+ if (!force) return entry.snapshot;
3163
+ } else throw entry.error;
3164
+ return this.refresh(key, fetcher);
3165
+ }
3166
+ /** Drop cached snapshots: one account, or a whole provider when `account` is omitted. */
3167
+ invalidate(provider, account) {
3168
+ if (account !== void 0) {
3169
+ this.entries.delete(`${provider}/${account}`);
3170
+ return;
3171
+ }
3172
+ for (const key of [...this.entries.keys()]) if (key.startsWith(`${provider}/`)) this.entries.delete(key);
3173
+ }
3174
+ /**
3175
+ * Run (or join) the single in-flight fetch for one account key, caching
3176
+ * either outcome. A missing/invalid credential is deliberately NOT
3177
+ * negative-cached: it costs no network round trip (the session lookup
3178
+ * fails before the request goes out) and re-checking live means the
3179
+ * member rejoins routing the instant its login is fixed, rather than
3180
+ * waiting out a stale cooldown.
3181
+ */
3182
+ refresh(key, fetcher) {
3183
+ let pending = this.inflight.get(key);
3184
+ if (pending === void 0) {
3185
+ pending = fetcher().then((snapshot) => {
3186
+ this.entries.set(key, {
3187
+ snapshot,
3188
+ at: Date.now()
3189
+ });
3190
+ return snapshot;
3191
+ }, (error) => {
3192
+ if (!isMissingOrInvalidCredential(error)) this.entries.set(key, {
3193
+ error,
3194
+ at: Date.now(),
3195
+ cooldownMs: cooldownFor(error, this.ttlMs)
3196
+ });
3197
+ throw error;
3198
+ }).finally(() => {
3199
+ this.inflight.delete(key);
3200
+ });
3201
+ this.inflight.set(key, pending);
3202
+ }
3203
+ return pending;
3204
+ }
3205
+ /** Score one member against a snapshot's windows. */
3206
+ score(member, entry) {
3207
+ const windows = (entry.snapshot.windows ?? []).filter((window) => windowApplies(window, member.model));
3208
+ let available = true;
3209
+ let urgency = 0;
3210
+ for (const window of windows) {
3211
+ if (window.usedPercent >= QUOTA_FULL_PERCENT) available = false;
3212
+ urgency = Math.max(urgency, windowUrgency(window));
3213
+ }
3214
+ return {
3215
+ available,
3216
+ urgency,
3217
+ fetchedAt: entry.at
3218
+ };
1746
3219
  }
3220
+ };
3221
+ /**
3222
+ * The routing view of a fetch failure. Logged out: the member cannot serve
3223
+ * at all. Any other failure (network, endpoint rate limit) must not block
3224
+ * routing — the member stays available with a zero score, degrading the
3225
+ * strategy to plain priority order for it.
3226
+ */
3227
+ function degradedQuota(error) {
3228
+ return isMissingOrInvalidCredential(error) ? {
3229
+ available: false,
3230
+ urgency: 0,
3231
+ fetchedAt: 0
3232
+ } : {
3233
+ available: true,
3234
+ urgency: 0,
3235
+ fetchedAt: 0
3236
+ };
3237
+ }
3238
+ /** How long to hold a failure in the negative cache: the endpoint's own `retry-after`, or the default TTL. */
3239
+ function cooldownFor(error, defaultTtlMs) {
3240
+ return error instanceof OAuthEndpointError && error.retryAfterMs !== void 0 ? error.retryAfterMs : defaultTtlMs;
1747
3241
  }
1748
3242
  /**
1749
- * Build the durable half of one provider's catalog cache over the shared
1750
- * models.json file (concurrent writers are last-writer-wins, acceptable for
1751
- * a cache).
1752
- * @param provider - the provider route keying the file entry.
1753
- * @param path - store file path; defaults to {@link modelsFilePath}.
1754
- * @returns the persistence hooks for {@link ModelCatalogCache}.
3243
+ * Whether a window constrains this model: unscoped windows always do; a
3244
+ * model-scoped window (Claude's Opus/Sonnet lanes) applies when its scope
3245
+ * names the model family.
1755
3246
  */
1756
- function catalogStore(provider, path = modelsFilePath()) {
1757
- return {
1758
- async load() {
1759
- return sanitizeSnapshot((await readCatalogFile(path))[provider]);
1760
- },
1761
- async save(snapshot) {
1762
- const store = await readCatalogFile(path);
1763
- store[provider] = snapshot;
1764
- await writeCatalogFile(store, path);
1765
- },
1766
- async clear() {
1767
- const store = await readCatalogFile(path);
1768
- if (store[provider] === void 0) return;
1769
- delete store[provider];
1770
- await writeCatalogFile(store, path);
1771
- }
1772
- };
3247
+ function windowApplies(window, model) {
3248
+ if (window.scope === void 0) return true;
3249
+ return model.toLowerCase().includes(window.scope.toLowerCase());
3250
+ }
3251
+ /** The required burn rate of one window (fraction per ms). */
3252
+ function windowUrgency(window, now = Date.now()) {
3253
+ return Math.max(0, 1 - window.usedPercent / 100) / (window.resetsAt !== void 0 ? Math.max(window.resetsAt - now, 1) : FALLBACK_HORIZON_MS[window.kind]);
1773
3254
  }
1774
3255
 
1775
3256
  //#endregion
@@ -1825,6 +3306,14 @@ async function resolveImages(messages, attachments, signal) {
1825
3306
  })));
1826
3307
  }
1827
3308
 
3309
+ //#endregion
3310
+ //#region src/compat.ts
3311
+ /** Brand a string as a tool-call id: alpha's `ToolCallId`, rc.2's `CallId`. */
3312
+ const ToolCallId = (() => {
3313
+ const exports = llm;
3314
+ return exports["ToolCallId"] ?? exports["CallId"];
3315
+ })();
3316
+
1828
3317
  //#endregion
1829
3318
  //#region src/translate/sse.ts
1830
3319
  /**
@@ -2019,7 +3508,7 @@ function closeBlock$2(block) {
2019
3508
  };
2020
3509
  case "tool-call": return {
2021
3510
  type: "tool-call",
2022
- id: CallId(block.callId),
3511
+ id: ToolCallId(block.callId),
2023
3512
  name: block.name ?? "",
2024
3513
  arguments: block.text
2025
3514
  };
@@ -2109,7 +3598,7 @@ var ResponsesStreamTranslator = class {
2109
3598
  chunks.push({
2110
3599
  type: "tool-call-delta",
2111
3600
  index: block.index,
2112
- id: CallId(callId),
3601
+ id: ToolCallId(callId),
2113
3602
  ...item.name === void 0 ? {} : { name: item.name },
2114
3603
  argumentsDelta: ""
2115
3604
  });
@@ -2151,7 +3640,7 @@ var ResponsesStreamTranslator = class {
2151
3640
  chunks.push({
2152
3641
  type: "tool-call-delta",
2153
3642
  index: block.index,
2154
- id: CallId(block.callId),
3643
+ id: ToolCallId(block.callId),
2155
3644
  ...block.name === void 0 ? {} : { name: block.name },
2156
3645
  argumentsDelta: event.delta ?? ""
2157
3646
  });
@@ -2244,6 +3733,28 @@ const CODEX_CONTEXT_WINDOW = 4e5;
2244
3733
  const CODEX_DEFAULT_MAX_TOKENS = 128e3;
2245
3734
  /** Refresh when the access token has less than this much life left. */
2246
3735
  const CODEX_PREEMPT_MS = 5 * 6e4;
3736
+ /**
3737
+ * Body fields the backend uses to name a reset. A window-exhaustion rejection
3738
+ * carries `usage_limit_reached` with the seconds left on the window — the case
3739
+ * that used to classify as a terminal quota and never be retried at all.
3740
+ */
3741
+ const CODEX_RESET_FIELDS = [
3742
+ "resets_in_seconds",
3743
+ "reset_after_seconds",
3744
+ "resets_at",
3745
+ "reset_at"
3746
+ ];
3747
+ /**
3748
+ * Reads the reset instant of the Codex window that rejected a request.
3749
+ *
3750
+ * Body only. The `x-codex-{primary,secondary}-reset-after-seconds` headers are
3751
+ * rollover snapshots the backend attaches to every response, one per window,
3752
+ * so they say nothing about which window refused: a burst 429 that would clear
3753
+ * in seconds still carries a primary rollover hours out, and reading it would
3754
+ * park the turn for those hours. They reach the operator through
3755
+ * `rateLimitDiagnostics` instead.
3756
+ */
3757
+ const codexRateLimitReset = (_response, body, now) => resetFromFields(jsonBody(body), CODEX_RESET_FIELDS, now);
2247
3758
  /** Default instruction when the request carries no system prompt. */
2248
3759
  const DEFAULT_CODEX_INSTRUCTIONS = "You are Codex, a coding agent based on GPT-5. Help the user with their software engineering tasks.";
2249
3760
  /** Refresh-grant rejections that mean the login is gone for good. */
@@ -2492,10 +4003,6 @@ const CODEX_MODELS_URL = "https://chatgpt.com/backend-api/codex/models";
2492
4003
  * the range of current codex CLI releases.
2493
4004
  */
2494
4005
  const CODEX_CLIENT_VERSION = "0.147.0";
2495
- /** Display name for a wire reasoning-effort value. */
2496
- function effortName(effort) {
2497
- return effort === "xhigh" ? "Extra High" : effort.charAt(0).toUpperCase() + effort.slice(1);
2498
- }
2499
4006
  /**
2500
4007
  * Whether a catalog entry advertises the fast tier. Mirrors codex-rs
2501
4008
  * `ModelPreset::supports_fast_mode`: a `service_tiers` id matching the fast
@@ -2508,16 +4015,20 @@ function supportsFastTier(entry) {
2508
4015
  * Fetch the live codex model catalog with the session's auth headers.
2509
4016
  * @param session - the stored session (used as-is; never refreshed here).
2510
4017
  * @param fetchFn - fetch implementation (injectable for tests).
4018
+ * @param signal - caller cancellation (pool-assembly timeout).
2511
4019
  * @returns discovered models: hidden entries dropped, sorted by priority.
2512
4020
  */
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
- } });
4021
+ async function fetchCodexModels(session, fetchFn = proxiedFetch, signal) {
4022
+ const response = await fetchFn(`${CODEX_MODELS_URL}?client_version=${CODEX_CLIENT_VERSION}`, {
4023
+ headers: {
4024
+ "authorization": `Bearer ${session.accessToken}`,
4025
+ "chatgpt-account-id": session.accountId,
4026
+ "originator": "codex_cli_rs",
4027
+ "accept": "application/json",
4028
+ ...attributionHeaders()
4029
+ },
4030
+ ...signal === void 0 ? {} : { signal }
4031
+ });
2521
4032
  if (!response.ok) throw await oauthEndpointError(response, "codex models");
2522
4033
  const payload = await response.json();
2523
4034
  if (!Array.isArray(payload.models)) throw new Error("codex models endpoint returned no models array");
@@ -2527,7 +4038,7 @@ async function fetchCodexModels(session, fetchFn = proxiedFetch) {
2527
4038
  if (entry.visibility === "hide" || entry.visibility === "none") continue;
2528
4039
  const efforts = (entry.supported_reasoning_levels ?? []).filter((level) => typeof level.effort === "string" && level.effort.length > 0).map((level) => ({
2529
4040
  id: ReasoningEffortId(level.effort),
2530
- name: effortName(level.effort),
4041
+ name: effortDisplayName(level.effort),
2531
4042
  ...level.description === void 0 ? {} : { description: level.description }
2532
4043
  }));
2533
4044
  const defaultEffort = typeof entry.default_reasoning_level === "string" && entry.default_reasoning_level.length > 0 && efforts.some((effort) => effort.id === ReasoningEffortId(entry.default_reasoning_level)) ? ReasoningEffortId(entry.default_reasoning_level) : void 0;
@@ -2621,14 +4132,43 @@ function codexRequestBody(options, resolved, fast) {
2621
4132
  /** Codex wire adapter: one instance serves the `codex` provider route. */
2622
4133
  var CodexAdapter = class extends LlmAdapter {
2623
4134
  catalog;
4135
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
4136
+ accountCatalogs = /* @__PURE__ */ new Map();
4137
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
4138
+ catalogOwner;
2624
4139
  constructor(options) {
2625
4140
  super();
2626
4141
  this.options = options;
2627
4142
  this.catalog = new ModelCatalogCache(options.catalogStore);
2628
4143
  }
2629
4144
  /** Discovery fetcher: resolves the session through the refresh-aware path. */
2630
- async fetchCatalog() {
2631
- return fetchCodexModels(await this.options.tokens.session(), this.options.fetchFn);
4145
+ async fetchCatalog(account, signal) {
4146
+ return fetchCodexModels(await this.options.tokens.session(account), this.options.fetchFn, signal);
4147
+ }
4148
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
4149
+ clearAccountCatalog(account) {
4150
+ if (account === void 0) this.accountCatalogs.clear();
4151
+ else this.accountCatalogs.delete(account);
4152
+ if (account === void 0 || this.catalogOwner === account || this.catalogOwner === void 0) {
4153
+ this.catalogOwner = void 0;
4154
+ this.catalog.invalidate();
4155
+ }
4156
+ }
4157
+ /** Persisted cache for the default account; a throwaway cache for any other. */
4158
+ async catalogFor(account) {
4159
+ const defaultKey = await this.options.tokens.defaultAccount();
4160
+ const key = account ?? defaultKey;
4161
+ if (key === void 0 || key === defaultKey) {
4162
+ if (this.catalogOwner !== void 0 && this.catalogOwner !== defaultKey) this.catalog.invalidate();
4163
+ this.catalogOwner = defaultKey;
4164
+ return this.catalog;
4165
+ }
4166
+ let cache = this.accountCatalogs.get(key);
4167
+ if (cache === void 0) {
4168
+ cache = new ModelCatalogCache();
4169
+ this.accountCatalogs.set(key, cache);
4170
+ }
4171
+ return cache;
2632
4172
  }
2633
4173
  providerInfo(provider) {
2634
4174
  return {
@@ -2636,6 +4176,9 @@ var CodexAdapter = class extends LlmAdapter {
2636
4176
  name: "ChatGPT (Codex)"
2637
4177
  };
2638
4178
  }
4179
+ providerRetryPolicy(provider) {
4180
+ return subscriptionRetryPolicy(DEFAULT_RETRY, this.options.rateLimit ?? DEFAULT_RATE_LIMIT_WAIT, `codex: provider "${provider}" retryPolicy`);
4181
+ }
2639
4182
  staticModels(provider) {
2640
4183
  return this.options.models.map((model) => ({
2641
4184
  provider,
@@ -2645,17 +4188,37 @@ var CodexAdapter = class extends LlmAdapter {
2645
4188
  }));
2646
4189
  }
2647
4190
  async listModels(provider) {
2648
- if (await this.options.tokens.peek() === void 0) return [];
4191
+ const own = await this.listOwnModels(provider);
4192
+ const pool = this.options.pool?.();
4193
+ if (pool === void 0) return own;
4194
+ const extra = await pool.modelsForProvider(provider);
4195
+ const seen = new Set(own.map((model) => model.id));
4196
+ return [...own, ...extra.filter((model) => !seen.has(model.id))];
4197
+ }
4198
+ /** The provider's own catalog: union of every account, or one account when named. */
4199
+ async listOwnModels(provider, account, signal) {
4200
+ if (account === void 0) {
4201
+ const accounts = (await this.options.tokens.list()).map((entry) => entry.key);
4202
+ if (accounts.length === 0) return [];
4203
+ return unionAccountCatalogs(accounts, (key, accountSignal) => this.listOwnModels(provider, key, accountSignal), {
4204
+ timeoutMs: this.options.discoveryTimeoutMs ?? DISCOVERY_TIMEOUT_MS,
4205
+ ...signal === void 0 ? {} : { signal }
4206
+ });
4207
+ }
4208
+ if (!await this.options.tokens.hasSession(account)) return [];
2649
4209
  if (!this.options.discovery) return this.staticModels(provider);
4210
+ const catalog = await this.catalogFor(account);
2650
4211
  try {
2651
- return (await discoverOrRetryAuth((force) => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog()))).map((model) => ({
4212
+ return (await discoverOrRetryAuth((force) => this.options.tokens.session(account, force), catalog, () => catalog.get(() => this.fetchCatalog(account, signal)))).map((model) => ({
2652
4213
  provider,
2653
4214
  id: model.id,
2654
4215
  name: model.name,
2655
4216
  ...model.description === void 0 ? {} : { description: model.description },
2656
- inputModalities: CODEX_MODALITIES
4217
+ inputModalities: CODEX_MODALITIES,
4218
+ ...model.priority === void 0 ? {} : { priority: model.priority }
2657
4219
  }));
2658
4220
  } catch (error) {
4221
+ if (isDiscoveryAborted(error, signal)) throw error;
2659
4222
  if (isMissingOrInvalidCredential(error)) return [];
2660
4223
  this.options.onWarn?.(`codex model discovery failed; using the built-in catalog (${errorChain(error)})`);
2661
4224
  return this.staticModels(provider);
@@ -2670,7 +4233,9 @@ var CodexAdapter = class extends LlmAdapter {
2670
4233
  */
2671
4234
  async discovered(model) {
2672
4235
  if (!this.options.discovery) return void 0;
2673
- return (await this.catalog.resolve(() => this.fetchCatalog()))?.find((entry) => entry.id === model);
4236
+ return discoverAcrossAccounts((await this.options.tokens.list()).map((entry) => entry.key), async (account) => {
4237
+ return (await (await this.catalogFor(account)).resolve(() => this.fetchCatalog(account)))?.find((entry) => entry.id === model);
4238
+ });
2674
4239
  }
2675
4240
  /** Whether the discovered catalog advertises a fast tier for this model. */
2676
4241
  async supportsFastTier(model) {
@@ -2679,12 +4244,33 @@ var CodexAdapter = class extends LlmAdapter {
2679
4244
  /** Ids of every discovered model with a fast tier (the Speed toggle's visibility list). */
2680
4245
  async fastCapableModels() {
2681
4246
  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);
4247
+ const accounts = (await this.options.tokens.list()).map((entry) => entry.key);
4248
+ if (accounts.length === 0) return [];
4249
+ const seen = /* @__PURE__ */ new Set();
4250
+ const ids = [];
4251
+ for (const account of accounts) try {
4252
+ const models = await (await this.catalogFor(account)).resolve(() => this.fetchCatalog(account));
4253
+ for (const model of models ?? []) {
4254
+ if (model.fastTier !== true || seen.has(model.id)) continue;
4255
+ seen.add(model.id);
4256
+ ids.push(model.id);
4257
+ }
4258
+ } catch {}
4259
+ return ids;
2684
4260
  }
2685
4261
  async resolveModel(provider, model) {
4262
+ const pool = this.options.pool?.();
4263
+ if (pool !== void 0 && await pool.owns(provider, model)) return pool.resolveModel(provider, model);
4264
+ return this.resolveOwnModel(provider, model);
4265
+ }
4266
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
4267
+ async resolveOwnModel(provider, model) {
2686
4268
  const discovered = await this.discovered(model);
2687
4269
  const configured = this.options.models.find((entry) => entry.id === model);
4270
+ const reasoning = mergeReasoning(this.options.defaultEffortOf?.(model), discovered?.reasoning ?? {
4271
+ efforts: CODEX_EFFORTS,
4272
+ defaultEffort: CODEX_DEFAULT_EFFORT
4273
+ }, { extendable: discovered?.reasoning === void 0 });
2688
4274
  return {
2689
4275
  provider,
2690
4276
  id: model,
@@ -2693,22 +4279,34 @@ var CodexAdapter = class extends LlmAdapter {
2693
4279
  inputModalities: configured?.inputModalities ?? CODEX_MODALITIES,
2694
4280
  context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? CODEX_CONTEXT_WINDOW },
2695
4281
  defaultMaxTokens: configured?.maxTokens ?? CODEX_DEFAULT_MAX_TOKENS,
2696
- reasoning: discovered?.reasoning ?? {
2697
- efforts: CODEX_EFFORTS,
2698
- defaultEffort: CODEX_DEFAULT_EFFORT
2699
- }
4282
+ ...reasoning === void 0 ? {} : { reasoning }
2700
4283
  };
2701
4284
  }
2702
4285
  async *stream(options) {
4286
+ const pool = this.options.pool?.();
4287
+ if (pool !== void 0 && await pool.owns(options.provider, options.model)) {
4288
+ yield* pool.stream(options);
4289
+ return;
4290
+ }
4291
+ yield* this.streamCore(options);
4292
+ }
4293
+ /** Pool seam: stream through one specific account instead of the default. */
4294
+ streamAccount(options, account) {
4295
+ return this.streamCore(options, account);
4296
+ }
4297
+ async *streamCore(options, account) {
2703
4298
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
2704
4299
  try {
2705
- let session = await this.options.tokens.session();
4300
+ let session = await this.options.tokens.session(account);
2706
4301
  let response = await this.request(options, session, watchdog.signal);
2707
4302
  if (response.status === 401) {
2708
- session = await this.options.tokens.session(true);
4303
+ session = await this.options.tokens.session(account, true);
2709
4304
  response = await this.request(options, session, watchdog.signal);
2710
4305
  }
2711
- if (!response.ok) throw await httpLlmError(response, "codex API");
4306
+ if (!response.ok) throw await httpLlmError(response, "codex API", {
4307
+ rateLimitReset: codexRateLimitReset,
4308
+ ...this.options.onWarn === void 0 ? {} : { onWarn: this.options.onWarn }
4309
+ });
2712
4310
  if (response.body === null) throw new LlmError("codex API returned no response body", EMPTY_RESPONSE_CODE);
2713
4311
  yield* streamResponses(response.body, () => {
2714
4312
  watchdog.pulse();
@@ -2961,7 +4559,7 @@ function closeBlock$1(block) {
2961
4559
  };
2962
4560
  case "tool-call": return {
2963
4561
  type: "tool-call",
2964
- id: CallId(block.callId),
4562
+ id: ToolCallId(block.callId),
2965
4563
  name: block.name ?? "",
2966
4564
  arguments: block.text
2967
4565
  };
@@ -3064,7 +4662,7 @@ var AnthropicStreamTranslator = class {
3064
4662
  chunks.push({
3065
4663
  type: "tool-call-delta",
3066
4664
  index: opened.index,
3067
- id: CallId(opened.callId),
4665
+ id: ToolCallId(opened.callId),
3068
4666
  ...block.name === void 0 ? {} : { name: block.name },
3069
4667
  argumentsDelta: ""
3070
4668
  });
@@ -3101,7 +4699,7 @@ var AnthropicStreamTranslator = class {
3101
4699
  chunks.push({
3102
4700
  type: "tool-call-delta",
3103
4701
  index: block.index,
3104
- id: CallId(block.callId),
4702
+ id: ToolCallId(block.callId),
3105
4703
  ...block.name === void 0 ? {} : { name: block.name },
3106
4704
  argumentsDelta: delta.partial_json ?? ""
3107
4705
  });
@@ -3205,6 +4803,34 @@ const CLAUDE_DEFAULT_MAX_TOKENS = 32e3;
3205
4803
  /** Refresh when the access token has less than this much life left. */
3206
4804
  const CLAUDE_PREEMPT_MS = 5 * 6e4;
3207
4805
  /**
4806
+ * Body fields Anthropic uses to name a reset instant, read when the unified
4807
+ * headers are absent.
4808
+ */
4809
+ const CLAUDE_RESET_FIELDS = [
4810
+ "resets_at",
4811
+ "resetsAt",
4812
+ "reset_at",
4813
+ "retry_after"
4814
+ ];
4815
+ /**
4816
+ * Reads the reset instant of the Anthropic window that rejected a request.
4817
+ *
4818
+ * `anthropic-ratelimit-unified-*` is the subscription-plan family — the one
4819
+ * Claude Code renders as "resets 3pm" — and is the only header that names the
4820
+ * window which actually rejected this request. The per-bucket
4821
+ * `anthropic-ratelimit-{requests,tokens,input-tokens,output-tokens}-reset`
4822
+ * headers are deliberately not read: they are rollover snapshots attached to
4823
+ * every response, so on a 429 they cannot say which bucket refused, and the
4824
+ * earliest of them is typically the bucket that still had room — a wait that
4825
+ * lands straight back in the closed window. They reach the operator through
4826
+ * `rateLimitDiagnostics` instead.
4827
+ */
4828
+ const claudeRateLimitReset = (response, body, now) => {
4829
+ const unified = earliestReset(resetInstantFromHeader(response, "anthropic-ratelimit-unified-reset", now), resetInstantFromHeader(response, "anthropic-ratelimit-unified-fallback-reset", now));
4830
+ if (unified !== void 0) return unified;
4831
+ return resetFromFields(jsonBody(body), CLAUDE_RESET_FIELDS, now);
4832
+ };
4833
+ /**
3208
4834
  * The subscription endpoint only serves requests presenting as Claude Code,
3209
4835
  * so these headers impersonate the CLI; the harness attribution user-agent
3210
4836
  * cannot be sent here (one user-agent slot, and the CLI's wins).
@@ -3444,15 +5070,18 @@ function claudeReasoning(capabilities) {
3444
5070
  }));
3445
5071
  return efforts.length > 0 ? { efforts } : void 0;
3446
5072
  }
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
- } });
5073
+ /** Fetch the live model catalog from the subscription endpoint. `signal` cancels the request. */
5074
+ async function fetchClaudeModels(session, fetchFn = proxiedFetch, signal) {
5075
+ const response = await fetchFn(CLAUDE_MODELS_URL, {
5076
+ headers: {
5077
+ "authorization": `Bearer ${session.accessToken}`,
5078
+ "anthropic-version": "2023-06-01",
5079
+ "user-agent": getClaudeCliUserAgent(),
5080
+ "anthropic-dangerous-direct-browser-access": "true",
5081
+ "accept": "application/json"
5082
+ },
5083
+ ...signal === void 0 ? {} : { signal }
5084
+ });
3456
5085
  if (!response.ok) throw await httpLlmError(response, "claude models API");
3457
5086
  const payload = await response.json();
3458
5087
  if (!Array.isArray(payload.data)) throw new Error("claude models API returned an invalid catalog");
@@ -3469,14 +5098,6 @@ async function fetchClaudeModels(session, fetchFn = proxiedFetch) {
3469
5098
  if (models.length === 0) throw new Error("claude models API returned an empty catalog");
3470
5099
  return models;
3471
5100
  }
3472
- /**
3473
- * Claude Code's own SDK retry shape: exponential backoff starting at 1s,
3474
- * doubling per attempt, capped at 60s, plus jitter. `maxRetries` is the
3475
- * count of retries after the first attempt (Claude Code defaults to 10).
3476
- */
3477
- const CLAUDE_RETRY_INITIAL_DELAY_MS = 1e3;
3478
- const CLAUDE_RETRY_MAX_DELAY_MS = 6e4;
3479
- const CLAUDE_RETRY_JITTER_RATIO = .2;
3480
5101
  /** The Claude 4.5 family accepts image input. */
3481
5102
  const CLAUDE_MODALITIES = ["text", "image"];
3482
5103
  /**
@@ -3512,17 +5133,48 @@ function claudeRequestBody(options, messages, maxTokens, thinking, effort) {
3512
5133
  /** Claude wire adapter: one instance serves the `claude` provider route. */
3513
5134
  var ClaudeAdapter = class extends LlmAdapter {
3514
5135
  catalog;
5136
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
5137
+ accountCatalogs = /* @__PURE__ */ new Map();
5138
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
5139
+ catalogOwner;
3515
5140
  constructor(options) {
3516
5141
  super();
3517
5142
  this.options = options;
3518
5143
  this.catalog = new ModelCatalogCache(options.catalogStore);
3519
5144
  }
3520
- async fetchCatalog() {
3521
- return fetchClaudeModels(await this.options.tokens.session(), this.options.fetchFn);
5145
+ async fetchCatalog(account, signal) {
5146
+ return fetchClaudeModels(await this.options.tokens.session(account), this.options.fetchFn, signal);
5147
+ }
5148
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
5149
+ clearAccountCatalog(account) {
5150
+ if (account === void 0) this.accountCatalogs.clear();
5151
+ else this.accountCatalogs.delete(account);
5152
+ if (account === void 0 || this.catalogOwner === account || this.catalogOwner === void 0) {
5153
+ this.catalogOwner = void 0;
5154
+ this.catalog.invalidate();
5155
+ }
5156
+ }
5157
+ /** Persisted cache for the default account; a throwaway cache for any other. */
5158
+ async catalogFor(account) {
5159
+ const defaultKey = await this.options.tokens.defaultAccount();
5160
+ const key = account ?? defaultKey;
5161
+ if (key === void 0 || key === defaultKey) {
5162
+ if (this.catalogOwner !== void 0 && this.catalogOwner !== defaultKey) this.catalog.invalidate();
5163
+ this.catalogOwner = defaultKey;
5164
+ return this.catalog;
5165
+ }
5166
+ let cache = this.accountCatalogs.get(key);
5167
+ if (cache === void 0) {
5168
+ cache = new ModelCatalogCache();
5169
+ this.accountCatalogs.set(key, cache);
5170
+ }
5171
+ return cache;
3522
5172
  }
3523
5173
  async discovered(model) {
3524
5174
  if (!this.options.discovery) return void 0;
3525
- return (await this.catalog.resolve(() => this.fetchCatalog()))?.find((entry) => entry.id === model);
5175
+ return discoverAcrossAccounts((await this.options.tokens.list()).map((entry) => entry.key), async (account) => {
5176
+ return (await (await this.catalogFor(account)).resolve(() => this.fetchCatalog(account)))?.find((entry) => entry.id === model);
5177
+ });
3526
5178
  }
3527
5179
  staticModels(provider) {
3528
5180
  return this.options.models.map((model) => ({
@@ -3539,37 +5191,53 @@ var ClaudeAdapter = class extends LlmAdapter {
3539
5191
  };
3540
5192
  }
3541
5193
  providerRetryPolicy(provider) {
3542
- if (this.options.maxRetries === void 0) return void 0;
3543
- return resolveRetryPolicy({
3544
- mode: "normal",
3545
- maxRetries: this.options.maxRetries,
3546
- backoff: {
3547
- initialDelayMs: CLAUDE_RETRY_INITIAL_DELAY_MS,
3548
- maxDelayMs: CLAUDE_RETRY_MAX_DELAY_MS,
3549
- jitterRatio: CLAUDE_RETRY_JITTER_RATIO
3550
- }
3551
- }, `claude: provider "${provider}" retryPolicy`);
5194
+ return subscriptionRetryPolicy(DEFAULT_RETRY, this.options.rateLimit ?? DEFAULT_RATE_LIMIT_WAIT, `claude: provider "${provider}" retryPolicy`);
3552
5195
  }
3553
5196
  async listModels(provider) {
3554
- if (await this.options.tokens.peek() === void 0) return [];
5197
+ const own = await this.listOwnModels(provider);
5198
+ const pool = this.options.pool?.();
5199
+ if (pool === void 0) return own;
5200
+ const extra = await pool.modelsForProvider(provider);
5201
+ const seen = new Set(own.map((model) => model.id));
5202
+ return [...own, ...extra.filter((model) => !seen.has(model.id))];
5203
+ }
5204
+ /** The provider's own catalog: union of every account, or one account when named. */
5205
+ async listOwnModels(provider, account, signal) {
5206
+ if (account === void 0) {
5207
+ const accounts = (await this.options.tokens.list()).map((entry) => entry.key);
5208
+ if (accounts.length === 0) return [];
5209
+ return unionAccountCatalogs(accounts, (key, accountSignal) => this.listOwnModels(provider, key, accountSignal), {
5210
+ timeoutMs: DISCOVERY_TIMEOUT_MS,
5211
+ ...signal === void 0 ? {} : { signal }
5212
+ });
5213
+ }
5214
+ if (!await this.options.tokens.hasSession(account)) return [];
3555
5215
  if (!this.options.discovery) return this.staticModels(provider);
5216
+ const catalog = await this.catalogFor(account);
3556
5217
  try {
3557
- return (await discoverOrRetryAuth((force) => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog()))).map((model) => ({
5218
+ return (await discoverOrRetryAuth((force) => this.options.tokens.session(account, force), catalog, () => catalog.get(() => this.fetchCatalog(account, signal)))).map((model) => ({
3558
5219
  provider,
3559
5220
  id: model.id,
3560
5221
  name: model.name,
3561
5222
  inputModalities: CLAUDE_MODALITIES
3562
5223
  }));
3563
5224
  } catch (error) {
5225
+ if (isDiscoveryAborted(error, signal)) throw error;
3564
5226
  if (isMissingOrInvalidCredential(error)) return [];
3565
5227
  this.options.onWarn?.(`claude model discovery failed; using the built-in catalog (${errorChain(error)})`);
3566
5228
  return this.staticModels(provider);
3567
5229
  }
3568
5230
  }
3569
5231
  async resolveModel(provider, model) {
5232
+ const pool = this.options.pool?.();
5233
+ if (pool !== void 0 && await pool.owns(provider, model)) return pool.resolveModel(provider, model);
5234
+ return this.resolveOwnModel(provider, model);
5235
+ }
5236
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
5237
+ async resolveOwnModel(provider, model) {
3570
5238
  const disc = await this.discovered(model);
3571
5239
  const configured = this.options.models.find((entry) => entry.id === model);
3572
- const reasoning = disc?.reasoning;
5240
+ const reasoning = mergeReasoning(this.options.defaultEffortOf?.(model), disc?.reasoning);
3573
5241
  return {
3574
5242
  provider,
3575
5243
  id: model,
@@ -3581,15 +5249,30 @@ var ClaudeAdapter = class extends LlmAdapter {
3581
5249
  };
3582
5250
  }
3583
5251
  async *stream(options) {
5252
+ const pool = this.options.pool?.();
5253
+ if (pool !== void 0 && await pool.owns(options.provider, options.model)) {
5254
+ yield* pool.stream(options);
5255
+ return;
5256
+ }
5257
+ yield* this.streamCore(options);
5258
+ }
5259
+ /** Pool seam: stream through one specific account instead of the default. */
5260
+ streamAccount(options, account) {
5261
+ return this.streamCore(options, account);
5262
+ }
5263
+ async *streamCore(options, account) {
3584
5264
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
3585
5265
  try {
3586
- let session = await this.options.tokens.session();
5266
+ let session = await this.options.tokens.session(account);
3587
5267
  let response = await this.request(options, session, watchdog.signal);
3588
5268
  if (response.status === 401) {
3589
- session = await this.options.tokens.session(true);
5269
+ session = await this.options.tokens.session(account, true);
3590
5270
  response = await this.request(options, session, watchdog.signal);
3591
5271
  }
3592
- if (!response.ok) throw await httpLlmError(response, "claude API");
5272
+ if (!response.ok) throw await httpLlmError(response, "claude API", {
5273
+ rateLimitReset: claudeRateLimitReset,
5274
+ ...this.options.onWarn === void 0 ? {} : { onWarn: this.options.onWarn }
5275
+ });
3593
5276
  if (response.body === null) throw new LlmError("claude API returned no response body", EMPTY_RESPONSE_CODE);
3594
5277
  yield* streamAnthropic(response.body, () => {
3595
5278
  watchdog.pulse();
@@ -3656,6 +5339,24 @@ const GROK_CONTEXT_WINDOW = 256e3;
3656
5339
  const GROK_DEFAULT_MAX_TOKENS = 32e3;
3657
5340
  /** Refresh when the access token has less than this much life left. */
3658
5341
  const GROK_PREEMPT_MS = 2 * 6e4;
5342
+ /** Body fields xAI uses to name a delay or reset. */
5343
+ const GROK_RESET_FIELDS = [
5344
+ "retry_after",
5345
+ "retry_after_seconds",
5346
+ "resets_at",
5347
+ "reset_at"
5348
+ ];
5349
+ /**
5350
+ * Reads the reset instant of the xAI window that rejected a request.
5351
+ *
5352
+ * Body only. xAI serves the OpenAI-compatible `x-ratelimit-reset-*` family,
5353
+ * whose values are rollover durations (`6m0s`) present on every response, one
5354
+ * per bucket — on a 429 the earliest of them is usually a bucket with room
5355
+ * (`0s` for the request bucket while the token bucket is the one exhausted),
5356
+ * which would burn the whole retry budget in seconds. They reach the operator
5357
+ * through `rateLimitDiagnostics` instead.
5358
+ */
5359
+ const grokRateLimitReset = (_response, body, now) => resetFromFields(jsonBody(body), GROK_RESET_FIELDS, now);
3659
5360
  /** A discovered URL must be https on x.ai or a subdomain; anything else is a hostile document. */
3660
5361
  function assertXaiEndpoint(url, field) {
3661
5362
  let parsed;
@@ -3918,15 +5619,19 @@ function grokCliReasoning(entry) {
3918
5619
  * Fetch the CLI catalog and index its per-model metadata by model id.
3919
5620
  * @param session - the stored session (used as-is; never refreshed here).
3920
5621
  * @param fetchFn - fetch implementation (injectable for tests).
5622
+ * @param signal - caller cancellation (pool-assembly timeout).
3921
5623
  * @returns model id → contributed metadata.
3922
5624
  */
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
- } });
5625
+ async function fetchGrokCliCatalog(session, fetchFn = proxiedFetch, signal) {
5626
+ const response = await fetchFn(GROK_CLI_MODELS_URL, {
5627
+ headers: {
5628
+ "authorization": `Bearer ${session.accessToken}`,
5629
+ "x-xai-token-auth": "xai-grok-cli",
5630
+ "accept": "application/json",
5631
+ ...attributionHeaders()
5632
+ },
5633
+ ...signal === void 0 ? {} : { signal }
5634
+ });
3930
5635
  if (!response.ok) throw await oauthEndpointError(response, "grok CLI catalog");
3931
5636
  const payload = await response.json();
3932
5637
  if (!Array.isArray(payload.data)) throw new Error("grok CLI catalog returned no data array");
@@ -3979,15 +5684,20 @@ function grokPriorMeta(prior) {
3979
5684
  * @param onWarn - warning sink for a failed CLI catalog fetch.
3980
5685
  * @param previous - last-known catalog used to keep enrichment when the CLI
3981
5686
  * catalog is down or omits a model.
5687
+ * @param signal - caller cancellation (pool-assembly timeout).
3982
5688
  * @returns discovered chat models in endpoint order.
3983
5689
  */
3984
- async function fetchGrokModels(session, fetchFn = proxiedFetch, onWarn, previous) {
5690
+ async function fetchGrokModels(session, fetchFn = proxiedFetch, onWarn, previous, signal) {
3985
5691
  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) => {
5692
+ const [response, cliCatalog] = await Promise.all([fetchFn(GROK_MODELS_URL, {
5693
+ headers: {
5694
+ "authorization": `Bearer ${session.accessToken}`,
5695
+ "accept": "application/json",
5696
+ ...attributionHeaders()
5697
+ },
5698
+ ...signal === void 0 ? {} : { signal }
5699
+ }), fetchGrokCliCatalog(session, fetchFn, signal).catch((error) => {
5700
+ if (isDiscoveryAborted(error, signal)) throw error;
3991
5701
  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
5702
  })]);
3993
5703
  if (!response.ok) throw await oauthEndpointError(response, "grok models");
@@ -4012,14 +5722,44 @@ async function fetchGrokModels(session, fetchFn = proxiedFetch, onWarn, previous
4012
5722
  /** Grok wire adapter: one instance serves the `grok` provider route. */
4013
5723
  var GrokAdapter = class extends LlmAdapter {
4014
5724
  catalog;
5725
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
5726
+ accountCatalogs = /* @__PURE__ */ new Map();
5727
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
5728
+ catalogOwner;
4015
5729
  constructor(options) {
4016
5730
  super();
4017
5731
  this.options = options;
4018
5732
  this.catalog = new ModelCatalogCache(options.catalogStore);
4019
5733
  }
4020
5734
  /** 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());
5735
+ async fetchCatalog(account, signal) {
5736
+ const lastKnown = account === void 0 || account === await this.options.tokens.defaultAccount() ? this.catalog.lastKnown() : this.accountCatalogs.get(account)?.lastKnown();
5737
+ return fetchGrokModels(await this.options.tokens.session(account), this.options.fetchFn, this.options.onWarn, lastKnown, signal);
5738
+ }
5739
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
5740
+ clearAccountCatalog(account) {
5741
+ if (account === void 0) this.accountCatalogs.clear();
5742
+ else this.accountCatalogs.delete(account);
5743
+ if (account === void 0 || this.catalogOwner === account || this.catalogOwner === void 0) {
5744
+ this.catalogOwner = void 0;
5745
+ this.catalog.invalidate();
5746
+ }
5747
+ }
5748
+ /** Persisted cache for the default account; a throwaway cache for any other. */
5749
+ async catalogFor(account) {
5750
+ const defaultKey = await this.options.tokens.defaultAccount();
5751
+ const key = account ?? defaultKey;
5752
+ if (key === void 0 || key === defaultKey) {
5753
+ if (this.catalogOwner !== void 0 && this.catalogOwner !== defaultKey) this.catalog.invalidate();
5754
+ this.catalogOwner = defaultKey;
5755
+ return this.catalog;
5756
+ }
5757
+ let cache = this.accountCatalogs.get(key);
5758
+ if (cache === void 0) {
5759
+ cache = new ModelCatalogCache();
5760
+ this.accountCatalogs.set(key, cache);
5761
+ }
5762
+ return cache;
4023
5763
  }
4024
5764
  listed(provider, discovered) {
4025
5765
  return discovered.map((model) => ({
@@ -4036,6 +5776,9 @@ var GrokAdapter = class extends LlmAdapter {
4036
5776
  name: "Grok (Subscription)"
4037
5777
  };
4038
5778
  }
5779
+ providerRetryPolicy(provider) {
5780
+ return subscriptionRetryPolicy(DEFAULT_RETRY, this.options.rateLimit ?? DEFAULT_RATE_LIMIT_WAIT, `grok: provider "${provider}" retryPolicy`);
5781
+ }
4039
5782
  staticModels(provider) {
4040
5783
  return this.options.models.map((model) => ({
4041
5784
  provider,
@@ -4045,11 +5788,30 @@ var GrokAdapter = class extends LlmAdapter {
4045
5788
  }));
4046
5789
  }
4047
5790
  async listModels(provider) {
4048
- if (await this.options.tokens.peek() === void 0) return [];
5791
+ const own = await this.listOwnModels(provider);
5792
+ const pool = this.options.pool?.();
5793
+ if (pool === void 0) return own;
5794
+ const extra = await pool.modelsForProvider(provider);
5795
+ const seen = new Set(own.map((model) => model.id));
5796
+ return [...own, ...extra.filter((model) => !seen.has(model.id))];
5797
+ }
5798
+ /** The provider's own catalog: union of every account, or one account when named. */
5799
+ async listOwnModels(provider, account, signal) {
5800
+ if (account === void 0) {
5801
+ const accounts = (await this.options.tokens.list()).map((entry) => entry.key);
5802
+ if (accounts.length === 0) return [];
5803
+ return unionAccountCatalogs(accounts, (key, accountSignal) => this.listOwnModels(provider, key, accountSignal), {
5804
+ timeoutMs: DISCOVERY_TIMEOUT_MS,
5805
+ ...signal === void 0 ? {} : { signal }
5806
+ });
5807
+ }
5808
+ if (!await this.options.tokens.hasSession(account)) return [];
4049
5809
  if (!this.options.discovery) return this.staticModels(provider);
5810
+ const catalog = await this.catalogFor(account);
4050
5811
  try {
4051
- return this.listed(provider, await discoverOrRetryAuth((force) => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog())));
5812
+ return this.listed(provider, await discoverOrRetryAuth((force) => this.options.tokens.session(account, force), catalog, () => catalog.get(() => this.fetchCatalog(account, signal))));
4052
5813
  } catch (error) {
5814
+ if (isDiscoveryAborted(error, signal)) throw error;
4053
5815
  if (isMissingOrInvalidCredential(error)) return [];
4054
5816
  this.options.onWarn?.(`grok model discovery failed; using the built-in catalog (${errorChain(error)})`);
4055
5817
  return this.staticModels(provider);
@@ -4065,11 +5827,20 @@ var GrokAdapter = class extends LlmAdapter {
4065
5827
  */
4066
5828
  async discovered(model) {
4067
5829
  if (!this.options.discovery) return void 0;
4068
- return (await this.catalog.resolve(() => this.fetchCatalog()))?.find((entry) => entry.id === model);
5830
+ return discoverAcrossAccounts((await this.options.tokens.list()).map((entry) => entry.key), async (account) => {
5831
+ return (await (await this.catalogFor(account)).resolve(() => this.fetchCatalog(account)))?.find((entry) => entry.id === model);
5832
+ });
4069
5833
  }
4070
5834
  async resolveModel(provider, model) {
5835
+ const pool = this.options.pool?.();
5836
+ if (pool !== void 0 && await pool.owns(provider, model)) return pool.resolveModel(provider, model);
5837
+ return this.resolveOwnModel(provider, model);
5838
+ }
5839
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
5840
+ async resolveOwnModel(provider, model) {
4071
5841
  const discovered = await this.discovered(model);
4072
5842
  const configured = this.options.models.find((entry) => entry.id === model);
5843
+ const reasoning = mergeReasoning(this.options.defaultEffortOf?.(model), discovered?.reasoning);
4073
5844
  return {
4074
5845
  provider,
4075
5846
  id: model,
@@ -4078,19 +5849,34 @@ var GrokAdapter = class extends LlmAdapter {
4078
5849
  inputModalities: configured?.inputModalities ?? grokModalities(model),
4079
5850
  context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? GROK_CONTEXT_WINDOW },
4080
5851
  defaultMaxTokens: configured?.maxTokens ?? GROK_DEFAULT_MAX_TOKENS,
4081
- ...discovered?.reasoning === void 0 ? {} : { reasoning: discovered.reasoning }
5852
+ ...reasoning === void 0 ? {} : { reasoning }
4082
5853
  };
4083
5854
  }
4084
5855
  async *stream(options) {
5856
+ const pool = this.options.pool?.();
5857
+ if (pool !== void 0 && await pool.owns(options.provider, options.model)) {
5858
+ yield* pool.stream(options);
5859
+ return;
5860
+ }
5861
+ yield* this.streamCore(options);
5862
+ }
5863
+ /** Pool seam: stream through one specific account instead of the default. */
5864
+ streamAccount(options, account) {
5865
+ return this.streamCore(options, account);
5866
+ }
5867
+ async *streamCore(options, account) {
4085
5868
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
4086
5869
  try {
4087
- let session = await this.options.tokens.session();
5870
+ let session = await this.options.tokens.session(account);
4088
5871
  let response = await this.request(options, session, watchdog.signal);
4089
5872
  if (response.status === 401) {
4090
- session = await this.options.tokens.session(true);
5873
+ session = await this.options.tokens.session(account, true);
4091
5874
  response = await this.request(options, session, watchdog.signal);
4092
5875
  }
4093
- if (!response.ok) throw await httpLlmError(response, "grok API");
5876
+ if (!response.ok) throw await httpLlmError(response, "grok API", {
5877
+ rateLimitReset: grokRateLimitReset,
5878
+ ...this.options.onWarn === void 0 ? {} : { onWarn: this.options.onWarn }
5879
+ });
4094
5880
  if (response.body === null) throw new LlmError("grok API returned no response body", EMPTY_RESPONSE_CODE);
4095
5881
  yield* streamResponses(response.body, () => {
4096
5882
  watchdog.pulse();
@@ -4112,6 +5898,7 @@ var GrokAdapter = class extends LlmAdapter {
4112
5898
  parallel_tool_calls: true,
4113
5899
  ...options.maxTokens !== void 0 ? { max_output_tokens: options.maxTokens } : {},
4114
5900
  ...options.reasoningEffort !== void 0 ? { reasoning: { effort: String(options.reasoningEffort) } } : {},
5901
+ ...options.sessionId !== void 0 ? { prompt_cache_key: String(options.sessionId) } : {},
4115
5902
  store: false,
4116
5903
  stream: true
4117
5904
  };
@@ -4274,7 +6061,7 @@ function closeBlock(block) {
4274
6061
  };
4275
6062
  case "tool-call": return {
4276
6063
  type: "tool-call",
4277
- id: CallId(block.callId),
6064
+ id: ToolCallId(block.callId),
4278
6065
  name: block.name ?? "",
4279
6066
  arguments: block.text
4280
6067
  };
@@ -4428,7 +6215,7 @@ var ChatCompletionsStreamTranslator = class {
4428
6215
  chunks.push({
4429
6216
  type: "tool-call-delta",
4430
6217
  index: block.index,
4431
- id: CallId(block.callId),
6218
+ id: ToolCallId(block.callId),
4432
6219
  ...block.name === void 0 ? {} : { name: block.name },
4433
6220
  argumentsDelta: ""
4434
6221
  });
@@ -4438,7 +6225,7 @@ var ChatCompletionsStreamTranslator = class {
4438
6225
  chunks.push({
4439
6226
  type: "tool-call-delta",
4440
6227
  index: block.index,
4441
- id: CallId(block.callId),
6228
+ id: ToolCallId(block.callId),
4442
6229
  argumentsDelta: call.function.arguments
4443
6230
  });
4444
6231
  }
@@ -4694,14 +6481,18 @@ function copilotReasoning(entry) {
4694
6481
  * reasoning efforts (the endpoint discloses no default, so none is claimed).
4695
6482
  * @param session - the stored session (used as-is; never refreshed here).
4696
6483
  * @param fetchFn - fetch implementation (injectable for tests).
6484
+ * @param signal - caller cancellation (pool-assembly timeout).
4697
6485
  * @returns discovered chat models in endpoint order.
4698
6486
  */
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
- } });
6487
+ async function fetchCopilotModels(session, fetchFn = proxiedFetch, signal) {
6488
+ const response = await fetchFn(COPILOT_MODELS_URL, {
6489
+ headers: {
6490
+ "authorization": `Bearer ${session.accessToken}`,
6491
+ "accept": "application/json",
6492
+ ...copilotHeaders(false, await latestVsCodeVersion(fetchFn))
6493
+ },
6494
+ ...signal === void 0 ? {} : { signal }
6495
+ });
4705
6496
  if (!response.ok) throw await oauthEndpointError(response, "copilot models");
4706
6497
  const payload = await response.json();
4707
6498
  if (!Array.isArray(payload.data)) throw new Error("copilot models endpoint returned no data array");
@@ -4923,6 +6714,10 @@ var CopilotResponsesItemNormalizer = class {
4923
6714
  /** Copilot wire adapter: one instance serves the `copilot` provider route. */
4924
6715
  var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4925
6716
  catalog;
6717
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
6718
+ accountCatalogs = /* @__PURE__ */ new Map();
6719
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
6720
+ catalogOwner;
4926
6721
  /**
4927
6722
  * [2026-08-23]-[a reasoning model continuing a tool chain must get its
4928
6723
  * reasoning back or it restarts from scratch every tool round trip; the
@@ -4945,8 +6740,33 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4945
6740
  this.catalog = new ModelCatalogCache(options.catalogStore);
4946
6741
  }
4947
6742
  /** Discovery fetcher: resolves the session through the refresh-aware path. */
4948
- async fetchCatalog() {
4949
- return fetchCopilotModels(await this.options.tokens.session(), this.options.fetchFn);
6743
+ async fetchCatalog(account, signal) {
6744
+ return fetchCopilotModels(await this.options.tokens.session(account), this.options.fetchFn, signal);
6745
+ }
6746
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
6747
+ clearAccountCatalog(account) {
6748
+ if (account === void 0) this.accountCatalogs.clear();
6749
+ else this.accountCatalogs.delete(account);
6750
+ if (account === void 0 || this.catalogOwner === account || this.catalogOwner === void 0) {
6751
+ this.catalogOwner = void 0;
6752
+ this.catalog.invalidate();
6753
+ }
6754
+ }
6755
+ /** Persisted cache for the default account; a throwaway cache for any other. */
6756
+ async catalogFor(account) {
6757
+ const defaultKey = await this.options.tokens.defaultAccount();
6758
+ const key = account ?? defaultKey;
6759
+ if (key === void 0 || key === defaultKey) {
6760
+ if (this.catalogOwner !== void 0 && this.catalogOwner !== defaultKey) this.catalog.invalidate();
6761
+ this.catalogOwner = defaultKey;
6762
+ return this.catalog;
6763
+ }
6764
+ let cache = this.accountCatalogs.get(key);
6765
+ if (cache === void 0) {
6766
+ cache = new ModelCatalogCache();
6767
+ this.accountCatalogs.set(key, cache);
6768
+ }
6769
+ return cache;
4950
6770
  }
4951
6771
  providerInfo(provider) {
4952
6772
  return {
@@ -4954,6 +6774,9 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4954
6774
  name: "GitHub Copilot"
4955
6775
  };
4956
6776
  }
6777
+ providerRetryPolicy(provider) {
6778
+ return subscriptionRetryPolicy(DEFAULT_RETRY, this.options.rateLimit ?? DEFAULT_RATE_LIMIT_WAIT, `copilot: provider "${provider}" retryPolicy`);
6779
+ }
4957
6780
  staticModels(provider) {
4958
6781
  return this.options.models.map((model) => ({
4959
6782
  provider,
@@ -4963,10 +6786,28 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4963
6786
  }));
4964
6787
  }
4965
6788
  async listModels(provider) {
4966
- if (await this.options.tokens.peek() === void 0) return [];
6789
+ const own = await this.listOwnModels(provider);
6790
+ const pool = this.options.pool?.();
6791
+ if (pool === void 0) return own;
6792
+ const extra = await pool.modelsForProvider(provider);
6793
+ const seen = new Set(own.map((model) => model.id));
6794
+ return [...own, ...extra.filter((model) => !seen.has(model.id))];
6795
+ }
6796
+ /** The provider's own catalog: union of every account, or one account when named. */
6797
+ async listOwnModels(provider, account, signal) {
6798
+ if (account === void 0) {
6799
+ const accounts = (await this.options.tokens.list()).map((entry) => entry.key);
6800
+ if (accounts.length === 0) return [];
6801
+ return unionAccountCatalogs(accounts, (key, accountSignal) => this.listOwnModels(provider, key, accountSignal), {
6802
+ timeoutMs: DISCOVERY_TIMEOUT_MS,
6803
+ ...signal === void 0 ? {} : { signal }
6804
+ });
6805
+ }
6806
+ if (!await this.options.tokens.hasSession(account)) return [];
4967
6807
  if (!this.options.discovery) return this.staticModels(provider);
6808
+ const catalog = await this.catalogFor(account);
4968
6809
  try {
4969
- return (await discoverOrRetryAuth((force) => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog()))).map((model) => ({
6810
+ return (await discoverOrRetryAuth((force) => this.options.tokens.session(account, force), catalog, () => catalog.get(() => this.fetchCatalog(account, signal)))).map((model) => ({
4970
6811
  provider,
4971
6812
  id: model.id,
4972
6813
  name: model.name,
@@ -4974,6 +6815,7 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4974
6815
  ...model.inputModalities === void 0 ? {} : { inputModalities: model.inputModalities }
4975
6816
  }));
4976
6817
  } catch (error) {
6818
+ if (isDiscoveryAborted(error, signal)) throw error;
4977
6819
  if (isMissingOrInvalidCredential(error)) return [];
4978
6820
  this.options.onWarn?.(`copilot model discovery failed; using the built-in catalog (${errorChain(error)})`);
4979
6821
  return this.staticModels(provider);
@@ -4987,7 +6829,9 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4987
6829
  */
4988
6830
  async discovered(model) {
4989
6831
  if (!this.options.discovery) return void 0;
4990
- return (await this.catalog.resolve(() => this.fetchCatalog()))?.find((entry) => entry.id === model);
6832
+ return discoverAcrossAccounts((await this.options.tokens.list()).map((entry) => entry.key), async (account) => {
6833
+ return (await (await this.catalogFor(account)).resolve(() => this.fetchCatalog(account)))?.find((entry) => entry.id === model);
6834
+ });
4991
6835
  }
4992
6836
  /**
4993
6837
  * [2026-08-23]-[a manually configured responses-only model combined with
@@ -5087,8 +6931,15 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
5087
6931
  this.replayByScope.clear();
5088
6932
  }
5089
6933
  async resolveModel(provider, model) {
6934
+ const pool = this.options.pool?.();
6935
+ if (pool !== void 0 && await pool.owns(provider, model)) return pool.resolveModel(provider, model);
6936
+ return this.resolveOwnModel(provider, model);
6937
+ }
6938
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
6939
+ async resolveOwnModel(provider, model) {
5090
6940
  const discovered = await this.discovered(model);
5091
6941
  const configured = this.options.models.find((entry) => entry.id === model);
6942
+ const reasoning = mergeReasoning(this.options.defaultEffortOf?.(model), discovered?.reasoning);
5092
6943
  return {
5093
6944
  provider,
5094
6945
  id: model,
@@ -5097,22 +6948,34 @@ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
5097
6948
  inputModalities: discovered?.inputModalities ?? configured?.inputModalities ?? ["text"],
5098
6949
  context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? COPILOT_CONTEXT_WINDOW },
5099
6950
  defaultMaxTokens: configured?.maxTokens ?? COPILOT_DEFAULT_MAX_TOKENS,
5100
- ...discovered?.reasoning === void 0 ? {} : { reasoning: discovered.reasoning }
6951
+ ...reasoning === void 0 ? {} : { reasoning }
5101
6952
  };
5102
6953
  }
5103
6954
  async *stream(options) {
6955
+ const pool = this.options.pool?.();
6956
+ if (pool !== void 0 && await pool.owns(options.provider, options.model)) {
6957
+ yield* pool.stream(options);
6958
+ return;
6959
+ }
6960
+ yield* this.streamCore(options);
6961
+ }
6962
+ /** Pool seam: stream through one specific account instead of the default. */
6963
+ streamAccount(options, account) {
6964
+ return this.streamCore(options, account);
6965
+ }
6966
+ async *streamCore(options, account) {
5104
6967
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
5105
6968
  try {
5106
6969
  const wire = copilotRequestWire(this.configuredWireEntry(options.model) ?? await this.discovered(options.model), options);
5107
- let session = await this.options.tokens.session();
6970
+ let session = await this.options.tokens.session(account);
5108
6971
  const scope = this.replayScope(session.refreshToken, options);
5109
6972
  let response = await this.request(options, session, watchdog.signal, wire, scope);
5110
6973
  if (response.status === 401) {
5111
6974
  await latestVsCodeVersion(this.options.fetchFn ?? proxiedFetch, true);
5112
- session = await this.options.tokens.session(true);
6975
+ session = await this.options.tokens.session(account, true);
5113
6976
  response = await this.request(options, session, watchdog.signal, wire, scope);
5114
6977
  }
5115
- if (!response.ok) throw await httpLlmError(response, "copilot API");
6978
+ if (!response.ok) throw await httpLlmError(response, "copilot API", { ...this.options.onWarn === void 0 ? {} : { onWarn: this.options.onWarn } });
5116
6979
  if (response.body === null) throw new LlmError("copilot API returned no response body", EMPTY_RESPONSE_CODE);
5117
6980
  const pulse = () => {
5118
6981
  watchdog.pulse();
@@ -5435,13 +7298,13 @@ function truncate$1(text, max = 60) {
5435
7298
  * image input; any resolution failure means "no".
5436
7299
  */
5437
7300
  async function routeDeclaresImageInput(resolveLlm, exec) {
5438
- const llm = resolveLlm?.();
7301
+ const llm$1 = resolveLlm?.();
5439
7302
  const routed = exec.agent?.session.requestHeader()?.config;
5440
7303
  const provider = routed?.provider ?? exec.agent?.options.provider;
5441
7304
  const model = routed?.model ?? exec.agent?.options.model;
5442
- if (llm === void 0 || provider === void 0 || model === void 0) return false;
7305
+ if (llm$1 === void 0 || provider === void 0 || model === void 0) return false;
5443
7306
  try {
5444
- return (await llm.resolveModelInfo(provider, model, exec.signal)).inputModalities?.includes("image") === true;
7307
+ return (await llm$1.resolveModelInfo(provider, model, exec.signal)).inputModalities?.includes("image") === true;
5445
7308
  } catch {
5446
7309
  return false;
5447
7310
  }
@@ -5901,6 +7764,8 @@ const name = "dsh-plugin-subscriptions";
5901
7764
  const inject = ["llm"];
5902
7765
  /** Default maximum provider idle time while one stream read is outstanding. */
5903
7766
  const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 3e5;
7767
+ /** Bound on one pool quota poll — member selection must not hang on a usage endpoint. */
7768
+ const POOL_USAGE_TIMEOUT_MS = DISCOVERY_TIMEOUT_MS;
5904
7769
  const providerIdSchema = z.union([
5905
7770
  "codex",
5906
7771
  "claude",
@@ -5915,6 +7780,11 @@ const modelEntrySchema = z.object({
5915
7780
  inputModalities: z.array(z.union(["text", "image"])),
5916
7781
  wire: z.union(["chat-completions", "responses"])
5917
7782
  });
7783
+ const poolMemberSchema = z.object({
7784
+ provider: providerIdSchema.required(),
7785
+ account: z.string(),
7786
+ model: z.string().required()
7787
+ });
5918
7788
  const Config = z.object({
5919
7789
  providers: z.array(providerIdSchema).default([
5920
7790
  "codex",
@@ -5923,11 +7793,24 @@ const Config = z.object({
5923
7793
  "copilot"
5924
7794
  ]),
5925
7795
  streamIdleTimeoutMs: z.number().min(1).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
7796
+ rateLimit: z.object({
7797
+ wait: z.boolean().default(true),
7798
+ maxWaitMs: z.number().min(1).default(DEFAULT_RATE_LIMIT_MAX_WAIT_MS)
7799
+ }),
5926
7800
  models: z.object({
5927
7801
  codex: z.array(modelEntrySchema),
5928
7802
  claude: z.array(modelEntrySchema),
5929
7803
  grok: z.array(modelEntrySchema),
5930
7804
  copilot: z.array(modelEntrySchema)
7805
+ }),
7806
+ pool: z.object({
7807
+ enabled: z.boolean().default(true),
7808
+ strategy: z.union(["priority", "quota_aware"]).default("quota_aware"),
7809
+ switchMargin: z.number().min(1).default(2),
7810
+ autoAccounts: z.boolean().default(true),
7811
+ autoFamilies: z.boolean(),
7812
+ families: z.dict(z.array(poolMemberSchema)),
7813
+ tiers: z.dict(z.array(poolMemberSchema))
5931
7814
  })
5932
7815
  });
5933
7816
  /** Built-in catalogs used when the config does not override a provider's models. */
@@ -6034,6 +7917,15 @@ function accountOf(provider, session) {
6034
7917
  case "copilot": return session.account;
6035
7918
  }
6036
7919
  }
7920
+ /** The plan name a stored session carries, when the provider told us. */
7921
+ function planOf(provider, session) {
7922
+ switch (provider) {
7923
+ case "codex": return session.planType;
7924
+ case "claude": return session.subscriptionType;
7925
+ case "grok": return;
7926
+ case "copilot": return;
7927
+ }
7928
+ }
6037
7929
  /**
6038
7930
  * Auth operations behind the `/subscriptions-auth` RPC channel: start/complete
6039
7931
  * OAuth attempts in the background, feed pasted codes, cancel, log out, and
@@ -6067,18 +7959,20 @@ var SubscriptionsAuthController = class {
6067
7959
  * cannot be read off the flow manager.
6068
7960
  */
6069
7961
  claims = /* @__PURE__ */ new Map();
6070
- constructor(flows, deviceFlows, onAuthChanged, resolveAttachments, usageFetchers = {}, readClaudeCreds = readClaudeCodeCredentials) {
7962
+ constructor(flows, deviceFlows, onAuthChanged, resolveAttachments, usageFetchers = {}, readClaudeCreds = readClaudeCodeCredentials, poolUsage = void 0) {
6071
7963
  this.flows = flows;
6072
7964
  this.deviceFlows = deviceFlows;
6073
7965
  this.onAuthChanged = onAuthChanged;
6074
7966
  this.resolveAttachments = resolveAttachments;
6075
7967
  this.usageFetchers = usageFetchers;
6076
7968
  this.readClaudeCreds = readClaudeCreds;
7969
+ this.poolUsage = poolUsage;
6077
7970
  }
6078
- usage(provider, signal) {
7971
+ usage(provider, account, signal, force = false) {
6079
7972
  const fetcher = this.usageFetchers[provider];
6080
7973
  if (fetcher === void 0) return Promise.resolve({ supported: false });
6081
- return fetcher(signal);
7974
+ if (this.poolUsage === void 0) return fetcher(account, signal);
7975
+ return this.poolUsage.snapshotFor(provider, account, force);
6082
7976
  }
6083
7977
  async readImage(ref, signal) {
6084
7978
  const attachments = this.resolveAttachments();
@@ -6096,28 +7990,45 @@ var SubscriptionsAuthController = class {
6096
7990
  };
6097
7991
  }
6098
7992
  async status(provider) {
6099
- const session = await getSession(provider);
6100
- const account = accountOf(provider, session);
7993
+ const entries = await listAccounts(provider);
6101
7994
  const detail = this.lastError.get(provider);
6102
7995
  return {
6103
- loggedIn: session !== void 0,
6104
7996
  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 },
7997
+ accounts: entries.map(({ key, session }, index) => {
7998
+ const account = accountOf(provider, session);
7999
+ const plan = planOf(provider, session);
8000
+ return {
8001
+ key,
8002
+ isDefault: index === 0,
8003
+ expiresAt: session.expiresAt,
8004
+ ...account === void 0 ? {} : { account },
8005
+ ...plan === void 0 ? {} : { plan }
8006
+ };
8007
+ }),
6107
8008
  ...detail === void 0 ? {} : { detail }
6108
8009
  };
6109
8010
  }
6110
- async login(provider) {
6111
- if (provider === "claude") {
8011
+ async login(provider, method) {
8012
+ if (provider === "claude" && method !== "oauth") {
6112
8013
  const imported = this.readClaudeCreds();
6113
8014
  if (imported !== void 0) {
6114
8015
  this.claim("claude");
6115
8016
  this.flows.pending("claude")?.cancel();
6116
- await this.persist("claude", imported);
8017
+ const session = {
8018
+ ...imported,
8019
+ keychainBound: true
8020
+ };
8021
+ await this.persist("claude", session);
6117
8022
  this.lastError.delete("claude");
6118
- this.onAuthChanged("claude");
8023
+ this.onAuthChanged("claude", accountKeyOf("claude", session));
6119
8024
  return { authorizeUrl: "" };
6120
8025
  }
8026
+ if (method === "keychain") throw new Error("no Claude Code credentials found; run `claude` and log in first, or choose the browser flow");
8027
+ const attempt$1 = await this.flows.start("claude", claudeFlow);
8028
+ this.completions.set("claude", this.complete("claude", attempt$1, this.claim("claude")));
8029
+ return { authorizeUrl: attempt$1.authorizeUrl };
8030
+ }
8031
+ if (provider === "claude") {
6121
8032
  const attempt$1 = await this.flows.start("claude", claudeFlow);
6122
8033
  this.completions.set("claude", this.complete("claude", attempt$1, this.claim("claude")));
6123
8034
  return { authorizeUrl: attempt$1.authorizeUrl };
@@ -6159,7 +8070,7 @@ var SubscriptionsAuthController = class {
6159
8070
  if (this.claims.get(provider) !== claim) return;
6160
8071
  await this.persist(provider, session);
6161
8072
  this.lastError.delete(provider);
6162
- this.onAuthChanged(provider);
8073
+ this.onAuthChanged(provider, accountKeyOf(provider, session));
6163
8074
  } catch (error) {
6164
8075
  if (this.claims.get(provider) !== claim) return;
6165
8076
  if (!(error instanceof Error && error.message === "login cancelled")) this.lastError.set(provider, errorChain(error));
@@ -6171,7 +8082,7 @@ var SubscriptionsAuthController = class {
6171
8082
  const session = await completeCopilotLogin(await attempt.waitToken());
6172
8083
  await this.persist(provider, session);
6173
8084
  this.lastError.delete(provider);
6174
- this.onAuthChanged(provider);
8085
+ this.onAuthChanged(provider, accountKeyOf(provider, session));
6175
8086
  } catch (error) {
6176
8087
  if (!(error instanceof Error && error.message === "login cancelled")) this.lastError.set(provider, errorChain(error));
6177
8088
  } finally {
@@ -6187,12 +8098,7 @@ var SubscriptionsAuthController = class {
6187
8098
  }
6188
8099
  }
6189
8100
  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
- }
8101
+ return saveAccountSession(provider, accountKeyOf(provider, session), session);
6196
8102
  }
6197
8103
  /**
6198
8104
  * Settle once no OAuth completion is running for a provider.
@@ -6216,19 +8122,24 @@ var SubscriptionsAuthController = class {
6216
8122
  this.deviceFlows.pending(provider)?.cancel();
6217
8123
  return Promise.resolve();
6218
8124
  }
6219
- async logout(provider) {
8125
+ async logout(provider, account) {
6220
8126
  this.claim(provider);
6221
8127
  this.flows.pending(provider)?.cancel();
6222
8128
  this.deviceFlows.pending(provider)?.cancel();
6223
- await deleteSession(provider);
8129
+ await deleteAccountSession(provider, account);
6224
8130
  this.lastError.delete(provider);
6225
- this.onAuthChanged(provider);
8131
+ this.onAuthChanged(provider, account);
8132
+ }
8133
+ async setDefault(provider, account) {
8134
+ await setDefaultAccount(provider, account);
8135
+ this.onAuthChanged(provider, account);
6226
8136
  }
6227
8137
  };
6228
8138
  function apply(ctx, config) {
6229
8139
  const providers = [...new Set(config.providers ?? [...PROVIDER_IDS])];
6230
8140
  const streamIdleTimeoutMs = config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
6231
8141
  if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0) throw new Error(`${name}: streamIdleTimeoutMs must be a positive finite number`);
8142
+ const rateLimit = resolveRateLimitWait(config.rateLimit, `${name}: rateLimit`);
6232
8143
  const catalog = resolveCatalog(config.models);
6233
8144
  const overridden = new Set(PROVIDER_IDS.filter((provider) => (config.models?.[provider]?.length ?? 0) > 0));
6234
8145
  const flows = new OAuthFlowManager();
@@ -6238,10 +8149,20 @@ function apply(ctx, config) {
6238
8149
  };
6239
8150
  const resolveAttachments = () => ctx.get("attachments");
6240
8151
  const handles = /* @__PURE__ */ new Map();
6241
- const authChanged = (provider) => {
8152
+ const adapters = /* @__PURE__ */ new Map();
8153
+ const accountTokens = /* @__PURE__ */ new Map();
8154
+ let poolHealth;
8155
+ let poolUsage;
8156
+ let poolAdapter;
8157
+ const authChanged = (provider, account) => {
6242
8158
  if (provider === "copilot") copilotAdapter?.clearReplayState();
6243
- handles.get(provider)?.replace([provider]);
8159
+ adapters.get(provider)?.clearAccountCatalog(account);
8160
+ poolHealth?.clear(provider, account);
8161
+ poolUsage?.invalidate(provider, account);
8162
+ poolAdapter?.invalidate();
8163
+ for (const [route, handle] of handles) handle.replace([route]);
6244
8164
  };
8165
+ loadModelDefaults();
6245
8166
  let codexTokens;
6246
8167
  let claudeTokens;
6247
8168
  let grokTokens;
@@ -6251,115 +8172,199 @@ function apply(ctx, config) {
6251
8172
  let copilotAdapter;
6252
8173
  for (const provider of providers) switch (provider) {
6253
8174
  case "codex": {
6254
- const tokens = new TokenManager({
8175
+ const tokens = new AccountTokenManager({
8176
+ provider: "codex",
6255
8177
  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");
8178
+ makeOptions: () => ({
8179
+ preemptMs: CODEX_PREEMPT_MS,
8180
+ refresh: refreshCodex,
8181
+ isPermanent: isCodexPermanentRefreshError
8182
+ }),
8183
+ onAccountRemoved: (account) => {
8184
+ authChanged("codex", account);
6264
8185
  }
6265
8186
  });
6266
8187
  codexTokens = tokens;
6267
- usageFetchers.codex = async (signal) => fetchCodexUsage(await tokens.session(), proxiedFetch, signal);
8188
+ accountTokens.set("codex", tokens);
8189
+ usageFetchers.codex = async (account, signal) => fetchCodexUsage(await tokens.session(account), proxiedFetch, signal);
6268
8190
  let adapter;
6269
8191
  adapter = new CodexAdapter({
6270
8192
  models: catalog.codex,
6271
8193
  streamIdleTimeoutMs,
8194
+ rateLimit,
6272
8195
  tokens,
6273
8196
  discovery: !overridden.has("codex"),
6274
8197
  onWarn,
6275
8198
  resolveAttachments,
6276
8199
  catalogStore: catalogStore("codex"),
8200
+ defaultEffortOf: (model) => defaultEffortOf("codex", model),
8201
+ pool: () => poolAdapter,
6277
8202
  speedFor: (sessionId, model) => sessionId !== void 0 && speedBySession.get(sessionId) === "fast" && adapter.supportsFastTier(model)
6278
8203
  });
6279
8204
  codexAdapter = adapter;
8205
+ adapters.set("codex", adapter);
6280
8206
  handles.set("codex", ctx.llm.registerAdapter(["codex"], adapter));
6281
8207
  break;
6282
8208
  }
6283
8209
  case "claude": {
6284
- const tokens = new TokenManager({
8210
+ const tokens = new AccountTokenManager({
8211
+ provider: "claude",
6285
8212
  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");
8213
+ makeOptions: () => ({
8214
+ preemptMs: CLAUDE_PREEMPT_MS,
8215
+ refresh: (session) => session.keychainBound === true ? refreshClaudeSynced(session, refreshClaude) : refreshClaude(session),
8216
+ isPermanent: isClaudePermanentRefreshError
8217
+ }),
8218
+ onAccountRemoved: (account) => {
8219
+ authChanged("claude", account);
6294
8220
  }
6295
8221
  });
6296
8222
  claudeTokens = tokens;
6297
- usageFetchers.claude = async (signal) => fetchClaudeUsage(await tokens.session(), proxiedFetch, signal);
6298
- handles.set("claude", ctx.llm.registerAdapter(["claude"], new ClaudeAdapter({
8223
+ accountTokens.set("claude", tokens);
8224
+ usageFetchers.claude = async (account, signal) => fetchClaudeUsage(await tokens.session(account), proxiedFetch, signal);
8225
+ const adapter = new ClaudeAdapter({
6299
8226
  models: catalog.claude,
6300
8227
  streamIdleTimeoutMs,
8228
+ rateLimit,
6301
8229
  tokens,
6302
8230
  discovery: !overridden.has("claude"),
6303
8231
  onWarn,
6304
- maxRetries: 10,
6305
8232
  resolveAttachments,
6306
- catalogStore: catalogStore("claude")
6307
- })));
8233
+ catalogStore: catalogStore("claude"),
8234
+ defaultEffortOf: (model) => defaultEffortOf("claude", model),
8235
+ pool: () => poolAdapter
8236
+ });
8237
+ adapters.set("claude", adapter);
8238
+ handles.set("claude", ctx.llm.registerAdapter(["claude"], adapter));
6308
8239
  break;
6309
8240
  }
6310
8241
  case "grok": {
6311
- const tokens = new TokenManager({
8242
+ const tokens = new AccountTokenManager({
8243
+ provider: "grok",
6312
8244
  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");
8245
+ makeOptions: () => ({
8246
+ preemptMs: GROK_PREEMPT_MS,
8247
+ refresh: refreshGrok,
8248
+ isPermanent: isGrokPermanentRefreshError
8249
+ }),
8250
+ onAccountRemoved: (account) => {
8251
+ authChanged("grok", account);
6321
8252
  }
6322
8253
  });
6323
8254
  grokTokens = tokens;
6324
- usageFetchers.grok = async (signal) => fetchGrokUsage(await tokens.session(), proxiedFetch, signal);
6325
- handles.set("grok", ctx.llm.registerAdapter(["grok"], new GrokAdapter({
8255
+ accountTokens.set("grok", tokens);
8256
+ usageFetchers.grok = async (account, signal) => fetchGrokUsage(await tokens.session(account), proxiedFetch, signal);
8257
+ const adapter = new GrokAdapter({
6326
8258
  models: catalog.grok,
6327
8259
  streamIdleTimeoutMs,
8260
+ rateLimit,
6328
8261
  tokens,
6329
8262
  discovery: !overridden.has("grok"),
6330
8263
  onWarn,
6331
8264
  resolveAttachments,
6332
- catalogStore: catalogStore("grok")
6333
- })));
8265
+ catalogStore: catalogStore("grok"),
8266
+ defaultEffortOf: (model) => defaultEffortOf("grok", model),
8267
+ pool: () => poolAdapter
8268
+ });
8269
+ adapters.set("grok", adapter);
8270
+ handles.set("grok", ctx.llm.registerAdapter(["grok"], adapter));
6334
8271
  break;
6335
8272
  }
6336
8273
  case "copilot": {
6337
- const tokens = new TokenManager({
8274
+ const tokens = new AccountTokenManager({
8275
+ provider: "copilot",
6338
8276
  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");
8277
+ makeOptions: () => ({
8278
+ preemptMs: COPILOT_PREEMPT_MS,
8279
+ refresh: refreshCopilot,
8280
+ isPermanent: isCopilotPermanentRefreshError
8281
+ }),
8282
+ onAccountRemoved: (account) => {
8283
+ authChanged("copilot", account);
6347
8284
  }
6348
8285
  });
8286
+ accountTokens.set("copilot", tokens);
6349
8287
  copilotAdapter = new CopilotAdapter({
6350
8288
  models: catalog.copilot,
6351
8289
  streamIdleTimeoutMs,
8290
+ rateLimit,
6352
8291
  tokens,
6353
8292
  discovery: !overridden.has("copilot"),
6354
8293
  onWarn,
6355
8294
  resolveAttachments,
6356
- catalogStore: catalogStore("copilot")
8295
+ catalogStore: catalogStore("copilot"),
8296
+ defaultEffortOf: (model) => defaultEffortOf("copilot", model),
8297
+ pool: () => poolAdapter
6357
8298
  });
8299
+ adapters.set("copilot", copilotAdapter);
6358
8300
  handles.set("copilot", ctx.llm.registerAdapter(["copilot"], copilotAdapter));
6359
8301
  break;
6360
8302
  }
6361
8303
  }
6362
- registerAuthRpc(ctx, new SubscriptionsAuthController(flows, deviceFlows, authChanged, resolveAttachments, usageFetchers), {
8304
+ const poolConfig = config.pool;
8305
+ const autoAccounts = poolConfig?.autoAccounts ?? poolConfig?.autoFamilies ?? true;
8306
+ if (poolConfig?.enabled !== false && adapters.size >= 1) {
8307
+ const fetcherFor = (provider, account) => {
8308
+ switch (provider) {
8309
+ case "codex": {
8310
+ const tokens = codexTokens;
8311
+ return tokens === void 0 ? void 0 : async () => fetchCodexUsage(await tokens.session(account), proxiedFetch, AbortSignal.timeout(POOL_USAGE_TIMEOUT_MS));
8312
+ }
8313
+ case "claude": {
8314
+ const tokens = claudeTokens;
8315
+ return tokens === void 0 ? void 0 : async () => fetchClaudeUsage(await tokens.session(account), proxiedFetch, AbortSignal.timeout(POOL_USAGE_TIMEOUT_MS));
8316
+ }
8317
+ case "grok": {
8318
+ const tokens = grokTokens;
8319
+ return tokens === void 0 ? void 0 : async () => fetchGrokUsage(await tokens.session(account), proxiedFetch, AbortSignal.timeout(POOL_USAGE_TIMEOUT_MS));
8320
+ }
8321
+ case "copilot": return;
8322
+ }
8323
+ };
8324
+ poolHealth = new PoolHealthRegistry();
8325
+ poolUsage = new PoolUsageTracker(fetcherFor);
8326
+ const families = async () => {
8327
+ const pools = /* @__PURE__ */ new Map();
8328
+ if (autoAccounts) {
8329
+ const sources = {};
8330
+ await Promise.all([...adapters].map(async ([provider, adapter]) => {
8331
+ try {
8332
+ const accounts = (await accountTokens.get(provider)?.list() ?? []).map((entry) => entry.key);
8333
+ if (accounts.length < 2) return;
8334
+ const catalogs = (await Promise.all(accounts.map(async (account) => {
8335
+ const models = await withTimeout((signal) => adapter.listOwnModels(provider, account, signal), POOL_USAGE_TIMEOUT_MS);
8336
+ return models === void 0 ? void 0 : {
8337
+ account,
8338
+ models
8339
+ };
8340
+ }))).filter((entry) => entry !== void 0);
8341
+ if (catalogs.length >= 2) sources[provider] = { catalogs };
8342
+ } catch {}
8343
+ }));
8344
+ for (const [key, definition] of buildAccountPools(sources)) pools.set(key, definition);
8345
+ }
8346
+ for (const [id, members] of Object.entries(poolConfig?.families ?? {})) {
8347
+ if (members.length === 0) continue;
8348
+ const owner = members[0].provider;
8349
+ const kept = members.filter((member) => member.provider === owner);
8350
+ if (kept.length < members.length) onWarn(`pool "${id}": cross-provider members are ignored; only ${owner} accounts are pooled`);
8351
+ pools.set(poolKey(owner, id), { members: kept });
8352
+ }
8353
+ return pools;
8354
+ };
8355
+ poolAdapter = new PoolAdapter({
8356
+ adapters: Object.fromEntries(adapters),
8357
+ health: poolHealth,
8358
+ usage: poolUsage,
8359
+ strategy: poolConfig?.strategy ?? "quota_aware",
8360
+ switchMargin: poolConfig?.switchMargin ?? 2,
8361
+ defaultAccount: (provider) => accountTokens.get(provider)?.defaultAccount() ?? Promise.resolve(void 0),
8362
+ families,
8363
+ tiers: poolConfig?.tiers ?? {},
8364
+ onWarn
8365
+ });
8366
+ }
8367
+ registerAuthRpc(ctx, new SubscriptionsAuthController(flows, deviceFlows, authChanged, resolveAttachments, usageFetchers, void 0, poolUsage), {
6363
8368
  async speed(sessionId) {
6364
8369
  return {
6365
8370
  tier: speedBySession.get(sessionId) ?? "standard",
@@ -6374,10 +8379,73 @@ function apply(ctx, config) {
6374
8379
  get: () => proxyGetConfig(),
6375
8380
  set: (input) => proxySetConfig(input),
6376
8381
  test: (payload) => proxyTestConnection(payload.url, payload.proxy)
8382
+ }, {
8383
+ async catalog() {
8384
+ const visible = new Set((await ctx.llm.listProviders()).map((provider) => provider.id));
8385
+ const catalog$1 = [];
8386
+ for (const provider of PROVIDER_IDS) {
8387
+ if (!visible.has(provider)) continue;
8388
+ let models = [];
8389
+ try {
8390
+ models = await ctx.llm.listModels(provider);
8391
+ } catch {
8392
+ continue;
8393
+ }
8394
+ let tierIds = /* @__PURE__ */ new Set();
8395
+ try {
8396
+ const tiers = await poolAdapter?.modelsForProvider(provider);
8397
+ if (tiers !== void 0) tierIds = new Set(tiers.map((tier) => tier.id));
8398
+ } catch {}
8399
+ const views = [];
8400
+ for (const model of models) {
8401
+ if (tierIds.has(model.id)) continue;
8402
+ let info;
8403
+ try {
8404
+ info = await ctx.llm.resolveModelInfo(provider, model.id);
8405
+ } catch {
8406
+ continue;
8407
+ }
8408
+ if (info === void 0) continue;
8409
+ const override = defaultEffortOf(provider, model.id);
8410
+ views.push({
8411
+ id: model.id,
8412
+ name: model.name,
8413
+ efforts: info.reasoning?.efforts.map((effort) => ({
8414
+ id: effort.id,
8415
+ name: effort.name
8416
+ })) ?? [],
8417
+ ...override === void 0 ? {} : { configured: override }
8418
+ });
8419
+ }
8420
+ catalog$1.push({
8421
+ provider,
8422
+ models: views
8423
+ });
8424
+ }
8425
+ return catalog$1;
8426
+ },
8427
+ async set(provider, model, effort) {
8428
+ if (effort !== void 0) {
8429
+ let info;
8430
+ try {
8431
+ info = await ctx.llm.resolveModelInfo(provider, model);
8432
+ } catch {}
8433
+ const offered = info?.reasoning?.efforts ?? [];
8434
+ if (offered.length > 0 && !offered.some((entry) => entry.id === effort)) throw new BadRequest(`model ${model} does not advertise a "${effort}" reasoning effort`);
8435
+ }
8436
+ await setDefaultEffort(provider, model, effort);
8437
+ handles.get(provider)?.replace([provider]);
8438
+ }
6377
8439
  });
6378
8440
  if (claudeTokens !== void 0) {
8441
+ const tokens = claudeTokens;
6379
8442
  const syncTimer = setInterval(() => {
6380
- claudeTokens?.session().catch(() => {});
8443
+ tokens.list().then((accounts) => {
8444
+ for (const { key, session } of accounts) {
8445
+ if (session.keychainBound !== true) continue;
8446
+ tokens.session(key).catch(() => {});
8447
+ }
8448
+ }, () => void 0);
6381
8449
  }, 5 * 6e4);
6382
8450
  ctx.effect(() => () => {
6383
8451
  clearInterval(syncTimer);
@@ -6398,4 +8466,4 @@ function apply(ctx, config) {
6398
8466
  }
6399
8467
 
6400
8468
  //#endregion
6401
- export { Config, DEFAULT_STREAM_IDLE_TIMEOUT_MS, SubscriptionsAuthController, apply, inject, name };
8469
+ export { Config, DEFAULT_STREAM_IDLE_TIMEOUT_MS, POOL_USAGE_TIMEOUT_MS, SubscriptionsAuthController, apply, inject, name, withTimeout };