@hystudio/dsh-quota-pill 0.2.11 → 0.2.13

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.
package/AGENTS.md CHANGED
@@ -16,10 +16,10 @@ For a fresh DSH Desktop:
16
16
 
17
17
  1. If `dsh` is not on PATH: DSH Desktop → Settings → **Manage dsh Command**.
18
18
  2. Install a **pinned** build, never the bare name:
19
- - GUI: **Plugins → Install** → `@hystudio/dsh-quota-pill@0.2.11`
20
- - CLI: `dsh plugin add @hystudio/dsh-quota-pill@0.2.11 --profile desktop`
21
- - Until 0.2.11 is on npm:
22
- `github:598934321/dsh-quota-pill#feat/user-adapters-0.2.11`
19
+ - GUI: **Plugins → Install** → `@hystudio/dsh-quota-pill@0.2.12`
20
+ - CLI: `dsh plugin add @hystudio/dsh-quota-pill@0.2.12 --profile desktop`
21
+ - If pnpm's minimumReleaseAge rejects the fresh 0.2.12 release, use the
22
+ branch until it ages: `github:598934321/dsh-quota-pill#master`
23
23
  3. Confirm the dependency and the bundle are present:
24
24
 
25
25
  ```bash
@@ -34,7 +34,7 @@ For a fresh DSH Desktop:
34
34
 
35
35
  **Why pinned:** pnpm's `minimumReleaseAge` can resolve an unpinned install to the
36
36
  broken 0.2.2 (its client half fails to import and the web GUI shows "Failed to
37
- load plugins"). 0.2.11 is the first version with all providers, Open Design
37
+ load plugins"). 0.2.12 is the first version with all providers, Open Design
38
38
  Control Keys, sidecar auto-start, and user-defined adapters.
39
39
 
40
40
  The host half starts the sidecar when nothing is listening on :8319, so a normal
@@ -77,7 +77,7 @@ DSH account menu, then re-run step 5.
77
77
 
78
78
  ## 1. Verify the sidecar runs
79
79
 
80
- The 0.2.11 host plugin starts it automatically when :8319 is free. Give DSH a few
80
+ The 0.2.12 host plugin starts it automatically when :8319 is free. Give DSH a few
81
81
  seconds, then check. If the host half could not start it (or you opted out with
82
82
  `QUOTA_PILL_NO_AUTOSTART=1`), run it manually:
83
83
 
@@ -105,7 +105,7 @@ CLIProxyAPI". That is expected on a fresh machine — tell the human that
105
105
  `kimi` / `opencode` / `commandcode` work without it, and ask whether they want
106
106
  the OAuth three as well.
107
107
 
108
- **Auto-start is the default from 0.2.11**; do not add a service unless the human
108
+ **Auto-start is the default from 0.2.12**; do not add a service unless the human
109
109
  asks for one or DSH is headless. If you do add one, set
110
110
  `QUOTA_PILL_NO_AUTOSTART=1` so the plugin and the service do not race, and use
111
111
  the **absolute** Node path (launchd's PATH does not include nvm/homebrew):
package/README.md CHANGED
@@ -71,25 +71,26 @@ in [`src/user-adapters.js`](src/user-adapters.js) and in AGENTS.md §2b.
71
71
 
72
72
  **DSH Desktop (recommended).** Open **Plugins → Install**, paste a spec, enable
73
73
  the bundle. **Pin the version**: an unpinned install can resolve 0.2.2, which
74
- breaks web boot. 0.2.11 is the first version with every provider, Open Design
74
+ breaks web boot. 0.2.12 is the first version with every provider, Open Design
75
75
  Control Keys, sidecar auto-start, and **user-defined adapters** for providers the
76
76
  package does not ship.
77
77
 
78
78
  ```text
