dsh-agy 0.3.2 → 0.4.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.
@@ -1091,14 +1091,23 @@ function earliestResetTime(a, b) {
1091
1091
  * counts Claude and GPT under one third-party counter, so the weekly budget
1092
1092
  * belongs to both. No model-id prefix rule can recover that split, which is why
1093
1093
  * the mapping lives here rather than in `modelFamilyOf`.
1094
+ *
1095
+ * Window-aware as of #56: a bucket id resolves a family only when the window it
1096
+ * describes is one `CachedQuota` can hold AND the id agrees with the token (see
1097
+ * `agreedWindowKind`). The prefix and the window were two independent decisions
1098
+ * once, and an unrecognized window still claimed a family.
1094
1099
  */
1095
- function familiesForBucketId(bucketId) {
1100
+ function familiesForBucketId(bucketId, window) {
1096
1101
  const id = bucketId.toLowerCase();
1097
- if (id.startsWith("gemini-")) return ["google"];
1098
- if (id.startsWith("3p-") || id.startsWith("third_party-")) return ["anthropic", "openai"];
1099
- if (id.startsWith("claude-")) return ["anthropic"];
1100
- if (id.startsWith("gpt-") || id.startsWith("openai-")) return ["openai"];
1101
- return [];
1102
+ const families = [];
1103
+ if (id.startsWith("gemini-")) families.push("google");
1104
+ else if (id.startsWith("3p-") || id.startsWith("third_party-")) families.push("anthropic", "openai");
1105
+ else if (id.startsWith("claude-")) families.push("anthropic");
1106
+ else if (id.startsWith("gpt-") || id.startsWith("openai-")) families.push("openai");
1107
+ if (families.length === 0) return [];
1108
+ const declared = window === void 0 ? advertisedWindowOf(id) : window.toLowerCase();
1109
+ if (declared === void 0 || agreedWindowKind(id, declared) === void 0) return [];
1110
+ return families;
1102
1111
  }
1103
1112
  /**
1104
1113
  * The families a group's LABEL covers, consulted only when no bucket id was
@@ -1124,7 +1133,6 @@ function familiesForGroupName(name) {
1124
1133
  if (claude && gpt) families.push("anthropic", "openai");
1125
1134
  else if (claude) families.push("anthropic");
1126
1135
  else if (gpt) families.push("openai");
1127
- else if (families.length === 0 && (label.includes("3p") || label.includes("third"))) families.push("anthropic", "openai");
1128
1136
  return families;
1129
1137
  }
1130
1138
  /**
@@ -1145,13 +1153,44 @@ function familiesForGroupName(name) {
1145
1153
  function windowKind(window) {
1146
1154
  return QUOTA_WINDOWS[window.toLowerCase()]?.kind;
1147
1155
  }
1156
+ /** The window token a bucket id advertises, when it ends with one upstream names. */
1157
+ function advertisedWindowOf(bucketId) {
1158
+ const id = bucketId.toLowerCase();
1159
+ const token = id.slice(id.lastIndexOf("-") + 1);
1160
+ return QUOTA_WINDOWS[token] ? token : void 0;
1161
+ }
1162
+ /**
1163
+ * The field a window belongs in, decided from an AGREED (id, token) pair — or
1164
+ * `undefined` when the pair cannot be agreed.
1165
+ *
1166
+ * The family used to come from the bucket id's prefix while the field came from
1167
+ * the window token, as two independent decisions nothing checked against each
1168
+ * other. A bucket whose window the cache cannot hold still claimed the family,
1169
+ * and its reading was then filed by whichever field the TOKEN named. `gemini-weekly`
1170
+ * declaring `5h` is the shape that puts a multi-day weekly fraction into
1171
+ * `remainingFraction` (the 5-hour field), where `isFamilyDrained` and
1172
+ * `parseFutureResetMs` block the account for days on a window they misread.
1173
+ *
1174
+ * Refusing an unagreed pair is deliberately the strict option: the permissive
1175
+ * mapping is exactly the coupling that let two window vocabularies drift into
1176
+ * destroying a measurement. A bucket id that advertises no recognizable token is
1177
+ * held to the declared one alone, so a rename does not silently disable the cache.
1178
+ */
1179
+ function agreedWindowKind(bucketId, window) {
1180
+ const declared = window.toLowerCase();
1181
+ const kind = windowKind(declared);
1182
+ if (kind === void 0) return void 0;
1183
+ const advertised = advertisedWindowOf(bucketId);
1184
+ if (advertised !== void 0 && advertised !== declared) return void 0;
1185
+ return kind;
1186
+ }
1148
1187
  /** The families one group belongs to: its bucket ids first, its label second. */
1149
1188
  function familiesForGroup(group) {
1150
1189
  const families = [];
1151
1190
  const add = (family) => {
1152
1191
  if (!families.includes(family)) families.push(family);
1153
1192
  };
1154
- for (const window of group.windows) for (const family of familiesForBucketId(window.bucketId)) add(family);
1193
+ for (const window of group.windows) for (const family of familiesForBucketId(window.bucketId, window.window)) add(family);
1155
1194
  if (families.length === 0) for (const family of familiesForGroupName(group.name)) add(family);
1156
1195
  return families;
1157
1196
  }
@@ -1163,9 +1202,16 @@ function familiesForGroup(group) {
1163
1202
  * `remainingFraction` and `resetTime`, with no window field at all, which is why
1164
1203
  * a weekly limit used to be invisible to rotation.
1165
1204
  *
1166
- * Two groups can land on one family (upstream may split `3p` later), so each
1167
- * window keeps the MOST pressured reading rather than letting the last group
1168
- * win — the same rule the per-model merge below uses.
1205
+ * Two groups can land on one family (upstream may split `3p` later), so the
1206
+ * family record keeps the MOST pressured reading of each window across those
1207
+ * groups (`Math.min` below) rather than letting the last group win — the same
1208
+ * rule the per-model merge uses.
1209
+ *
1210
+ * WITHIN one group the rule is deliberately different: `??=` keeps upstream's
1211
+ * FIRST bucket of each window, because a group is upstream's own 5h + weekly
1212
+ * pair and a duplicate would otherwise depend on upstream's ordering. "Most
1213
+ * pressured" therefore applies to two GROUPS landing on one family, not to two
1214
+ * buckets inside one group.
1169
1215
  */
1170
1216
  function ingestQuotaGroups(groups) {
1171
1217
  const families = /* @__PURE__ */ new Map();
@@ -1175,7 +1221,7 @@ function ingestQuotaGroups(groups) {
1175
1221
  let rolling;
1176
1222
  let weekly;
1177
1223
  for (const window of group.windows) {
1178
- const kind = windowKind(window.window);
1224
+ const kind = agreedWindowKind(window.bucketId, window.window);
1179
1225
  if (kind === "rolling") rolling ??= window;
1180
1226
  else if (kind === "weekly") weekly ??= window;
1181
1227
  }
@@ -1248,21 +1294,70 @@ function ingestFamilyQuotas(discovered, groups, previous) {
1248
1294
  }
1249
1295
  return Object.fromEntries(families);
1250
1296
  }
1251
- /** The quota record for one family, or the most-pressured family when the model is unknown. */
1252
- function familyQuotaFor(account, family) {
1297
+ /**
1298
+ * The lowest fraction a record reports across its LIVE windows — its overall
1299
+ * pressure.
1300
+ *
1301
+ * A window whose reset has already passed describes a window that no longer
1302
+ * exists, so it does not count — the same rule `isQuotaStale` applies when it
1303
+ * sizes the TTL from the selected record. Scoring a dead window would let one
1304
+ * family's expired weekly reading win the selection and represent the account,
1305
+ * masking another family's live pressure (issue #69: a dead weekly at 0.004
1306
+ * beat a live 0.2 and stretched the refresh TTL from 5 to 15 minutes).
1307
+ *
1308
+ * `undefined` means the record carries no usable LIVE reading at all, which is
1309
+ * deliberately different from a measured zero: only a real number can size a
1310
+ * refresh interval.
1311
+ */
1312
+ function pressureScore(entry, now) {
1313
+ const fractions = [];
1314
+ if (typeof entry.remainingFraction === "number" && Number.isFinite(entry.remainingFraction) && !resetInPast(entry.resetTime, now)) fractions.push(entry.remainingFraction);
1315
+ if (typeof entry.weeklyFraction === "number" && Number.isFinite(entry.weeklyFraction) && !resetInPast(entry.weeklyResetTime, now)) fractions.push(entry.weeklyFraction);
1316
+ return fractions.length === 0 ? void 0 : Math.min(...fractions);
1317
+ }
1318
+ /**
1319
+ * The quota record for one family, or the most-pressured family when the model
1320
+ * is unknown.
1321
+ *
1322
+ * "Most pressured" compares BOTH windows and reports the record whose LOWEST
1323
+ * window is the lowest of all of them. That matters because `isQuotaStale` sizes
1324
+ * the refresh TTL from whichever record this returns: a family at 0.9 / weekly
1325
+ * 0.004 is more pressured than one at 0.2, and comparing the 5-hour fraction
1326
+ * alone picked the 0.2 record — so the weekly-driven 60s TTL was never applied
1327
+ * to the family actually being requested and a spent week was discovered late.
1328
+ *
1329
+ * A record carrying ONLY a weekly reading participates for the same reason: #48
1330
+ * produces that shape legitimately (the anthropic/openai entries of a
1331
+ * Gemini-only pool), and the old `typeof entry.remainingFraction !== 'number'`
1332
+ * guard skipped it outright, leaving the week invisible to TTL sizing.
1333
+ *
1334
+ * Selection considers LIVE windows only (`pressureScore` drops a window whose
1335
+ * reset has passed) — but the with-family path returns the record as-is either
1336
+ * way, because its consumers guard each window individually.
1337
+ *
1338
+ * @param account - the account whose cache to read.
1339
+ * @param family - the exact family's record; omit to select the most pressured.
1340
+ * @param now - current time (Unix ms), which decides whether a window is live.
1341
+ */
1342
+ function familyQuotaFor(account, family, now = Date.now()) {
1253
1343
  const cache = account.cachedQuota ?? {};
1254
1344
  if (family) return cache[family];
1255
1345
  let worst;
1346
+ let worstScore;
1256
1347
  for (const entry of Object.values(cache)) {
1257
- if (typeof entry.remainingFraction !== "number") continue;
1258
- if (!worst || entry.remainingFraction < (worst.remainingFraction ?? 1)) worst = entry;
1348
+ const score = pressureScore(entry, now);
1349
+ if (score === void 0) continue;
1350
+ if (worstScore === void 0 || score < worstScore) {
1351
+ worst = entry;
1352
+ worstScore = score;
1353
+ }
1259
1354
  }
1260
1355
  return worst;
1261
1356
  }
1262
1357
  /** Whether the account's quota cache needs a refresh (missing, or past its health-based TTL). */
1263
1358
  function isQuotaStale(account, now = Date.now()) {
1264
1359
  if (!account.cachedQuota || !account.cachedQuotaUpdatedAt) return true;
1265
- const mostPressured = familyQuotaFor(account);
1360
+ const mostPressured = familyQuotaFor(account, void 0, now);
1266
1361
  const ttl = computeSoftQuotaCacheTtlMs(resetInPast(mostPressured?.resetTime, now) ? void 0 : mostPressured?.remainingFraction, resetInPast(mostPressured?.weeklyResetTime, now) ? void 0 : mostPressured?.weeklyFraction);
1267
1362
  return now - account.cachedQuotaUpdatedAt > ttl;
1268
1363
  }
@@ -1296,26 +1391,6 @@ function resetInPast(resetTime, now) {
1296
1391
  return !Number.isNaN(reset) && reset <= now;
1297
1392
  }
1298
1393
  /**
1299
- * Whether the requested family on this account is soft-quota-exhausted.
1300
- *
1301
- * BOTH windows are checked, because they refill on different clocks. The 5-hour
1302
- * bucket can be nearly empty while the weekly budget is untouched, and the
1303
- * reverse — a spent weekly budget stays spent across four 5-hour refills. Before
1304
- * this, only the 5-hour reading was consulted, so an account whose week was over
1305
- * kept being selected until a real request failed.
1306
- *
1307
- * A window whose reset has already passed is IGNORED rather than read: its
1308
- * fraction describes a window that no longer exists, so the account stays
1309
- * selectable until the next measurement replaces the stale value.
1310
- */
1311
- function isFamilyDrained(account, family, now = Date.now()) {
1312
- const quota = familyQuotaFor(account, family);
1313
- if (!quota) return false;
1314
- if (typeof quota.remainingFraction === "number" && !resetInPast(quota.resetTime, now) && quota.remainingFraction < .15) return true;
1315
- if (typeof quota.weeklyFraction === "number" && !resetInPast(quota.weeklyResetTime, now) && quota.weeklyFraction <= .01) return true;
1316
- return false;
1317
- }
1318
- /**
1319
1394
  * Required drain rate: headroomFraction / remainingHours — how fast the
1320
1395
  * family's remaining quota must be consumed to avoid expiring unused at its
1321
1396
  * reset (mirrors AuthStorage.#computeWindowRequiredDrain with a daily window).
@@ -1350,6 +1425,32 @@ function parseFutureResetMs(resetTime, now) {
1350
1425
  return reset;
1351
1426
  }
1352
1427
  /**
1428
+ * Whether the requested family on this account is effectively quota-exhausted
1429
+ * for account STEERING (affinity pins, the active-account preference): an
1430
+ * explicit quota-exhausted cooldown in force, a spent 5-hour window
1431
+ * (`remainingFraction <= 0` with a future reset), or a weekly window at or
1432
+ * below `WEEKLY_QUOTA_THRESHOLD` — a spent week refills in days, so its rule
1433
+ * mirrors `isFamilyDrained`'s weekly branch rather than the block wall.
1434
+ *
1435
+ * Deliberately a DIFFERENT predicate from the one in `pickAccount`'s `!picked`
1436
+ * branch, which applies a strict zero to BOTH windows to mirror
1437
+ * `rankPoolCandidates`' block wall when classifying `AgyPoolBlockedError`; and
1438
+ * deliberately NARROWER than `isFamilyDrained`'s 5-hour rule
1439
+ * (`SOFT_QUOTA_THRESHOLD`): account prioritization holds a chosen account
1440
+ * until it is actually spent, not until ranking would prefer another one.
1441
+ */
1442
+ function isFamilyQuotaExhausted(account, family, now = Date.now()) {
1443
+ if (account.cooldownReason === "quota-exhausted" && (account.coolingDownUntil ?? 0) > now) return true;
1444
+ const quota = familyQuotaFor(account, family);
1445
+ if (!quota) return false;
1446
+ const spent = (fraction, resetTime, threshold = 0) => {
1447
+ if (typeof fraction !== "number" || fraction > threshold || !resetTime) return false;
1448
+ const resetMs = parseFutureResetMs(resetTime, now);
1449
+ return resetMs !== void 0 && resetMs > now;
1450
+ };
1451
+ return spent(quota.remainingFraction, quota.resetTime, 0) || spent(quota.weeklyFraction, quota.weeklyResetTime, .01);
1452
+ }
1453
+ /**
1353
1454
  * Rank pool candidates for one request, mirroring AuthStorage's antigravity
1354
1455
  * ordering: unblocked first (earliest unblock time among blocked), hot windows
1355
1456
  * last, measured usage before unmeasured, required-drain descending, then
@@ -1361,10 +1462,12 @@ function rankPoolCandidates(entries, modelId, now = Date.now(), startIndex = 0)
1361
1462
  const activePos = entries.findIndex((e) => e.index === startIndex);
1362
1463
  const clampedStart = activePos >= 0 ? activePos : 0;
1363
1464
  const candidates = (entries.length === 0 ? [] : [...entries.slice(clampedStart), ...entries.slice(0, clampedStart)]).map(({ account, index }, orderPos) => {
1364
- const quota = familyQuotaFor(account, family);
1365
- const remaining = quota?.remainingFraction;
1465
+ const quota = familyQuotaFor(account, family, now);
1466
+ const live5h = quota !== void 0 && !resetInPast(quota.resetTime, now);
1467
+ const liveWeekly = quota !== void 0 && !resetInPast(quota.weeklyResetTime, now);
1468
+ const remaining = live5h ? quota.remainingFraction : void 0;
1366
1469
  const used = typeof remaining === "number" ? Math.min(Math.max(1 - remaining, 0), 1) : void 0;
1367
- const weeklyRemaining = quota?.weeklyFraction;
1470
+ const weeklyRemaining = liveWeekly ? quota.weeklyFraction : void 0;
1368
1471
  const weeklyUsed = typeof weeklyRemaining === "number" ? Math.min(Math.max(1 - weeklyRemaining, 0), 1) : void 0;
1369
1472
  let blockedUntil = null;
1370
1473
  if (account.coolingDownUntil && account.coolingDownUntil > now) blockedUntil = account.coolingDownUntil;
@@ -1575,6 +1678,22 @@ var AgySessionManager = class AgySessionManager {
1575
1678
  refreshInFlight = /* @__PURE__ */ new Map();
1576
1679
  /** In-flight quota fetches keyed by account: concurrent selections share one fetchAvailableModels call. */
1577
1680
  quotaRefreshInFlight = /* @__PURE__ */ new Map();
1681
+ /**
1682
+ * In-flight `retrieveUserQuotaSummary` probes keyed by account.
1683
+ *
1684
+ * ONE promise per account per cycle, shared by BOTH consumers:
1685
+ * `refreshLimits` (display, writes `cachedLimits`) and `refreshQuotaCache`
1686
+ * (scheduling, writes `cachedQuota`). Since #48 both read the same endpoint for
1687
+ * the same account, so a stale account paid two round trips where one would
1688
+ * do; `quotaRefreshInFlight` de-duplicated only WITHIN the scheduling path.
1689
+ * Sharing makes the two caches agree on what upstream actually said.
1690
+ *
1691
+ * It carries the FETCH and nothing else: the invariant that `refreshLimits`
1692
+ * must never write `cachedQuota` (which `rankPoolCandidates` turns into a
1693
+ * `blockedUntil`) is about WHERE a result lands, and each path still decides
1694
+ * that for itself.
1695
+ */
1696
+ quotaSummaryInFlight = /* @__PURE__ */ new Map();
1578
1697
  failureCounts = /* @__PURE__ */ new Map();
1579
1698
  /** Accounts whose request-time project discovery already failed (no retry per request). */
1580
1699
  projectRetryFailed = /* @__PURE__ */ new Set();
@@ -1682,6 +1801,28 @@ var AgySessionManager = class AgySessionManager {
1682
1801
  clearAffinityForAccount(accountKey) {
1683
1802
  for (const [conversation, pin] of this.affinity) if (pin.key === accountKey) this.affinity.delete(conversation);
1684
1803
  }
1804
+ /**
1805
+ * Clear all session affinity pins.
1806
+ *
1807
+ * Called when an account is explicitly activated by the user, so all
1808
+ * ongoing and upcoming conversations immediately steer to the newly
1809
+ * activated account rather than staying pinned to the previous one.
1810
+ */
1811
+ clearAllAffinity() {
1812
+ this.affinity.clear();
1813
+ }
1814
+ /**
1815
+ * Explicitly activate an account by index: sets `storage.activeIndex = index`,
1816
+ * clears session affinity pins so conversations immediately switch to the
1817
+ * activated account, and resets transient failure counts.
1818
+ */
1819
+ async activateAccount(index) {
1820
+ await this.store.mutate((storage) => {
1821
+ if (index < 0 || index >= storage.accounts.length) throw new Error("account not found");
1822
+ storage.activeIndex = index;
1823
+ });
1824
+ this.clearAllAffinity();
1825
+ }
1685
1826
  constructor(options) {
1686
1827
  this.store = options.store;
1687
1828
  this.onRotate = options.onRotate;
@@ -1786,6 +1927,49 @@ var AgySessionManager = class AgySessionManager {
1786
1927
  return this.refreshToken(key, account, cached);
1787
1928
  }
1788
1929
  /**
1930
+ * Probe `retrieveUserQuotaSummary` for one account, sharing ONE in-flight
1931
+ * promise with every other caller in this cycle.
1932
+ *
1933
+ * Returns `[]` on any failure — the fetch itself already answers a dead
1934
+ * endpoint with an empty list — so both consumers read "no groups" the same
1935
+ * way. The promise is dropped as soon as it settles, so the NEXT cycle
1936
+ * re-probes.
1937
+ *
1938
+ * The account's proxy is honoured here or the call would egress direct and
1939
+ * leak the account's real IP, and one timeout bounds the whole endpoint
1940
+ * fallback chain.
1941
+ */
1942
+ probeQuotaSummary(account) {
1943
+ const key = this.accountKey(account);
1944
+ const existing = this.quotaSummaryInFlight.get(key);
1945
+ if (existing) return existing;
1946
+ const probe = (async () => {
1947
+ try {
1948
+ const auth = await this.accessTokenFor(account);
1949
+ if (!auth) return [];
1950
+ const { fetchQuotaSummary } = await import("./quota-summary-adID82I2.mjs");
1951
+ const routed = accountFetch({ proxyUrl: account.proxy });
1952
+ const bounded = (input, init) => {
1953
+ const timeout = AbortSignal.timeout(AgySessionManager.QUOTA_FETCH_TIMEOUT_MS);
1954
+ const signal = init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
1955
+ return routed(input, {
1956
+ ...init,
1957
+ signal
1958
+ });
1959
+ };
1960
+ return await fetchQuotaSummary(auth.access, account.projectId, bounded);
1961
+ } catch {
1962
+ return [];
1963
+ }
1964
+ })();
1965
+ this.quotaSummaryInFlight.set(key, probe);
1966
+ const release = () => {
1967
+ if (this.quotaSummaryInFlight.get(key) === probe) this.quotaSummaryInFlight.delete(key);
1968
+ };
1969
+ probe.then(release, release);
1970
+ return probe;
1971
+ }
1972
+ /**
1789
1973
  * Refresh the display-only 5h/weekly windows for every enabled account.
1790
1974
  *
1791
1975
  * SEPARATE from `refreshQuotaCache`, and deliberately so. That method writes
@@ -1833,22 +2017,7 @@ var AgySessionManager = class AgySessionManager {
1833
2017
  const probes = await Promise.all(targets.map(async (account) => {
1834
2018
  const key = this.accountKey(account);
1835
2019
  try {
1836
- const auth = await this.accessTokenFor(account);
1837
- if (!auth) return {
1838
- key,
1839
- ok: false
1840
- };
1841
- const { fetchQuotaSummary } = await import("./quota-summary-adID82I2.mjs");
1842
- const routed = accountFetch({ proxyUrl: account.proxy });
1843
- const bounded = (input, init) => {
1844
- const timeout = AbortSignal.timeout(AgySessionManager.QUOTA_FETCH_TIMEOUT_MS);
1845
- const signal = init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
1846
- return routed(input, {
1847
- ...init,
1848
- signal
1849
- });
1850
- };
1851
- const groups = await fetchQuotaSummary(auth.access, account.projectId, bounded);
2020
+ const groups = await this.probeQuotaSummary(account);
1852
2021
  if (groups.length === 0) return {
1853
2022
  key,
1854
2023
  ok: false
@@ -1911,7 +2080,6 @@ var AgySessionManager = class AgySessionManager {
1911
2080
  const auth = await this.accessTokenFor(account);
1912
2081
  if (!auth) return null;
1913
2082
  const { fetchAvailableModels } = await import("./models-Qn0-sh6X.mjs").then((n) => n.r);
1914
- const { fetchQuotaSummary } = await import("./quota-summary-adID82I2.mjs");
1915
2083
  const routed = accountFetch({ proxyUrl: account.proxy });
1916
2084
  const bounded = (input, init) => {
1917
2085
  const timeout = AbortSignal.timeout(AgySessionManager.QUOTA_FETCH_TIMEOUT_MS);
@@ -1921,7 +2089,7 @@ var AgySessionManager = class AgySessionManager {
1921
2089
  signal
1922
2090
  });
1923
2091
  };
1924
- const [discovered, groups] = await Promise.all([fetchAvailableModels(auth.access, account.projectId, bounded), fetchQuotaSummary(auth.access, account.projectId, bounded)]);
2092
+ const [discovered, groups] = await Promise.all([fetchAvailableModels(auth.access, account.projectId, bounded), this.probeQuotaSummary(account)]);
1925
2093
  const quotas = ingestFamilyQuotas(discovered, groups, account.cachedQuota);
1926
2094
  return {
1927
2095
  key,
@@ -1962,7 +2130,8 @@ var AgySessionManager = class AgySessionManager {
1962
2130
  }
1963
2131
  /**
1964
2132
  * Pick the account for one request: the affinity pin wins while it is fresh,
1965
- * healthy, and not drained for the requested model; otherwise the pool is
2133
+ * healthy, and not quota-exhausted for the requested model; the explicitly
2134
+ * activated account wins next while it is usable; otherwise the pool is
1966
2135
  * ranked by family-scoped usage (OMP-aligned) and the best candidate wins.
1967
2136
  */
1968
2137
  async pickAccount(storage, model, conversationKey) {
@@ -1975,7 +2144,7 @@ var AgySessionManager = class AgySessionManager {
1975
2144
  const lastIndex = storage.accounts.findIndex((a) => this.accountKey(a) === pinnedKey);
1976
2145
  if (lastIndex !== -1) {
1977
2146
  const last = storage.accounts[lastIndex];
1978
- if (last.enabled !== false && !isCoolingDown(last, now) && !isFamilyRateLimited(last, familyKey, now) && !isFamilyDrained(last, family, now)) return {
2147
+ if (last.enabled !== false && !isCoolingDown(last, now) && !isFamilyRateLimited(last, familyKey, now) && !isFamilyQuotaExhausted(last, family, now)) return {
1979
2148
  account: last,
1980
2149
  index: lastIndex
1981
2150
  };
@@ -1986,12 +2155,18 @@ var AgySessionManager = class AgySessionManager {
1986
2155
  index
1987
2156
  })).filter(({ account }) => account.enabled !== false);
1988
2157
  if (eligible.length === 0) return void 0;
2158
+ const active = storage.accounts[storage.activeIndex];
2159
+ const isActiveUsable = (acc) => acc !== void 0 && acc.enabled !== false && !isCoolingDown(acc, now) && !isFamilyRateLimited(acc, familyKey, now) && !isFamilyQuotaExhausted(acc, family, now);
2160
+ if (isActiveUsable(active) && this.inFlightCount(this.accountKey(active), now) < 3) return {
2161
+ account: active,
2162
+ index: storage.activeIndex
2163
+ };
1989
2164
  const ranked = rankPoolCandidates(eligible, model, now, storage.activeIndex);
1990
2165
  const picked = ranked.find((candidate) => candidate.blockedUntil === null && this.inFlightCount(this.accountKey(candidate.account), now) < 3) ?? ranked.find((candidate) => candidate.blockedUntil === null);
1991
2166
  if (!picked) {
1992
2167
  const quotaExhausted = (account) => {
1993
2168
  if (account.cooldownReason === "quota-exhausted" && (account.coolingDownUntil ?? 0) > now) return true;
1994
- const quota = familyQuotaFor(account, family);
2169
+ const quota = familyQuotaFor(account, family, now);
1995
2170
  if (!quota) return false;
1996
2171
  const spent = (fraction, resetTime) => {
1997
2172
  if (typeof fraction !== "number" || fraction > 0 || !resetTime) return false;
@@ -2168,6 +2343,13 @@ var AgySessionManager = class AgySessionManager {
2168
2343
  if (decision.action === "revoke") {
2169
2344
  this.tokenCache.delete(key);
2170
2345
  this.failureCounts.delete(key);
2346
+ const currentIndex = storage.accounts.findIndex((a) => this.accountKey(a) === key);
2347
+ const nextIndex = pickNextAccountIndex(storage.accounts, currentIndex >= 0 ? currentIndex : storage.activeIndex, Date.now());
2348
+ if (nextIndex !== storage.activeIndex) {
2349
+ storage.activeIndex = nextIndex;
2350
+ nextIndexToRotate = nextIndex;
2351
+ }
2352
+ this.clearAffinityForAccount(key);
2171
2353
  return;
2172
2354
  }
2173
2355
  if (kind === "rate-limit" && info?.rateLimitCategory !== "soft_rate_limit") {
@@ -2183,7 +2365,7 @@ var AgySessionManager = class AgySessionManager {
2183
2365
  }
2184
2366
  }
2185
2367
  }
2186
- if (decision.action === "rotate") {
2368
+ if (decision.action === "rotate" || decision.action === "cool") {
2187
2369
  const currentIndex = storage.accounts.findIndex((a) => this.accountKey(a) === key);
2188
2370
  const familyKey = familyKeyOf(info?.model);
2189
2371
  const nextIndex = pickNextAccountIndex(storage.accounts, currentIndex >= 0 ? currentIndex : storage.activeIndex, Date.now(), familyKey);
@@ -2241,7 +2423,7 @@ var AgySessionManager = class AgySessionManager {
2241
2423
  };
2242
2424
  try {
2243
2425
  const account = session.account.email ?? session.account.id;
2244
- const { toAgyRequestBody } = await import("./translate-BQu8HLvB.mjs").then((n) => n.n);
2426
+ const { toAgyRequestBody } = await import("./translate-YhGDUuVa.mjs").then((n) => n.n);
2245
2427
  const { fetchAgyFirstOk } = await import("./constants-BpyRirvk.mjs").then((n) => n.f);
2246
2428
  const { parseAgySse } = await import("./parse-Bs4rPkHA.mjs").then((n) => n.n);
2247
2429
  const requestId = generateAntigravityRequestId();
@@ -2684,4 +2866,4 @@ var JsonAccountStore = class {
2684
2866
  //#endregion
2685
2867
  export { resolveMasterKeyCodec as _, generateFingerprint as a, describeFetchError as b, resolveAntigravityVersionBounded as c, MASTER_KEY_REF as d, createAesGcmCodec as f, resolveDshHome as g, persistMasterKey as h, isAgyDisabled as i, clearExpiredState as l, loadMasterKey as m, maskProxyUrl as n, recordFingerprintVersion as o, deriveKey as p, AgySessionManager as r, resolveAntigravityVersion as s, JsonAccountStore as t, pickProbeProxyUrl as u, classifyFetchError as v, isSessionAccumulationOverflow as x, classifyHttpError as y };
2686
2868
 
2687
- //# sourceMappingURL=accounts-CKWybAfc.mjs.map
2869
+ //# sourceMappingURL=accounts-BN4h0AU4.mjs.map