dsh-agy 0.3.1 → 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.
Files changed (30) hide show
  1. package/README.md +29 -5
  2. package/lib/{accounts-CkaHhb5H.mjs → accounts-BN4h0AU4.mjs} +446 -63
  3. package/lib/accounts-BN4h0AU4.mjs.map +1 -0
  4. package/lib/cli/index.mjs +5 -5
  5. package/lib/client.js +25 -3
  6. package/lib/{constants-Db4tmyfb.mjs → constants-BpyRirvk.mjs} +38 -2
  7. package/lib/constants-BpyRirvk.mjs.map +1 -0
  8. package/lib/{exchange-yZPIZumk.mjs → exchange-LzEyxve1.mjs} +2 -2
  9. package/lib/{exchange-yZPIZumk.mjs.map → exchange-LzEyxve1.mjs.map} +1 -1
  10. package/lib/{import-BaFULQnH.mjs → import-CotRHNDk.mjs} +3 -3
  11. package/lib/{import-BaFULQnH.mjs.map → import-CotRHNDk.mjs.map} +1 -1
  12. package/lib/index.mjs +2 -2
  13. package/lib/{models-BGuwl50d.mjs → models-Qn0-sh6X.mjs} +2 -2
  14. package/lib/{models-BGuwl50d.mjs.map → models-Qn0-sh6X.mjs.map} +1 -1
  15. package/lib/{plugin-common-B1MG00JT.mjs → plugin-common-N7p9Cm-3.mjs} +58 -14
  16. package/lib/plugin-common-N7p9Cm-3.mjs.map +1 -0
  17. package/lib/{quota-summary-5CypVffd.mjs → quota-summary-adID82I2.mjs} +9 -12
  18. package/lib/quota-summary-adID82I2.mjs.map +1 -0
  19. package/lib/{translate-BQu8HLvB.mjs → translate-YhGDUuVa.mjs} +157 -23
  20. package/lib/translate-YhGDUuVa.mjs.map +1 -0
  21. package/lib/web/plugin.d.mts +28 -1
  22. package/lib/web/plugin.d.mts.map +1 -1
  23. package/lib/web/plugin.mjs +35 -19
  24. package/lib/web/plugin.mjs.map +1 -1
  25. package/package.json +9 -4
  26. package/lib/accounts-CkaHhb5H.mjs.map +0 -1
  27. package/lib/constants-Db4tmyfb.mjs.map +0 -1
  28. package/lib/plugin-common-B1MG00JT.mjs.map +0 -1
  29. package/lib/quota-summary-5CypVffd.mjs.map +0 -1
  30. package/lib/translate-BQu8HLvB.mjs.map +0 -1
@@ -1,4 +1,4 @@
1
- import { _ as setResolvedAgyVersion, a as AGY_PLATFORM_ENUM, b as isProxyRouted, d as antigravityUserAgent, g as resolveAgyClientCredentials, l as OAUTH_TOKEN_URL, p as currentAgyVersion, t as AGY_CLIENT_ID, v as AgyAuthError, y as AgyPoolBlockedError } from "./constants-Db4tmyfb.mjs";
1
+ import { _ as setResolvedAgyVersion, a as AGY_PLATFORM_ENUM, b as QUOTA_WINDOWS, d as antigravityUserAgent, g as resolveAgyClientCredentials, l as OAUTH_TOKEN_URL, p as currentAgyVersion, t as AGY_CLIENT_ID, v as AgyAuthError, x as isProxyRouted, y as AgyPoolBlockedError } from "./constants-BpyRirvk.mjs";
2
2
  import { a as probeFetch, l as redactCredentials, o as proxiedFetch, r as isProxyUnreachableError, t as accountFetch } from "./proxy-DfaL73mL.mjs";
3
3
  import { i as generateAntigravityRequestId, r as deriveAntigravitySessionId } from "./identity-Ci8EGc1F.mjs";
4
4
  import { i as parseRefreshParts, n as calculateTokenExpiry, r as formatRefreshParts, t as accessTokenExpired } from "./auth-DPPVFwio.mjs";
@@ -552,8 +552,6 @@ const BACKOFF_TIERS_MS = [
552
552
  3e4,
553
553
  6e4
554
554
  ];