79
- @hystudio/dsh-quota-pill@0.2.11
79
+ @hystudio/dsh-quota-pill@0.2.12
80
80
  ```
81
81
 
82
- Until 0.2.11 is on npm, install the branch:
82
+ 0.2.12 is published on npm. If pnpm's minimumReleaseAge rejects this fresh
83
+ release, install the branch until it ages:
83
84
 
84
85
  ```text
85
- github:598934321/dsh-quota-pill#feat/user-adapters-0.2.11
86
+ github:598934321/dsh-quota-pill#master
86
87
  ```
87
88
 
88
89
  **CLI.** If `dsh` is not on PATH, open DSH Desktop → Settings → **Manage dsh
89
90
  Command** first. Then:
90
91
 
91
92
  ```bash
92
- dsh plugin add @hystudio/dsh-quota-pill@0.2.11
93
+ dsh plugin add @hystudio/dsh-quota-pill@0.2.12
93
94
  ```
94
95
 
95
96
  > The npm package is `@hystudio/...` while the repo lives at `598934321/...` —
@@ -239,9 +240,15 @@ directly, current plans only reveal allowance in `x-ratelimit-*` response header
239
240
  of a one-token completion, and unified-billing accounts have no ceiling at all
240
241
  (reported as `payg`).
241
242
 
242
- **Antigravity** shares one quota pool across all Gemini models, so it reports a
243
- single aggregate row — per-model rows would all be the same number and the label
244
- would flicker between models you do not even have configured.
243
+ **Antigravity** has three independent quota families, and the pill shows one row
244
+ per family: Gemini models (5-hour + weekly), Gemini 4 Argon (its own weekly
245
+ bucket) and Claude / GPT (5-hour + weekly). The backend decides which catalogue
246
+ and which quota groups it reports from the client label in the `User-Agent`: a
247
+ generic UA gets a legacy answer where every Gemini model is a constant 100% and
248
+ `gemini-4-argon-*` does not exist at all. The adapter therefore sends the
249
+ Antigravity CLI's own UA (`antigravity/cli/<version> (aidev_client; …)`), reads
250
+ `retrieveUserQuotaSummary` and falls back to per-model `fetchAvailableModels`.
251
+ Set `QUOTA_PILL_AGY_UA` to override the UA if a future CLI changes it.
245
252
 
246
253
  **Command Code** does not expose a monthly ceiling. The adapter derives it from
247
254
  `monthlyCap = weekly.cap × 2`, an identity verified against two different plans
@@ -263,6 +270,11 @@ then writes `OPENDESIGN_CONTROL_KEY` and `OPENDESIGN_WORKSPACE_ID` into
263
270
  `~/.dsh/.credentials.yaml`. The workspace id is optional when using a Control
264
271
  Key — it is otherwise resolved from `/workspaces/current`.
265
272
 
273
+ Windows with a real reset period (5-hour / weekly / monthly) always render as a
274
+ percentage — including the 100% seen right after a reset — so a bucket never
275
+ switches units as it is spent. The credit counts stay in the row's tooltip;
276
+ only credit packs without a period render as credits.
277
+
266
278
  The older console-session route (`__Secure-better-auth.session_token` cookie,
267
279
  browser-session-only `/billing/coding-plan-usage`) is still supported as a
268
280
  fallback; that cookie is HttpOnly, so copy it from devtools → Application →
package/client.js CHANGED
@@ -172,7 +172,7 @@ window.__ModuleLoader__.load({
172
172
  */
173
173
  function adaptationPrompt(model, provider, lang) {
174
174
  const m = String(model || '(unknown)');
175
- const p = String(provider && provider !== 'unknown' ? provider : '(unknown)');
175
+ const p = String(provider && provider !== 'unknown' ? provider : 'unknown');
176
176
  if (lang === 'zh') {
177
177
  return [
178
178
  '你是 DSH quota-pill 的 provider 适配 agent。当前模型 ' + m + ' 的 provider(' + p + ')未被插件识别,读取不到余额。',
@@ -430,6 +430,12 @@ window.__ModuleLoader__.load({
430
430
  '7d': ['每周', 'Weekly'],
431
431
  monthly: ['每月', 'Monthly'],
432
432
  'model-pool': ['模型池', 'Model pool'],
433
+ // Antigravity 的两个独立额度族(Gemini / Claude·Opus)。必须登记成已知
434
+ // 窗口:否则 providerEntries 会认为它们不是额度周期,再补一行汇总,
435
+ // 同一份额度显示两次。
436
+ 'agy-gemini': ['Gemini', 'Gemini'],
437
+ 'agy-argon': ['Gemini 4', 'Gemini 4'],
438
+ 'agy-opus': ['Claude / Opus', 'Claude / Opus'],
433
439
  // 积分类的固定三行。放进这里是为了让 isKnownWindow 认得它们——
434
440
  // 否则 providerEntries 会再前置一条汇总行,变成四行。
435
441
  'credits-all': ['全部积分', 'All credits'],
@@ -535,6 +541,21 @@ window.__ModuleLoader__.load({
535
541
  return !!WINDOW_LABEL[String(key).replace(/^USAGE_PERIOD_TYPE_/i, '').toLowerCase()];
536
542
  }
537
543
 
544
+ /**
545
+ * 「周期性额度窗口」= 有真实重置周期的桶(5 小时 / 每周 / 每月)。
546
+ *
547
+ * 这类窗口的百分比永远有信息量:刚重置就是 100%,用掉就是 94%,两种状态
548
+ * 都值得看。它们不能走 pctIsInformative 的「满格就显示积分」规则——那条
549
+ * 规则是为「没有周期的积分包」写的,用在 opendesign 的 5h 桶上会让它在
550
+ * 刚重置时显示「50k 积分」、用掉之后又变回百分比,同一个窗口两种单位。
551
+ */
552
+ const PERIODIC_WINDOWS = new Set(['5h', 'fivehour', 'rolling', 'weekly', '7d', 'monthly']);
553
+
554
+ function isPeriodicWindow(key) {
555
+ const k = String(key || '').replace(/^USAGE_PERIOD_TYPE_/i, '').toLowerCase();
556
+ return PERIODIC_WINDOWS.has(k) || /^weekly-\d+$/.test(k);
557
+ }
558
+
538
559
  /**
539
560
  * 免费渠道池的两种汇总口径。它们不是「额度周期」,所以不加「额度剩余」后缀,
540
561
  * 否则会读成「最紧窗口额度剩余」这种别扭的说法。
@@ -641,6 +662,8 @@ window.__ModuleLoader__.load({
641
662
  pct: Number.isFinite(pct) ? pct : null,
642
663
  reset: (w.credits && w.resetAt) ? fmtExpiry(w.resetAt, lang) : fmtResetAt(w.resetAt, lang),
643
664
  credits: w.credits || null,
665
+ // 有重置周期的窗口一律优先显示百分比(见 isPeriodicWindow)。
666
+ periodic: isPeriodicWindow(w.key),
644
667
  });
645
668
  }
646
669
  // 积分类用的是固定三行,三个键都算「已知窗口」,所以不会再前置汇总行——
@@ -1385,7 +1408,7 @@ window.__ModuleLoader__.load({
1385
1408
  }
1386
1409
 
1387
1410
  function WindowRow(props) {
1388
- const { label, pct, reset, detail, credits, lang, stale, money: isMoney, compact } = props;
1411
+ const { label, pct, reset, detail, credits, lang, stale, money: isMoney, compact, periodic } = props;
1389
1412
  // `detail` is a generic secondary line (a payg bonus, an aggregate hint);
1390
1413
  // `reset` is the window-reset countdown. Rows in a normal group reserve
1391
1414
  // the line even when empty so their numbers stay aligned inside the group;
@@ -1402,7 +1425,8 @@ window.__ModuleLoader__.load({
1402
1425
  // · 百分比恒 100% → 显示积分(这种渠道百分比什么都说明不了)
1403
1426
  // 判据见 pctIsInformative;两种方式都保留,只是不同渠道各取其一。
1404
1427
  const hasCredits = credits && typeof credits.remain === 'number';
1405
- const showPct = isNum && pctIsInformative(pct, credits);
1428
+ // 周期窗口:百分比永远是主读数;积分只在 tooltip 里作为补充。
1429
+ const showPct = isNum && (periodic || pctIsInformative(pct, credits));
1406
1430
  const showCredits = hasCredits && !showPct;
1407
1431
  // data-stale:这份读数是「该 provider 上次的值」,正在后台刷新。
1408
1432
  // 视觉上降饱和 + 缓慢呼吸,数据到了再撤掉(见 CSS)。
@@ -1458,7 +1482,7 @@ window.__ModuleLoader__.load({
1458
1482
  h('div', { className: 'quota-pill-group-head' }, name),
1459
1483
  // 组内行 key 带组名,避免不同 provider 的同名窗口(都是 5h/weekly)
1460
1484
  // 被 React 当成同一个组件实例,导致 tween 跨 provider 插值。
1461
- entries.map((e) => h(WindowRow, { key: `${name}|${e.key}`, label: e.label, pct: e.pct, reset: e.reset, detail: e.detail, compact: e.compact, credits: e.credits, lang, stale, money: e.money })),
1485
+ entries.map((e) => h(WindowRow, { key: `${name}|${e.key}`, label: e.label, pct: e.pct, reset: e.reset, detail: e.detail, compact: e.compact, credits: e.credits, periodic: e.periodic, lang, stale, money: e.money })),
1462
1486
  );
1463
1487
  }
1464
1488
 
@@ -1533,7 +1557,7 @@ window.__ModuleLoader__.load({
1533
1557
  // key 带 provider:换 provider 时强制重挂载,让 tween 从「空」开始,
1534
1558
  // 而不是把 A 的 5h 平滑滑到 B 的 5h——那是两个不同账户的额度,
1535
1559
  // 中间的数字不代表任何真实状态。
1536
- entries.map((e) => h(WindowRow, { key: `${providerName}|${e.key}`, label: e.label, pct: e.pct, reset: e.reset, credits: e.credits, lang, stale, money: e.money })),
1560
+ entries.map((e) => h(WindowRow, { key: `${providerName}|${e.key}`, label: e.label, pct: e.pct, reset: e.reset, credits: e.credits, periodic: e.periodic, lang, stale, money: e.money })),
1537
1561
  ),
1538
1562
  h('dl', { className: 'quota-pill-stats' },
1539
1563
  data.account ? statRow(pick(lang, 'account'), String(data.account)) : null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hystudio/dsh-quota-pill",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Quota pill for DeepSeek Harness: remaining quota for every LLM subscription you have, in one collapsible pill",
@@ -422,67 +422,210 @@ export async function codex() {
422
422
 
423
423
  /* --------------------------------------------------------------------- agy */
424
424
  /**
425
- * Antigravity (Google Cloud Code) — all Gemini models share ONE pool.
425
+ * Antigravity (Google Cloud Code) reports THREE independent quota families,
426
+ * each with its own limits:
426
427
  *
427
- * That is why this adapter deliberately reports no per-model rows: all 22
428
- * remainingFraction values are identical, so picking "the tightest" would just
429
- * be "first in dict order" and the label would flicker between model names that
430
- * are not even what the user has configured. The pool total is real, so only
431
- * the aggregate is reported.
428
+ * - Gemini models - 5-hour + weekly buckets ("Gemini Models" group)
429
+ * - Gemini 4 Argon - its own weekly bucket ("Gemini 4 Argon" group)
430
+ * - Claude / GPT - 5-hour + weekly buckets ("Claude and GPT models")
431
+ *
432
+ * The backend decides which catalogue AND which quota groups it reports from
433
+ * the client label in the User-Agent. With a generic UA ("antigravity/1.0") it
434
+ * serves a legacy catalogue: no gemini-4-argon-*, and every Gemini model comes
435
+ * back with remainingFraction 1 and a resetTime that merely tracks "now + 5h"
436
+ * - a permanent 100% that hides real usage. The Antigravity CLI's own UA
437
+ * ("antigravity/cli/<version> (aidev_client; ...)") gets the real catalogue and
438
+ * the real groups. Verified against the CLI's captured traffic on 2026-10-26
439
+ * (same token: generic UA -> 23 models, no argon, no Gemini group; CLI UA ->
440
+ * 29 models, argon at ~91%, three groups).
441
+ *
442
+ * Preferred source is retrieveUserQuotaSummary: one request, one group per
443
+ * family, each with its buckets. Per-model fetchAvailableModels stays as a
444
+ * fallback if that RPC changes shape.
445
+ */
446
+ const AGY_CLI_UA = process.env.QUOTA_PILL_AGY_UA
447
+ || 'antigravity/cli/1.4.7 (aidev_client; os_type=darwin; arch=arm64; cl=1006008713; auth_method=consumer)';
448
+ const AGY_PROJECT = { project: 'aicode-consumers' };
449
+
450
+ /**
451
+ * One entry per quota family the pill shows. The key is the window key the
452
+ * client renders; keep it stable - the label table and the spend history are
453
+ * keyed by it.
454
+ */
455
+ const AGY_FAMILIES = [
456
+ { key: 'agy-gemini', label: 'Gemini' },
457
+ { key: 'agy-argon', label: 'Gemini 4' },
458
+ { key: 'agy-opus', label: 'Claude / Opus' },
459
+ ];
460
+
461
+ /** Family a model id belongs to (decides which number is the headline one). */
462
+ function agyFamilyForModel(model) {
463
+ // Model ids arrive bare from the all page and as provider/model on /quota.
464
+ const id = String(model || '').split('/').pop();
465
+ if (/argon/i.test(id)) return 'agy-argon';
466
+ if (/^(claude|gpt)/i.test(id)) return 'agy-opus';
467
+ if (/gemini/i.test(id)) return 'agy-gemini';
468
+ return null;
469
+ }
470
+
471
+ /** Family a quota-summary group belongs to. */
472
+ function agyFamilyForGroup(name) {
473
+ const n = String(name || '');
474
+ if (/argon/i.test(n)) return 'agy-argon';
475
+ if (/claude|gpt/i.test(n)) return 'agy-opus';
476
+ if (/gemini/i.test(n)) return 'agy-gemini';
477
+ return null;
478
+ }
479
+
480
+ /**
481
+ * Collapse quota-summary groups into one reading per family. The tightest
482
+ * bucket decides the percentage (a 5-hour bucket at 0 blocks even when the
483
+ * weekly one still has 46%), and that bucket's reset is what the user can act
484
+ * on.
485
+ */
486
+ function agyFamiliesFromSummary(data) {
487
+ const out = new Map();
488
+ for (const group of (data && data.groups) || []) {
489
+ const key = agyFamilyForGroup(group && group.displayName);
490
+ if (!key) continue;
491
+ const fam = AGY_FAMILIES.find((f) => f.key === key);
492
+ let frac = null;
493
+ let resetAt = null;
494
+ const buckets = [];
495
+ for (const b of (group && group.buckets) || []) {
496
+ const v = b && b.remainingFraction;
497
+ if (typeof v !== 'number') continue;
498
+ buckets.push({ id: b.bucketId || null, window: b.window || null, frac: v, resetAt: b.resetTime || null });
499
+ if (frac === null || v < frac) {
500
+ frac = v;
501
+ resetAt = b.resetTime || resetAt;
502
+ }
503
+ }
504
+ if (frac === null) continue;
505
+ out.set(key, { key, label: fam.label, frac, resetAt, buckets });
506
+ }
507
+ return out;
508
+ }
509
+
510
+ /** Fallback: collapse per-model buckets into one reading per family. */
511
+ function agyFamiliesFromModels(models) {
512
+ const out = new Map();
513
+ for (const [mid, info] of Object.entries(models || {})) {
514
+ const key = agyFamilyForModel(mid);
515
+ if (!key) continue;
516
+ const q = (info && info.quotaInfo) || {};
517
+ const frac = q.remainingFraction;
518
+ if (typeof frac !== 'number') continue;
519
+ const fam = AGY_FAMILIES.find((f) => f.key === key);
520
+ const cur = out.get(key) || { key, label: fam.label, frac: 1, resetAt: null, buckets: [] };
521
+ cur.buckets.push({ id: mid, window: null, frac, resetAt: q.resetTime || null });
522
+ if (frac < cur.frac) {
523
+ cur.frac = frac;
524
+ cur.resetAt = q.resetTime || cur.resetAt;
525
+ } else if (!cur.resetAt && q.resetTime) {
526
+ cur.resetAt = q.resetTime;
527
+ }
528
+ out.set(key, cur);
529
+ }
530
+ return out;
531
+ }
532
+
533
+ /** Shape one adapter payload from the families that were actually reported. */
534
+ function agyPayload(cred, fams, model, source) {
535
+ const wanted = agyFamilyForModel(model);
536
+ // Headline: the family the current model uses. Without a model (the all
537
+ // page) Gemini leads, which is also the number quota failover tools compare
538
+ // against - do not change that silently.
539
+ const primary = (wanted && fams.get(wanted))
540
+ || fams.get('agy-gemini')
541
+ || fams.values().next().value;
542
+ const remaining = round2(primary.frac * 100);
543
+ const windows = AGY_FAMILIES
544
+ .filter((f) => fams.has(f.key))
545
+ .map((f) => {
546
+ const v = fams.get(f.key);
547
+ return { key: v.key, remainPct: round2(v.frac * 100), resetAt: v.resetAt || null };
548
+ });
549
+ return {
550
+ kind: 'subscription-quota',
551
+ provider: 'agy',
552
+ account: cred.account || cred.name,
553
+ remainingPct: remaining,
554
+ usedPct: round2(100 - remaining),
555
+ window: primary.key,
556
+ resetAt: primary.resetAt || null,
557
+ windows,
558
+ raw: {
559
+ source,
560
+ families: Object.fromEntries([...fams].map(([k, v]) => [k, {
561
+ remainPct: round2(v.frac * 100),
562
+ resetAt: v.resetAt || null,
563
+ buckets: v.buckets,
564
+ }])),
565
+ },
566
+ };
567
+ }
568
+
569
+ /**
570
+ * Read one Antigravity login. Both endpoints need the CLI User-Agent - see the
571
+ * comment above: without it the backend answers from a legacy catalogue whose
572
+ * Gemini quota is a permanent 100%.
432
573
  */
433
- export async function agy() {
574
+ async function agyReading(cred, model, errors) {
575
+ const post = (url) => curlJson(url, {
576
+ method: 'POST',
577
+ // googleapis is unreachable directly on some networks; CLIProxyAPI users
578
+ // almost always have a local proxy configured.
579
+ proxy: process.env.QUOTA_PILL_PROXY || defaultProxy(),
580
+ token: cred.token,
581
+ body: AGY_PROJECT,
582
+ headers: { 'Content-Type': 'application/json', 'User-Agent': AGY_CLI_UA },
583
+ timeoutMs: 25000,
584
+ });
585
+
586
+ const summary = await post('https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary');
587
+ if (summary && summary.ok) {
588
+ const fams = agyFamiliesFromSummary(summary.data);
589
+ if (fams.size) return agyPayload(cred, fams, model, 'v1internal:retrieveUserQuotaSummary');
590
+ errors.push(cred.name + ': retrieveUserQuotaSummary had no known groups');
591
+ } else {
592
+ errors.push(cred.name + ': retrieveUserQuotaSummary ' + ((summary && summary.error) || 'request failed'));
593
+ }
594
+
595
+ const available = await post('https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels');
596
+ if (!available || !available.ok) {
597
+ errors.push(cred.name + ': ' + ((available && available.error) || 'request failed'));
598
+ return null;
599
+ }
600
+ const fams = agyFamiliesFromModels((available.data && available.data.models) || {});
601
+ if (!fams.size) {
602
+ errors.push(cred.name + ': fetchAvailableModels had no quotaInfo');
603
+ return null;
604
+ }
605
+ return agyPayload(cred, fams, model, 'v1internal:fetchAvailableModels');
606
+ }
607
+
608
+ export async function agy(model) {
434
609
  const usable = usableFiles('antigravity');
435
610
  if (!usable.length) return noCredential('agy', 'antigravity');
436
611
 
437
612
  const errors = [];
438
613
  let best = null;
439
-
440
614
  for (const cred of usable) {
441
- const res = await curlJson(
442
- 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
443
- {
444
- method: 'POST',
445
- // googleapis is unreachable directly on some networks; CLIProxyAPI
446
- // users almost always have a local proxy configured.
447
- proxy: process.env.QUOTA_PILL_PROXY || defaultProxy(),
448
- token: cred.token,
449
- body: {},
450
- headers: { 'Content-Type': 'application/json', 'User-Agent': 'antigravity/1.0' },
451
- timeoutMs: 25000,
452
- },
453
- );
454
- if (!res || !res.ok) {
455
- errors.push(`${cred.name}: ${(res && res.error) || 'request failed'}`);
456
- continue;
457
- }
458
- const models = (res.data && res.data.models) || {};
459
- const fractions = [];
460
- for (const [mid, info] of Object.entries(models)) {
461
- const q = (info && info.quotaInfo) || {};
462
- const frac = q.remainingFraction;
463
- if (typeof frac === 'number') fractions.push([mid, frac, q.resetTime]);
464
- }
465
- if (!fractions.length) {
466
- errors.push(cred.name + ': no quotaInfo');
467
- continue;
468
- }
469
- fractions.sort((a, b) => a[1] - b[1]);
470
- const [sampleModel, frac, reset] = fractions[0];
471
- const remaining = round2(frac * 100);
472
- const candidate = {
473
- kind: 'subscription-quota',
474
- provider: 'agy',
475
- account: cred.account || cred.name,
476
- remainingPct: remaining,
477
- usedPct: round2(100 - remaining),
478
- window: 'model-pool',
479
- resetAt: reset || null,
480
- raw: { accounts: usable.length, poolModels: fractions.length, sampleModel },
481
- };
482
- if (!best || candidate.remainingPct > best.remainingPct) best = candidate;
615
+ const reading = await agyReading(cred, model, errors);
616
+ if (!reading) continue;
617
+ // Several antigravity logins: report the one with the most headroom in its
618
+ // tightest family, so a half-spent account cannot mask a full one.
619
+ const tightest = Math.min(...reading.windows.map((w) => w.remainPct));
620
+ if (!best || tightest > best.tightest) best = { reading, tightest };
483
621
  }
484
622
 
485
- if (best) return best;
623
+ if (best) {
624
+ if (usable.length > 1) {
625
+ best.reading.raw = { ...best.reading.raw, accounts: usable.length };
626
+ }
627
+ return best.reading;
628
+ }
486
629
  return { kind: 'probe-failed', provider: 'agy', error: errors.join('; ') || 'no quota' };
487
630
  }
488
631