555
- /** Below this remaining fraction the account is treated as soft-quota-exhausted. */
556
- const SOFT_QUOTA_THRESHOLD = .15;
557
555
  function backoffFor(consecutiveFailures, maxJitterMs = 1e3) {
558
556
  const index = Math.min(Math.max(consecutiveFailures, 0), BACKOFF_TIERS_MS.length - 1);
559
557
  return (BACKOFF_TIERS_MS[index] ?? BACKOFF_TIERS_MS[BACKOFF_TIERS_MS.length - 1]) + Math.floor(Math.random() * maxJitterMs);
@@ -725,11 +723,28 @@ function pickNextAccountIndex(accounts, currentIndex, now = Date.now(), modelOrF
725
723
  if (enabled.length === 0) return currentIndex;
726
724
  return (enabled.find((e) => e.index > currentIndex) ?? enabled[0]).index;
727
725
  }
728
- /** Build the soft-quota cache TTL: short when low, long when healthy. */
729
- function computeSoftQuotaCacheTtlMs(remainingFraction, now = Date.now()) {
730
- if (typeof remainingFraction !== "number") return 6e5;
731
- if (remainingFraction < .15) return 6e4;
732
- if (remainingFraction < .5) return 3e5;
726
+ /**
727
+ * Build the soft-quota cache TTL: short when low, long when healthy.
728
+ *
729
+ * Driven by the MOST pressured of the two windows, because they drain on
730
+ * different clocks: an account sitting at 90% of its 5-hour bucket but 2% of its
731
+ * week still has to be re-measured on the short interval, or a weekly exhaustion
732
+ * is discovered only when a request fails.
733
+ *
734
+ * `weeklyFraction` is the optional SECOND parameter, so the two fractions sit
735
+ * adjacent.
736
+ *
737
+ * Takes NO timestamp on purpose: deciding which fractions are still meaningful is
738
+ * `isQuotaStale`'s job, because a window whose reset has passed must not drive the
739
+ * interval either. It applies that guard before calling in here, so both
740
+ * arguments this function sees describe windows that still exist.
741
+ */
742
+ function computeSoftQuotaCacheTtlMs(remainingFraction, weeklyFraction) {
743
+ if (typeof remainingFraction === "number" && remainingFraction < .15) return 6e4;
744
+ if (typeof weeklyFraction === "number" && weeklyFraction <= .01) return 6e4;
745
+ if (typeof remainingFraction !== "number" && typeof weeklyFraction !== "number") return 6e5;
746
+ if (typeof remainingFraction === "number" && remainingFraction < .5) return 3e5;
747
+ if (typeof weeklyFraction === "number" && weeklyFraction < .2) return 3e5;
733
748
  return 9e5;
734
749
  }
735
750
  //#endregion
@@ -1065,12 +1080,195 @@ function earliestResetTime(a, b) {
1065
1080
  return ta <= tb ? a : b;
1066
1081
  }
1067
1082
  /**
1068
- * Aggregate a fetchAvailableModels response into per-family quota records:
1069
- * the family's remaining fraction is its most-pressured model's, and the
1070
- * reset time is the earliest across the family (the bottleneck resets first).
1083
+ * The families a quota group's WINDOW BUCKET ID belongs to.
1084
+ *
1085
+ * `retrieveUserQuotaSummary` labels its buckets with upstream's own counters —
1086
+ * measured ids are `gemini-5h`, `gemini-weekly`, `3p-5h`, `3p-weekly` — which
1087
+ * is the same taxonomy `modelFamilyOf` mirrors. Reading the bucket id is
1088
+ * therefore a machine-readable lookup rather than a guess at a human label.
1089
+ *
1090
+ * `3p-` maps to TWO families on purpose, and that is not a shortcut: upstream
1091
+ * counts Claude and GPT under one third-party counter, so the weekly budget
1092
+ * belongs to both. No model-id prefix rule can recover that split, which is why
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.
1099
+ */
1100
+ function familiesForBucketId(bucketId, window) {
1101
+ const id = bucketId.toLowerCase();
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;
1111
+ }
1112
+ /**
1113
+ * The families a group's LABEL covers, consulted only when no bucket id was
1114
+ * recognizable.
1115
+ *
1116
+ * Upstream's groups are its own taxonomy, not ours, and the measured labels are
1117
+ * `Gemini Models` and `Claude and GPT models` — the second spans two families,
1118
+ * so this is a 1-to-N mapping. It is a FALLBACK, not the primary rule: matching
1119
+ * words in a display string is the fragile half, and it survives only so that a
1120
+ * bucket id upstream renames still lands somewhere sensible.
1121
+ *
1122
+ * A label naming nothing we recognize maps to NO family deliberately.
1123
+ * Attributing an unknown group's budget to `google` would block healthy
1124
+ * accounts; leaving it unmapped only means that family stays unmeasured, which
1125
+ * is exactly the state before this function existed.
1126
+ */
1127
+ function familiesForGroupName(name) {
1128
+ const label = name.toLowerCase();
1129
+ const families = [];
1130
+ if (label.includes("gemini") || label.includes("google")) families.push("google");
1131
+ const claude = label.includes("claude");
1132
+ const gpt = label.includes("gpt");
1133
+ if (claude && gpt) families.push("anthropic", "openai");
1134
+ else if (claude) families.push("anthropic");
1135
+ else if (gpt) families.push("openai");
1136
+ return families;
1137
+ }
1138
+ /**
1139
+ * Which of the two tracked windows an upstream token names, or `undefined` for a
1140
+ * token `CachedQuota` has no field for (`daily`, `monthly`).
1141
+ *
1142
+ * Read from `QUOTA_WINDOWS` (`types.ts`), the SAME table `adapter/quota-summary`
1143
+ * orders windows by. These were two independent tables once, and the gap between
1144
+ * them was a real defect: `daily` was orderable in the parser but unknown here, so
1145
+ * the group materialized an empty record that overwrote a live measurement. One
1146
+ * table makes that drift structurally impossible rather than merely tested for.
1147
+ *
1148
+ * Lookup is exact on the measured vocabulary (`5h`, `weekly`). A variant spelling
1149
+ * therefore classifies as unknown — which is safe, because the guard in
1150
+ * `ingestQuotaGroups` then skips the record rather than emptying it, and the
1151
+ * per-model probe still supplies the 5-hour reading.
1152
+ */
1153
+ function windowKind(window) {
1154
+ return QUOTA_WINDOWS[window.toLowerCase()]?.kind;
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.
1071
1178
  */
1072
- function ingestFamilyQuotas(discovered) {
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
+ }
1187
+ /** The families one group belongs to: its bucket ids first, its label second. */
1188
+ function familiesForGroup(group) {
1189
+ const families = [];
1190
+ const add = (family) => {
1191
+ if (!families.includes(family)) families.push(family);
1192
+ };
1193
+ for (const window of group.windows) for (const family of familiesForBucketId(window.bucketId, window.window)) add(family);
1194
+ if (families.length === 0) for (const family of familiesForGroupName(group.name)) add(family);
1195
+ return families;
1196
+ }
1197
+ /**
1198
+ * Aggregate the grouped `retrieveUserQuotaSummary` windows into per-family
1199
+ * records — the ONLY source of the weekly window.
1200
+ *
1201
+ * `fetchAvailableModels` cannot supply it: its `quotaInfo` carries exactly
1202
+ * `remainingFraction` and `resetTime`, with no window field at all, which is why
1203
+ * a weekly limit used to be invisible to rotation.
1204
+ *
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.
1215
+ */
1216
+ function ingestQuotaGroups(groups) {
1073
1217
  const families = /* @__PURE__ */ new Map();
1218
+ for (const group of groups) {
1219
+ const keys = familiesForGroup(group);
1220
+ if (keys.length === 0) continue;
1221
+ let rolling;
1222
+ let weekly;
1223
+ for (const window of group.windows) {
1224
+ const kind = agreedWindowKind(window.bucketId, window.window);
1225
+ if (kind === "rolling") rolling ??= window;
1226
+ else if (kind === "weekly") weekly ??= window;
1227
+ }
1228
+ for (const key of keys) {
1229
+ const current = families.get(key);
1230
+ const record = { ...current };
1231
+ if (typeof rolling?.remainingFraction === "number") {
1232
+ record.remainingFraction = current?.remainingFraction === void 0 ? rolling.remainingFraction : Math.min(current.remainingFraction, rolling.remainingFraction);
1233
+ const resetTime = earliestResetTime(current?.resetTime, rolling.resetTime ?? void 0);
1234
+ if (resetTime) record.resetTime = resetTime;
1235
+ }
1236
+ if (typeof weekly?.remainingFraction === "number") {
1237
+ record.weeklyFraction = current?.weeklyFraction === void 0 ? weekly.remainingFraction : Math.min(current.weeklyFraction, weekly.remainingFraction);
1238
+ const weeklyResetTime = earliestResetTime(current?.weeklyResetTime, weekly.resetTime ?? void 0);
1239
+ if (weeklyResetTime) record.weeklyResetTime = weeklyResetTime;
1240
+ }
1241
+ if (record.remainingFraction === void 0 && record.weeklyFraction === void 0 && !current) continue;
1242
+ families.set(key, record);
1243
+ }
1244
+ }
1245
+ return Object.fromEntries(families);
1246
+ }
1247
+ /**
1248
+ * Aggregate a `fetchAvailableModels` response, and when available the grouped
1249
+ * `retrieveUserQuotaSummary` windows, into per-family quota records.
1250
+ *
1251
+ * The two sources describe the same counters from different angles, so they are
1252
+ * merged rather than kept apart:
1253
+ * - per MODEL `quotaInfo.remainingFraction` is the rolling 5-hour counter, and
1254
+ * the family takes its most-pressured model (the bottleneck resets first);
1255
+ * - per GROUP summary windows add the weekly budget, which has no per-model
1256
+ * representation at all.
1257
+ *
1258
+ * `previous` is the account's existing cache, and it exists so a probe that
1259
+ * reports only one of the two windows cannot ERASE the other. The measured
1260
+ * reason: `fetchQuotaSummary` returns `[]` instead of throwing, so without the
1261
+ * carry-forward one timing-out endpoint would drop a known-drained weekly window
1262
+ * and put an exhausted account straight back into rotation.
1263
+ *
1264
+ * Carrying that value is safe next to a fresh 5-hour reading because it keeps its
1265
+ * own `weeklyResetTime`: once that moment passes, `isFamilyDrained` and
1266
+ * `parseFutureResetMs` both ignore the reading, so a stale weekly cannot outlive
1267
+ * the window it describes.
1268
+ */
1269
+ function ingestFamilyQuotas(discovered, groups, previous) {
1270
+ const families = /* @__PURE__ */ new Map();
1271
+ if (groups && groups.length > 0) for (const [key, quota] of Object.entries(ingestQuotaGroups(groups))) families.set(key, { ...quota });
1074
1272
  for (const [modelId, entry] of Object.entries(discovered.models ?? {})) {
1075
1273
  const remaining = entry.quotaInfo?.remainingFraction;
1076
1274
  if (typeof remaining !== "number" || !Number.isFinite(remaining)) continue;
@@ -1078,28 +1276,89 @@ function ingestFamilyQuotas(discovered) {
1078
1276
  const current = families.get(key);
1079
1277
  const resetTime = earliestResetTime(current?.resetTime, entry.quotaInfo?.resetTime);
1080
1278
  families.set(key, {
1081
- remainingFraction: current ? Math.min(current.remainingFraction ?? 1, remaining) : remaining,
1279
+ ...current,
1280
+ remainingFraction: current?.remainingFraction === void 0 ? remaining : Math.min(current.remainingFraction, remaining),
1082
1281
  ...resetTime ? { resetTime } : {},
1083
1282
  modelCount: (current?.modelCount ?? 0) + 1
1084
1283
  });
1085
1284
  }
1285
+ if (previous) for (const [key, prev] of Object.entries(previous)) {
1286
+ const current = families.get(key);
1287
+ if (prev.weeklyFraction === void 0) continue;
1288
+ if (current?.weeklyFraction !== void 0) continue;
1289
+ families.set(key, {
1290
+ ...current,
1291
+ weeklyFraction: prev.weeklyFraction,
1292
+ ...current?.weeklyResetTime === void 0 && prev.weeklyResetTime !== void 0 ? { weeklyResetTime: prev.weeklyResetTime } : {}
1293
+ });
1294
+ }
1086
1295
  return Object.fromEntries(families);
1087
1296
  }
1088
- /** The quota record for one family, or the most-pressured family when the model is unknown. */
1089
- 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()) {
1090
1343
  const cache = account.cachedQuota ?? {};
1091
1344
  if (family) return cache[family];
1092
1345
  let worst;
1346
+ let worstScore;
1093
1347
  for (const entry of Object.values(cache)) {
1094
- if (typeof entry.remainingFraction !== "number") continue;
1095
- 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
+ }
1096
1354
  }
1097
1355
  return worst;
1098
1356
  }
1099
1357
  /** Whether the account's quota cache needs a refresh (missing, or past its health-based TTL). */
1100
1358
  function isQuotaStale(account, now = Date.now()) {
1101
1359
  if (!account.cachedQuota || !account.cachedQuotaUpdatedAt) return true;
1102
- const ttl = computeSoftQuotaCacheTtlMs(familyQuotaFor(account)?.remainingFraction);
1360
+ const mostPressured = familyQuotaFor(account, void 0, now);
1361
+ const ttl = computeSoftQuotaCacheTtlMs(resetInPast(mostPressured?.resetTime, now) ? void 0 : mostPressured?.remainingFraction, resetInPast(mostPressured?.weeklyResetTime, now) ? void 0 : mostPressured?.weeklyFraction);
1103
1362
  return now - account.cachedQuotaUpdatedAt > ttl;
1104
1363
  }
1105
1364
  /** How long a measured 5h/weekly window snapshot stays fresh. */
@@ -1125,20 +1384,26 @@ function isLimitsStale(account, now = Date.now()) {
1125
1384
  if (typeof updatedAt !== "number" || !Number.isFinite(updatedAt)) return true;
1126
1385
  return now - updatedAt > LIMITS_CACHE_TTL_MS;
1127
1386
  }
1128
- /** Whether the requested family on this account is soft-quota-exhausted (below the pre-check threshold). */
1129
- function isFamilyDrained(account, family, now = Date.now()) {
1130
- const quota = familyQuotaFor(account, family);
1131
- if (!quota || typeof quota.remainingFraction !== "number") return false;
1132
- if (quota.resetTime) {
1133
- const reset = Date.parse(quota.resetTime);
1134
- if (!Number.isNaN(reset) && reset <= now) return false;
1135
- }
1136
- return quota.remainingFraction < SOFT_QUOTA_THRESHOLD;
1387
+ /** Whether a reset moment has already passed. An absent or malformed one has not. */
1388
+ function resetInPast(resetTime, now) {
1389
+ if (!resetTime) return false;
1390
+ const reset = Date.parse(resetTime);
1391
+ return !Number.isNaN(reset) && reset <= now;
1137
1392
  }
1138
1393
  /**
1139
1394
  * Required drain rate: headroomFraction / remainingHours — how fast the
1140
1395
  * family's remaining quota must be consumed to avoid expiring unused at its
1141
1396
  * reset (mirrors AuthStorage.#computeWindowRequiredDrain with a daily window).
1397
+ *
1398
+ * DELIBERATELY FIVE-HOUR ONLY, and this is a decision rather than an omission.
1399
+ * The weekly window influences `blockedUntil` and `hot` instead of the ranking
1400
+ * ORDER, because a week is not a spend-by deadline: the `DAY_MS` clamp below
1401
+ * caps the horizon at 24h, so a weekly reset ~5 days out would report ~24h of
1402
+ * urgency for a budget that cannot be spent faster to any benefit — and every
1403
+ * family would then be ranked by a number that no longer distinguishes them.
1404
+ * Making this weekly-aware therefore means replacing that clamp with a horizon
1405
+ * the weekly window can actually express, not adding a second fraction to the
1406
+ * numerator.
1142
1407
  */
1143
1408
  function requiredDrainFor(quota, now = Date.now()) {
1144
1409
  const remaining = quota?.remainingFraction;
@@ -1160,6 +1425,32 @@ function parseFutureResetMs(resetTime, now) {
1160
1425
  return reset;
1161
1426
  }
1162
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
+ /**
1163
1454
  * Rank pool candidates for one request, mirroring AuthStorage's antigravity
1164
1455
  * ordering: unblocked first (earliest unblock time among blocked), hot windows
1165
1456
  * last, measured usage before unmeasured, required-drain descending, then
@@ -1171,16 +1462,22 @@ function rankPoolCandidates(entries, modelId, now = Date.now(), startIndex = 0)
1171
1462
  const activePos = entries.findIndex((e) => e.index === startIndex);
1172
1463
  const clampedStart = activePos >= 0 ? activePos : 0;
1173
1464
  const candidates = (entries.length === 0 ? [] : [...entries.slice(clampedStart), ...entries.slice(0, clampedStart)]).map(({ account, index }, orderPos) => {
1174
- const quota = familyQuotaFor(account, family);
1175
- 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;
1176
1469
  const used = typeof remaining === "number" ? Math.min(Math.max(1 - remaining, 0), 1) : void 0;
1470
+ const weeklyRemaining = liveWeekly ? quota.weeklyFraction : void 0;
1471
+ const weeklyUsed = typeof weeklyRemaining === "number" ? Math.min(Math.max(1 - weeklyRemaining, 0), 1) : void 0;
1177
1472
  let blockedUntil = null;
1178
1473
  if (account.coolingDownUntil && account.coolingDownUntil > now) blockedUntil = account.coolingDownUntil;
1179
1474
  const familyLimit = account.rateLimitResetTimes?.[familyKeyOf(modelId)];
1180
1475
  if (familyLimit !== void 0 && familyLimit > now) blockedUntil = blockedUntil === null ? familyLimit : Math.max(blockedUntil, familyLimit);
1181
- if (blockedUntil === null && quota && typeof remaining === "number" && remaining <= 0) {
1182
- const resetMs = parseFutureResetMs(quota.resetTime, now);
1476
+ if (blockedUntil === null && quota) {
1477
+ const resetMs = typeof remaining === "number" && remaining <= 0 ? parseFutureResetMs(quota.resetTime, now) : void 0;
1183
1478
  if (resetMs !== void 0) blockedUntil = resetMs;
1479
+ const weeklyResetMs = typeof weeklyRemaining === "number" && weeklyRemaining <= 0 ? parseFutureResetMs(quota.weeklyResetTime, now) : void 0;
1480
+ if (weeklyResetMs !== void 0) blockedUntil = blockedUntil === null ? weeklyResetMs : Math.max(blockedUntil, weeklyResetMs);
1184
1481
  }
1185
1482
  return {
1186
1483
  account,
@@ -1189,8 +1486,8 @@ function rankPoolCandidates(entries, modelId, now = Date.now(), startIndex = 0)
1189
1486
  blockedUntil,
1190
1487
  usedFraction: used,
1191
1488
  requiredDrain: requiredDrainFor(quota, now),
1192
- hot: used !== void 0 && used >= .85,
1193
- measured: used !== void 0
1489
+ hot: used !== void 0 && used >= .85 || weeklyUsed !== void 0 && weeklyUsed >= .85,
1490
+ measured: used !== void 0 || weeklyUsed !== void 0
1194
1491
  };
1195
1492
  });
1196
1493
  candidates.sort((left, right) => {
@@ -1381,6 +1678,22 @@ var AgySessionManager = class AgySessionManager {
1381
1678
  refreshInFlight = /* @__PURE__ */ new Map();
1382
1679
  /** In-flight quota fetches keyed by account: concurrent selections share one fetchAvailableModels call. */
1383
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();
1384
1697
  failureCounts = /* @__PURE__ */ new Map();
1385
1698
  /** Accounts whose request-time project discovery already failed (no retry per request). */
1386
1699
  projectRetryFailed = /* @__PURE__ */ new Set();
@@ -1488,6 +1801,28 @@ var AgySessionManager = class AgySessionManager {
1488
1801
  clearAffinityForAccount(accountKey) {
1489
1802
  for (const [conversation, pin] of this.affinity) if (pin.key === accountKey) this.affinity.delete(conversation);
1490
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
+ }
1491
1826
  constructor(options) {
1492
1827
  this.store = options.store;
1493
1828
  this.onRotate = options.onRotate;
@@ -1592,6 +1927,49 @@ var AgySessionManager = class AgySessionManager {
1592
1927
  return this.refreshToken(key, account, cached);
1593
1928
  }
1594
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
+ /**
1595
1973
  * Refresh the display-only 5h/weekly windows for every enabled account.
1596
1974
  *
1597
1975
  * SEPARATE from `refreshQuotaCache`, and deliberately so. That method writes
@@ -1639,22 +2017,7 @@ var AgySessionManager = class AgySessionManager {
1639
2017
  const probes = await Promise.all(targets.map(async (account) => {
1640
2018
  const key = this.accountKey(account);
1641
2019
  try {
1642
- const auth = await this.accessTokenFor(account);
1643
- if (!auth) return {
1644
- key,
1645
- ok: false
1646
- };
1647
- const { fetchQuotaSummary } = await import("./quota-summary-5CypVffd.mjs");
1648
- const routed = accountFetch({ proxyUrl: account.proxy });
1649
- const bounded = (input, init) => {
1650
- const timeout = AbortSignal.timeout(AgySessionManager.QUOTA_FETCH_TIMEOUT_MS);
1651
- const signal = init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
1652
- return routed(input, {
1653
- ...init,
1654
- signal
1655
- });
1656
- };
1657
- const groups = await fetchQuotaSummary(auth.access, account.projectId, bounded);
2020
+ const groups = await this.probeQuotaSummary(account);
1658
2021
  if (groups.length === 0) return {
1659
2022
  key,
1660
2023
  ok: false
@@ -1716,16 +2079,18 @@ var AgySessionManager = class AgySessionManager {
1716
2079
  try {
1717
2080
  const auth = await this.accessTokenFor(account);
1718
2081
  if (!auth) return null;
1719
- const { fetchAvailableModels } = await import("./models-BGuwl50d.mjs").then((n) => n.r);
2082
+ const { fetchAvailableModels } = await import("./models-Qn0-sh6X.mjs").then((n) => n.r);
1720
2083
  const routed = accountFetch({ proxyUrl: account.proxy });
1721
- const quotas = ingestFamilyQuotas(await fetchAvailableModels(auth.access, account.projectId, (input, init) => {
2084
+ const bounded = (input, init) => {
1722
2085
  const timeout = AbortSignal.timeout(AgySessionManager.QUOTA_FETCH_TIMEOUT_MS);
1723
2086
  const signal = init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
1724
2087
  return routed(input, {
1725
2088
  ...init,
1726
2089
  signal
1727
2090
  });
1728
- }));
2091
+ };
2092
+ const [discovered, groups] = await Promise.all([fetchAvailableModels(auth.access, account.projectId, bounded), this.probeQuotaSummary(account)]);
2093
+ const quotas = ingestFamilyQuotas(discovered, groups, account.cachedQuota);
1729
2094
  return {
1730
2095
  key,
1731
2096
  quotas,
@@ -1765,7 +2130,8 @@ var AgySessionManager = class AgySessionManager {
1765
2130
  }
1766
2131
  /**
1767
2132
  * Pick the account for one request: the affinity pin wins while it is fresh,
1768
- * 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
1769
2135
  * ranked by family-scoped usage (OMP-aligned) and the best candidate wins.
1770
2136
  */
1771
2137
  async pickAccount(storage, model, conversationKey) {
@@ -1778,7 +2144,7 @@ var AgySessionManager = class AgySessionManager {
1778
2144
  const lastIndex = storage.accounts.findIndex((a) => this.accountKey(a) === pinnedKey);
1779
2145
  if (lastIndex !== -1) {
1780
2146
  const last = storage.accounts[lastIndex];
1781
- 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 {
1782
2148
  account: last,
1783
2149
  index: lastIndex
1784
2150
  };
@@ -1789,15 +2155,25 @@ var AgySessionManager = class AgySessionManager {
1789
2155
  index
1790
2156
  })).filter(({ account }) => account.enabled !== false);
1791
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
+ };
1792
2164
  const ranked = rankPoolCandidates(eligible, model, now, storage.activeIndex);
1793
2165
  const picked = ranked.find((candidate) => candidate.blockedUntil === null && this.inFlightCount(this.accountKey(candidate.account), now) < 3) ?? ranked.find((candidate) => candidate.blockedUntil === null);
1794
2166
  if (!picked) {
1795
2167
  const quotaExhausted = (account) => {
1796
2168
  if (account.cooldownReason === "quota-exhausted" && (account.coolingDownUntil ?? 0) > now) return true;
1797
- const quota = familyQuotaFor(account, family);
1798
- if ((quota?.remainingFraction ?? 1) > 0 || !quota?.resetTime) return false;
1799
- const resetAt = Date.parse(quota.resetTime);
1800
- return !Number.isNaN(resetAt) && resetAt > now;
2169
+ const quota = familyQuotaFor(account, family, now);
2170
+ if (!quota) return false;
2171
+ const spent = (fraction, resetTime) => {
2172
+ if (typeof fraction !== "number" || fraction > 0 || !resetTime) return false;
2173
+ const resetAt = Date.parse(resetTime);
2174
+ return !Number.isNaN(resetAt) && resetAt > now;
2175
+ };
2176
+ return spent(quota.remainingFraction, quota.resetTime) || spent(quota.weeklyFraction, quota.weeklyResetTime);
1801
2177
  };
1802
2178
  const retryable = ranked.filter((candidate) => !quotaExhausted(candidate.account));
1803
2179
  const blocked = retryable.length > 0 ? retryable : ranked;
@@ -1885,7 +2261,7 @@ var AgySessionManager = class AgySessionManager {
1885
2261
  }
1886
2262
  const key = this.accountKey(picked.account);
1887
2263
  if (!picked.account.projectId && !this.projectRetryFailed.has(key)) try {
1888
- const { loadCodeAssist } = await import("./exchange-yZPIZumk.mjs").then((n) => n.n);
2264
+ const { loadCodeAssist } = await import("./exchange-LzEyxve1.mjs").then((n) => n.n);
1889
2265
  const { projectId } = await loadCodeAssist(auth.access, { proxyUrl: picked.account.proxy });
1890
2266
  if (projectId) {
1891
2267
  await this.store.mutate((s) => {
@@ -1967,6 +2343,13 @@ var AgySessionManager = class AgySessionManager {
1967
2343
  if (decision.action === "revoke") {
1968
2344
  this.tokenCache.delete(key);
1969
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);
1970
2353
  return;
1971
2354
  }
1972
2355
  if (kind === "rate-limit" && info?.rateLimitCategory !== "soft_rate_limit") {
@@ -1982,7 +2365,7 @@ var AgySessionManager = class AgySessionManager {
1982
2365
  }
1983
2366
  }
1984
2367
  }
1985
- if (decision.action === "rotate") {
2368
+ if (decision.action === "rotate" || decision.action === "cool") {
1986
2369
  const currentIndex = storage.accounts.findIndex((a) => this.accountKey(a) === key);
1987
2370
  const familyKey = familyKeyOf(info?.model);
1988
2371
  const nextIndex = pickNextAccountIndex(storage.accounts, currentIndex >= 0 ? currentIndex : storage.activeIndex, Date.now(), familyKey);
@@ -2040,8 +2423,8 @@ var AgySessionManager = class AgySessionManager {
2040
2423
  };
2041
2424
  try {
2042
2425
  const account = session.account.email ?? session.account.id;
2043
- const { toAgyRequestBody } = await import("./translate-BQu8HLvB.mjs").then((n) => n.n);
2044
- const { fetchAgyFirstOk } = await import("./constants-Db4tmyfb.mjs").then((n) => n.f);
2426
+ const { toAgyRequestBody } = await import("./translate-YhGDUuVa.mjs").then((n) => n.n);
2427
+ const { fetchAgyFirstOk } = await import("./constants-BpyRirvk.mjs").then((n) => n.f);
2045
2428
  const { parseAgySse } = await import("./parse-Bs4rPkHA.mjs").then((n) => n.n);
2046
2429
  const requestId = generateAntigravityRequestId();
2047
2430
  const body = toAgyRequestBody({
@@ -2483,4 +2866,4 @@ var JsonAccountStore = class {
2483
2866
  //#endregion
2484
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 };
2485
2868
 
2486
- //# sourceMappingURL=accounts-CkaHhb5H.mjs.map
2869
+ //# sourceMappingURL=accounts-BN4h0AU4.mjs.map