dsh-plugin-subscription-oauth 0.7.0 → 0.8.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.
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Cross-request memory of when each provider account's allowance resets.
3
+ *
4
+ * This is what lets one model id draw on the whole account pool without a wasted
5
+ * request: when the default account's allowance runs out, the moment it will refill is
6
+ * remembered, and until then the scheduler sends work straight to another account
7
+ * instead of trying the spent one, failing, and only then moving on. When the reset
8
+ * time passes the spent account is first choice again.
9
+ *
10
+ * The holder is shared by the route's reader (which learns reset times from usage
11
+ * reads) and its streamer (which consults them when picking an account), so the
12
+ * scheduler's view is exactly the last read's.
13
+ *
14
+ * @module dsh-plugin-subscription-oauth/auth/allowance-scheduler
15
+ */
16
+ /** One account's remembered allowance state. */
17
+ export interface AllowanceState {
18
+ /** Epoch milliseconds at which the account's allowance resets, when known. */
19
+ resetsAt?: number;
20
+ /** Epoch milliseconds at which this state was last learned. */
21
+ learnedAt: number;
22
+ }
23
+ /** In-memory, per-provider memory of account reset times. */
24
+ export declare class AllowanceScheduler {
25
+ private readonly byProvider;
26
+ /** One provider's account states, creating the map on first use. */
27
+ private provider;
28
+ /**
29
+ * Record that one account's allowance resets at a given moment.
30
+ *
31
+ * @param provider - the provider route id.
32
+ * @param account - the account key.
33
+ * @param resetsAt - epoch milliseconds of the allowance reset, when known.
34
+ */
35
+ noteReset(provider: string, account: string, resetsAt?: number): void;
36
+ /**
37
+ * Whether one account's allowance is known to be spent now.
38
+ *
39
+ * True when the account was recorded with a reset time that is still in the future:
40
+ * its allowance is spent and will not refill until then, so a request should go to a
41
+ * different account rather than fail against this one.
42
+ *
43
+ * @param provider - the provider route id.
44
+ * @param account - the account key.
45
+ * @returns whether the account's allowance is spent until its remembered reset.
46
+ */
47
+ isSpentUntilReset(provider: string, account: string): boolean;
48
+ /**
49
+ * The moment one account's allowance is known to reset, when it was recorded.
50
+ *
51
+ * @param provider - the provider route id.
52
+ * @param account - the account key.
53
+ * @returns the reset instant, or undefined when unknown or already passed.
54
+ */
55
+ resetAt(provider: string, account: string): number | undefined;
56
+ }
57
+ /** The one shared scheduler every route reads and writes. */
58
+ export declare const allowanceScheduler: AllowanceScheduler;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Cross-request memory of when each provider account's allowance resets.
3
+ *
4
+ * This is what lets one model id draw on the whole account pool without a wasted
5
+ * request: when the default account's allowance runs out, the moment it will refill is
6
+ * remembered, and until then the scheduler sends work straight to another account
7
+ * instead of trying the spent one, failing, and only then moving on. When the reset
8
+ * time passes the spent account is first choice again.
9
+ *
10
+ * The holder is shared by the route's reader (which learns reset times from usage
11
+ * reads) and its streamer (which consults them when picking an account), so the
12
+ * scheduler's view is exactly the last read's.
13
+ *
14
+ * @module dsh-plugin-subscription-oauth/auth/allowance-scheduler
15
+ */
16
+ /** In-memory, per-provider memory of account reset times. */
17
+ export class AllowanceScheduler {
18
+ byProvider = new Map();
19
+ /** One provider's account states, creating the map on first use. */
20
+ provider(provider) {
21
+ let map = this.byProvider.get(provider);
22
+ if (map === undefined) {
23
+ map = new Map();
24
+ this.byProvider.set(provider, map);
25
+ }
26
+ return map;
27
+ }
28
+ /**
29
+ * Record that one account's allowance resets at a given moment.
30
+ *
31
+ * @param provider - the provider route id.
32
+ * @param account - the account key.
33
+ * @param resetsAt - epoch milliseconds of the allowance reset, when known.
34
+ */
35
+ noteReset(provider, account, resetsAt) {
36
+ this.provider(provider).set(account, { ...resetsAt === undefined ? {} : { resetsAt }, learnedAt: Date.now() });
37
+ }
38
+ /**
39
+ * Whether one account's allowance is known to be spent now.
40
+ *
41
+ * True when the account was recorded with a reset time that is still in the future:
42
+ * its allowance is spent and will not refill until then, so a request should go to a
43
+ * different account rather than fail against this one.
44
+ *
45
+ * @param provider - the provider route id.
46
+ * @param account - the account key.
47
+ * @returns whether the account's allowance is spent until its remembered reset.
48
+ */
49
+ isSpentUntilReset(provider, account) {
50
+ const state = this.byProvider.get(provider)?.get(account);
51
+ if (state === undefined)
52
+ return false;
53
+ if (state.resetsAt === undefined)
54
+ return false;
55
+ return Date.now() < state.resetsAt;
56
+ }
57
+ /**
58
+ * The moment one account's allowance is known to reset, when it was recorded.
59
+ *
60
+ * @param provider - the provider route id.
61
+ * @param account - the account key.
62
+ * @returns the reset instant, or undefined when unknown or already passed.
63
+ */
64
+ resetAt(provider, account) {
65
+ const state = this.byProvider.get(provider)?.get(account);
66
+ if (state === undefined || state.resetsAt === undefined)
67
+ return undefined;
68
+ return Date.now() < state.resetsAt ? state.resetsAt : undefined;
69
+ }
70
+ }
71
+ /** The one shared scheduler every route reads and writes. */
72
+ export const allowanceScheduler = new AllowanceScheduler();
@@ -117,10 +117,12 @@ export declare function balanceOf(value: {
117
117
  * Format one wallet for the chip's compact line.
118
118
  *
119
119
  * A balance is money, not a percentage, so it renders as `¥6.89` rather than a meter;
120
- * the currency prefix keeps two currencies from being mistaken for one sum.
120
+ * the currency prefix keeps two currencies from being mistaken for one sum. The amount
121
+ * is rounded to two decimal places (a balance reads to the cent, and a figure like
122
+ * `1.1` for `1.10` would mislead) and always carries both places.
121
123
  *
122
124
  * @param wallet - the wallet to show.
123
- * @returns the short form, e.g. `¥6.89` or `+¥2` for a bonus wallet.
125
+ * @returns the short form, e.g. `¥6.89` or `+$2.00` for a bonus wallet.
124
126
  */
125
127
  export declare function formatBalance(wallet: ChipBalance): string;
126
128
  /**
@@ -157,6 +159,60 @@ export declare function pickWindow(windows: readonly UsageWindow[]): UsageWindow
157
159
  * @param usage - the accounts the route answered for.
158
160
  * @returns the reading, or undefined when the route stated no figure.
159
161
  */
162
+ /** What one account's allowance reduces to for the chip. */
163
+ export interface AccountReading {
164
+ /** Which account these figures belong to. */
165
+ account: string;
166
+ /** Plan name, when the provider stated one. */
167
+ plan?: string;
168
+ /** Every metered window, in the order the route stated them. */
169
+ windows: readonly UsageWindow[];
170
+ }
171
+ /**
172
+ * Reduce a route's per-account usage to one reading per account.
173
+ *
174
+ * A provider can hold several accounts (two Antigravity sign-ins, say), and the chip
175
+ * shows each one's allowance on its own line, the way the settings cards do — a merged
176
+ * figure would hide that one account is exhausted while the other is free.
177
+ *
178
+ * @param usage - every account the route answered for.
179
+ * @returns one reading per account that states a figure, in the route's order.
180
+ */
181
+ /**
182
+ * Reduce a route's per-account usage to one reading per account.
183
+ *
184
+ * A provider can hold several accounts (two Antigravity sign-ins, say). Every account's
185
+ * windows are kept as the route stated them — a percentage-metered quota and a credit
186
+ * balance (`credits`) alike — so the per-account panel shows the real figures, and the
187
+ * compact line's merge decides which to integrate.
188
+ *
189
+ * @param usage - every account the route answered for.
190
+ * @returns one reading per account that states any window, in the route's order.
191
+ */
192
+ export declare function accountReadingsOf(usage: readonly ProviderUsage[]): readonly AccountReading[];
193
+ /**
194
+ * Merge every account's percentage-metered windows into one figure per label.
195
+ *
196
+ * The chip's compact line integrates the whole pool: two accounts at 50 percent read as
197
+ * one window at 50 percent (the same allowance spent twice as wide), not two lines. A
198
+ * credit balance (`credits`) is money, not a share of a shared allowance, so it is not
199
+ * merged — it stays on the per-account panel, where it reads correctly. Only windows
200
+ * that state a `usedPercent` (a share of an allowance) merge.
201
+ *
202
+ * @param readings - one per account, from {@link accountReadingsOf}.
203
+ * @returns one merged window per percentage-metered label, most-spent first.
204
+ */
205
+ export declare function mergeAccountWindows(readings: readonly AccountReading[]): readonly UsageWindow[];
206
+ /**
207
+ * Reduce one route's usage to the single line the compact chip renders.
208
+ *
209
+ * Kept for the balance-free providers that still show one merged line.
210
+ *
211
+ * @param provider - the route id.
212
+ * @param label - the route's display name.
213
+ * @param usage - the accounts the route answered for.
214
+ * @returns the first account's reading, or undefined when none states a figure.
215
+ */
160
216
  export declare function readingOf(provider: string, label: string, usage: readonly ProviderUsage[]): ChipReading | undefined;
161
217
  /**
162
218
  * Format a window's reset the way the settings cards do.
@@ -82,14 +82,16 @@ export function balanceOf(value) {
82
82
  * Format one wallet for the chip's compact line.
83
83
  *
84
84
  * A balance is money, not a percentage, so it renders as `¥6.89` rather than a meter;
85
- * the currency prefix keeps two currencies from being mistaken for one sum.
85
+ * the currency prefix keeps two currencies from being mistaken for one sum. The amount
86
+ * is rounded to two decimal places (a balance reads to the cent, and a figure like
87
+ * `1.1` for `1.10` would mislead) and always carries both places.
86
88
  *
87
89
  * @param wallet - the wallet to show.
88
- * @returns the short form, e.g. `¥6.89` or `+¥2` for a bonus wallet.
90
+ * @returns the short form, e.g. `¥6.89` or `+$2.00` for a bonus wallet.
89
91
  */
90
92
  export function formatBalance(wallet) {
91
93
  const amount = Number.parseFloat(wallet.balance);
92
- const trimmed = Number.isFinite(amount) ? String(Math.round(amount * 100) / 100) : wallet.balance;
94
+ const trimmed = Number.isFinite(amount) ? amount.toFixed(2) : wallet.balance;
93
95
  const symbol = wallet.currency === 'CNY' ? '¥' : wallet.currency === 'USD' ? '$' : `${wallet.currency} `;
94
96
  return `${wallet.bonus ? '+' : ''}${symbol}${trimmed}`;
95
97
  }
@@ -142,12 +144,90 @@ export function pickWindow(windows) {
142
144
  return best;
143
145
  }
144
146
  /**
145
- * Reduce one route's usage to what the chip renders.
147
+ * Reduce a route's per-account usage to one reading per account.
146
148
  *
147
- * @param provider - the route the figures came from.
149
+ * A provider can hold several accounts (two Antigravity sign-ins, say), and the chip
150
+ * shows each one's allowance on its own line, the way the settings cards do — a merged
151
+ * figure would hide that one account is exhausted while the other is free.
152
+ *
153
+ * @param usage - every account the route answered for.
154
+ * @returns one reading per account that states a figure, in the route's order.
155
+ */
156
+ /**
157
+ * Reduce a route's per-account usage to one reading per account.
158
+ *
159
+ * A provider can hold several accounts (two Antigravity sign-ins, say). Every account's
160
+ * windows are kept as the route stated them — a percentage-metered quota and a credit
161
+ * balance (`credits`) alike — so the per-account panel shows the real figures, and the
162
+ * compact line's merge decides which to integrate.
163
+ *
164
+ * @param usage - every account the route answered for.
165
+ * @returns one reading per account that states any window, in the route's order.
166
+ */
167
+ export function accountReadingsOf(usage) {
168
+ const out = [];
169
+ for (const entry of usage) {
170
+ if (entry.windows.length === 0)
171
+ continue;
172
+ out.push({ account: entry.account, windows: entry.windows, ...entry.plan === undefined ? {} : { plan: entry.plan } });
173
+ }
174
+ return out;
175
+ }
176
+ /**
177
+ * Merge every account's percentage-metered windows into one figure per label.
178
+ *
179
+ * The chip's compact line integrates the whole pool: two accounts at 50 percent read as
180
+ * one window at 50 percent (the same allowance spent twice as wide), not two lines. A
181
+ * credit balance (`credits`) is money, not a share of a shared allowance, so it is not
182
+ * merged — it stays on the per-account panel, where it reads correctly. Only windows
183
+ * that state a `usedPercent` (a share of an allowance) merge.
184
+ *
185
+ * @param readings - one per account, from {@link accountReadingsOf}.
186
+ * @returns one merged window per percentage-metered label, most-spent first.
187
+ */
188
+ export function mergeAccountWindows(readings) {
189
+ const byLabel = new Map();
190
+ for (const reading of readings) {
191
+ for (const window of reading.windows) {
192
+ // Only a percentage-metered window merges. A credit balance (a bare used/limit
193
+ // pair) is money, not a share of a shared allowance, and averaging it across
194
+ // accounts would misread money as a quota.
195
+ if (window.usedPercent === undefined)
196
+ continue;
197
+ const list = byLabel.get(window.label) ?? [];
198
+ list.push(window);
199
+ byLabel.set(window.label, list);
200
+ }
201
+ }
202
+ const merged = [];
203
+ for (const [label, list] of byLabel) {
204
+ // Percentages average across accounts: the pool's spent share is the mean of each
205
+ // account's spent share (accounts share an allowance, they do not add to it).
206
+ const withPercent = list.filter((window) => window.usedPercent !== undefined);
207
+ if (withPercent.length === 0)
208
+ continue;
209
+ const usedPercent = withPercent.reduce((sum, window) => sum + (window.usedPercent ?? 0), 0) / withPercent.length;
210
+ // The pool's reset is the soonest account reset: the window refills when the first
211
+ // account's does.
212
+ const resets = list.map((window) => window.resetsAt).filter((at) => typeof at === 'number');
213
+ merged.push({
214
+ label,
215
+ usedPercent: Math.max(0, Math.min(100, usedPercent)),
216
+ ...resets.length === 0 ? {} : { resetsAt: Math.min(...resets) },
217
+ });
218
+ }
219
+ // Most-spent first, so the line leads with the window that will stop work soonest.
220
+ return merged.sort((left, right) => (right.usedPercent ?? 0) - (left.usedPercent ?? 0));
221
+ }
222
+ /**
223
+ * Reduce one route's usage to the single line the compact chip renders.
224
+ *
225
+ * Kept for the balance-free providers that still show one merged line.
226
+ *
227
+ * @param provider - the route id.
148
228
  * @param label - the route's display name.
149
229
  * @param usage - the accounts the route answered for.
150
- * @returns the reading, or undefined when the route stated no figure.
230
+ * @returns the first account's reading, or undefined when none states a figure.
151
231
  */
152
232
  export function readingOf(provider, label, usage) {
153
233
  for (const entry of usage) {
@@ -197,6 +277,7 @@ export function formatReset(at, now) {
197
277
  */
198
278
  export function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBalance, useProjection }) {
199
279
  const [reading, setReading] = useState(undefined);
280
+ const [accountReadings, setAccountReadings] = useState(undefined);
200
281
  const [balance, setBalance] = useState(undefined);
201
282
  const [ccBalance, setCcBalance] = useState(undefined);
202
283
  const [now, setNow] = useState(() => Date.now());
@@ -239,8 +320,7 @@ export function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBala
239
320
  const load = useCallback(async (target) => {
240
321
  try {
241
322
  const result = await call('usage', { provider: target });
242
- const label = result.status.providers.find((entry) => entry.provider === target)?.displayName ?? target;
243
- return readingOf(target, label, result.usage);
323
+ return accountReadingsOf(result.usage);
244
324
  }
245
325
  catch {
246
326
  // A failed read is not worth a chip of its own: the settings page is where a user
@@ -276,7 +356,7 @@ export function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBala
276
356
  // Not one of ours (or none selected): keep showing the last own reading.
277
357
  : lastOwn.current;
278
358
  if (target === undefined) {
279
- setReading(undefined);
359
+ setAccountReadings(undefined);
280
360
  return;
281
361
  }
282
362
  let live = true;
@@ -286,7 +366,7 @@ export function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBala
286
366
  if (!live || next === undefined)
287
367
  return;
288
368
  lastOwn.current = target;
289
- setReading(next);
369
+ setAccountReadings(next);
290
370
  });
291
371
  };
292
372
  refresh();
@@ -327,20 +407,22 @@ export function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBala
327
407
  setOpen((was) => !was);
328
408
  }, children: [_jsx("span", { className: "dsh-subscription-usage-chip-label", children: short }), balance.map((wallet) => (_jsx("span", { className: "dsh-subscription-usage-chip-cell", children: _jsx("span", { className: "dsh-subscription-usage-chip-value", children: formatBalance(wallet) }) }, `${wallet.currency}${wallet.bonus ? ':bonus' : ''}`)))] }), open && (_jsxs("span", { className: "dsh-subscription-usage-chip-panel", role: "status", children: [_jsx("span", { className: "dsh-subscription-usage-chip-full", children: t('usageChipFull', { provider: 'DeepSeek Account' }) }), balance.map((wallet) => (_jsx("span", { className: "dsh-subscription-usage-chip-meter", children: _jsxs("span", { className: "dsh-subscription-usage-chip-meterHead", children: [_jsx("span", { children: `${wallet.bonus ? 'Bonus · ' : ''}${wallet.currency}` }), _jsx("span", { className: "dsh-subscription-usage-chip-detail", children: formatBalance(wallet) })] }) }, `${wallet.currency}${wallet.bonus ? ':bonus' : ''}`)))] }))] }));
329
409
  }
330
- if (reading === undefined)
331
- return null;
332
- const percent = reading.window.usedPercent;
333
- if (percent === undefined)
410
+ if (accountReadings === undefined || accountReadings.length === 0)
334
411
  return null;
335
- const reset = formatReset(reading.window.resetsAt, now);
336
- const clamped = Math.max(0, Math.min(100, percent));
337
- const short = shortLabel(reading.provider, reading.label);
338
- return (_jsxs("span", { className: "dsh-subscription-usage-chip", children: [_jsx("style", { children: usageChipCss }), _jsxs("button", { type: "button", className: "dsh-subscription-usage-chip-trigger", "aria-expanded": open, title: t('usageChipTitle', { provider: reading.label }), onClick: (event) => {
412
+ const short = shortLabel(selected ?? '', selected ?? '');
413
+ // The compact line integrates the whole pool; the panel shows each account's own
414
+ // figures, so a merged glance hides nothing a per-account look cannot recover.
415
+ const merged = mergeAccountWindows(accountReadings);
416
+ return (_jsxs("span", { className: "dsh-subscription-usage-chip", children: [_jsx("style", { children: usageChipCss }), _jsxs("button", { type: "button", className: "dsh-subscription-usage-chip-trigger", "aria-expanded": open, title: t('usageChipTitle', { provider: selected ?? '' }), onClick: (event) => {
339
417
  // The document listener would otherwise close what this click just opened.
340
418
  event.stopPropagation();
341
419
  setOpen((was) => !was);
342
- }, children: [_jsx("span", { className: "dsh-subscription-usage-chip-label", children: short }), reading.windows.map((window) => (_jsxs("span", { className: "dsh-subscription-usage-chip-cell", children: [_jsx("span", { className: "dsh-subscription-usage-chip-window", children: shortWindow(window.label) }), _jsx("span", { className: "dsh-subscription-usage-chip-value", children: `${String(Math.round(window.usedPercent ?? 0))}%` })] }, window.label)))] }), open && (_jsxs("span", { className: "dsh-subscription-usage-chip-panel", role: "status", children: [_jsx("span", { className: "dsh-subscription-usage-chip-full", children: t('usageChipFull', { provider: reading.label }) }), reading.windows.map((window) => {
343
- const windowReset = formatReset(window.resetsAt, now);
344
- return (_jsxs("span", { className: "dsh-subscription-usage-chip-meter", children: [_jsxs("span", { className: "dsh-subscription-usage-chip-meterHead", children: [_jsx("span", { children: window.label }), _jsx("span", { className: "dsh-subscription-usage-chip-detail", children: `${String(Math.round(window.usedPercent ?? 0))}%${windowReset === undefined ? '' : ` · ${windowReset}`}` })] }), _jsx("span", { className: "dsh-subscription-usage-chip-track", "aria-hidden": "true", children: _jsx("span", { className: "dsh-subscription-usage-chip-fill", style: { width: `${String(Math.max(0, Math.min(100, window.usedPercent ?? 0)))}%` } }) })] }, window.label));
345
- }), reading.plan === undefined ? null : (_jsx("span", { className: "dsh-subscription-usage-chip-detail", children: reading.plan }))] }))] }));
420
+ }, children: [_jsx("span", { className: "dsh-subscription-usage-chip-label", children: short }), merged.map((window) => (_jsxs("span", { className: "dsh-subscription-usage-chip-cell", children: [_jsx("span", { className: "dsh-subscription-usage-chip-window", children: shortWindow(window.label) }), _jsx("span", { className: "dsh-subscription-usage-chip-value", children: `${String(Math.round(window.usedPercent ?? 0))}%` })] }, window.label)))] }), open && (_jsxs("span", { className: "dsh-subscription-usage-chip-panel", role: "status", children: [_jsx("span", { className: "dsh-subscription-usage-chip-full", children: t('usageChipFull', { provider: selected ?? '' }) }), accountReadings.map((account) => (_jsxs("span", { className: "dsh-subscription-usage-chip-account", children: [_jsx("span", { className: "dsh-subscription-usage-chip-meterHead", children: _jsx("span", { children: account.plan ?? account.account }) }), account.windows.map((window) => {
421
+ const windowReset = formatReset(window.resetsAt, now);
422
+ // A credit balance reads as money (`used`/`limit`), a quota as a percent.
423
+ const amount = window.usedPercent === undefined && window.used !== undefined && window.limit !== undefined
424
+ ? `${String(window.used)}/${String(window.limit)}`
425
+ : `${String(Math.round(window.usedPercent ?? 0))}%`;
426
+ return (_jsxs("span", { className: "dsh-subscription-usage-chip-meter", children: [_jsxs("span", { className: "dsh-subscription-usage-chip-meterHead", children: [_jsx("span", { children: window.label }), _jsx("span", { className: "dsh-subscription-usage-chip-detail", children: `${amount}${windowReset === undefined ? '' : ` · ${windowReset}`}` })] }), window.usedPercent !== undefined && (_jsx("span", { className: "dsh-subscription-usage-chip-track", "aria-hidden": "true", children: _jsx("span", { className: "dsh-subscription-usage-chip-fill", style: { width: `${String(Math.max(0, Math.min(100, window.usedPercent ?? 0)))}%` } }) }))] }, window.label));
427
+ })] }, account.account)))] }))] }));
346
428
  }
package/lib/client.js CHANGED
@@ -2300,14 +2300,16 @@ function balanceOf(value) {
2300
2300
  * Format one wallet for the chip's compact line.
2301
2301
  *
2302
2302
  * A balance is money, not a percentage, so it renders as `¥6.89` rather than a meter;
2303
- * the currency prefix keeps two currencies from being mistaken for one sum.
2303
+ * the currency prefix keeps two currencies from being mistaken for one sum. The amount
2304
+ * is rounded to two decimal places (a balance reads to the cent, and a figure like
2305
+ * `1.1` for `1.10` would mislead) and always carries both places.
2304
2306
  *
2305
2307
  * @param wallet - the wallet to show.
2306
- * @returns the short form, e.g. `¥6.89` or `+¥2` for a bonus wallet.
2308
+ * @returns the short form, e.g. `¥6.89` or `+$2.00` for a bonus wallet.
2307
2309
  */
2308
2310
  function formatBalance(wallet) {
2309
2311
  const amount = Number.parseFloat(wallet.balance);
2310
- const trimmed = Number.isFinite(amount) ? String(Math.round(amount * 100) / 100) : wallet.balance;
2312
+ const trimmed = Number.isFinite(amount) ? amount.toFixed(2) : wallet.balance;
2311
2313
  const symbol = wallet.currency === "CNY" ? "¥" : wallet.currency === "USD" ? "$" : `${wallet.currency} `;
2312
2314
  return `${wallet.bonus ? "+" : ""}${symbol}${trimmed}`;
2313
2315
  }
@@ -2336,43 +2338,71 @@ function shortWindow(label) {
2336
2338
  return label.length <= 4 ? label : label.slice(0, 4);
2337
2339
  }
2338
2340
  /**
2339
- * Pick the window a single line should show.
2341
+ * Reduce a route's per-account usage to one reading per account.
2340
2342
  *
2341
- * A plan can meter several windows at once (a rolling five-hour one and a weekly one).
2342
- * The most constrained is the one that will stop work soonest, so it earns the space;
2343
- * ties fall to the earlier window, which is the order the providers state them in.
2343
+ * A provider can hold several accounts (two Antigravity sign-ins, say), and the chip
2344
+ * shows each one's allowance on its own line, the way the settings cards do — a merged
2345
+ * figure would hide that one account is exhausted while the other is free.
2344
2346
  *
2345
- * @param windows - the route's metered windows.
2346
- * @returns the window to show, or undefined when none states a figure.
2347
+ * @param usage - every account the route answered for.
2348
+ * @returns one reading per account that states a figure, in the route's order.
2347
2349
  */
2348
- function pickWindow(windows) {
2349
- let best;
2350
- for (const window$1 of windows) {
2351
- if (window$1.usedPercent === void 0) continue;
2352
- if (best?.usedPercent === void 0 || window$1.usedPercent > best.usedPercent) best = window$1;
2350
+ /**
2351
+ * Reduce a route's per-account usage to one reading per account.
2352
+ *
2353
+ * A provider can hold several accounts (two Antigravity sign-ins, say). Every account's
2354
+ * windows are kept as the route stated them — a percentage-metered quota and a credit
2355
+ * balance (`credits`) alike — so the per-account panel shows the real figures, and the
2356
+ * compact line's merge decides which to integrate.
2357
+ *
2358
+ * @param usage - every account the route answered for.
2359
+ * @returns one reading per account that states any window, in the route's order.
2360
+ */
2361
+ function accountReadingsOf(usage) {
2362
+ const out = [];
2363
+ for (const entry of usage) {
2364
+ if (entry.windows.length === 0) continue;
2365
+ out.push({
2366
+ account: entry.account,
2367
+ windows: entry.windows,
2368
+ ...entry.plan === void 0 ? {} : { plan: entry.plan }
2369
+ });
2353
2370
  }
2354
- return best;
2371
+ return out;
2355
2372
  }
2356
2373
  /**
2357
- * Reduce one route's usage to what the chip renders.
2374
+ * Merge every account's percentage-metered windows into one figure per label.
2375
+ *
2376
+ * The chip's compact line integrates the whole pool: two accounts at 50 percent read as
2377
+ * one window at 50 percent (the same allowance spent twice as wide), not two lines. A
2378
+ * credit balance (`credits`) is money, not a share of a shared allowance, so it is not
2379
+ * merged — it stays on the per-account panel, where it reads correctly. Only windows
2380
+ * that state a `usedPercent` (a share of an allowance) merge.
2358
2381
  *
2359
- * @param provider - the route the figures came from.
2360
- * @param label - the route's display name.
2361
- * @param usage - the accounts the route answered for.
2362
- * @returns the reading, or undefined when the route stated no figure.
2382
+ * @param readings - one per account, from {@link accountReadingsOf}.
2383
+ * @returns one merged window per percentage-metered label, most-spent first.
2363
2384
  */
2364
- function readingOf(provider, label, usage) {
2365
- for (const entry of usage) {
2366
- const window$1 = pickWindow(entry.windows);
2367
- if (window$1 === void 0) continue;
2368
- return {
2369
- provider,
2385
+ function mergeAccountWindows(readings) {
2386
+ const byLabel = /* @__PURE__ */ new Map();
2387
+ for (const reading of readings) for (const window$1 of reading.windows) {
2388
+ if (window$1.usedPercent === void 0) continue;
2389
+ const list = byLabel.get(window$1.label) ?? [];
2390
+ list.push(window$1);
2391
+ byLabel.set(window$1.label, list);
2392
+ }
2393
+ const merged = [];
2394
+ for (const [label, list] of byLabel) {
2395
+ const withPercent = list.filter((window$1) => window$1.usedPercent !== void 0);
2396
+ if (withPercent.length === 0) continue;
2397
+ const usedPercent = withPercent.reduce((sum, window$1) => sum + (window$1.usedPercent ?? 0), 0) / withPercent.length;
2398
+ const resets = list.map((window$1) => window$1.resetsAt).filter((at) => typeof at === "number");
2399
+ merged.push({
2370
2400
  label,
2371
- windows: entry.windows.filter((candidate) => candidate.usedPercent !== void 0),
2372
- window: window$1,
2373
- ...entry.plan === void 0 ? {} : { plan: entry.plan }
2374
- };
2401
+ usedPercent: Math.max(0, Math.min(100, usedPercent)),
2402
+ ...resets.length === 0 ? {} : { resetsAt: Math.min(...resets) }
2403
+ });
2375
2404
  }
2405
+ return merged.sort((left, right) => (right.usedPercent ?? 0) - (left.usedPercent ?? 0));
2376
2406
  }
2377
2407
  /**
2378
2408
  * Format a window's reset the way the settings cards do.
@@ -2402,6 +2432,7 @@ function formatReset(at, now) {
2402
2432
  */
2403
2433
  function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBalance, useProjection }) {
2404
2434
  const [reading, setReading] = (0, react.useState)(void 0);
2435
+ const [accountReadings, setAccountReadings] = (0, react.useState)(void 0);
2405
2436
  const [balance, setBalance] = (0, react.useState)(void 0);
2406
2437
  const [ccBalance, setCcBalance] = (0, react.useState)(void 0);
2407
2438
  const [now, setNow] = (0, react.useState)(() => Date.now());
@@ -2438,8 +2469,7 @@ function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBalance, us
2438
2469
  ]);
2439
2470
  const load = (0, react.useCallback)(async (target) => {
2440
2471
  try {
2441
- const result = await call("usage", { provider: target });
2442
- return readingOf(target, result.status.providers.find((entry) => entry.provider === target)?.displayName ?? target, result.usage);
2472
+ return accountReadingsOf((await call("usage", { provider: target })).usage);
2443
2473
  } catch {
2444
2474
  return;
2445
2475
  }
@@ -2465,7 +2495,7 @@ function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBalance, us
2465
2495
  (0, react.useEffect)(() => {
2466
2496
  const target = selected !== void 0 && isOwnProvider(selected) ? selected : lastOwn.current;
2467
2497
  if (target === void 0) {
2468
- setReading(void 0);
2498
+ setAccountReadings(void 0);
2469
2499
  return;
2470
2500
  }
2471
2501
  let live = true;
@@ -2474,7 +2504,7 @@ function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBalance, us
2474
2504
  load(target).then((next) => {
2475
2505
  if (!live || next === void 0) return;
2476
2506
  lastOwn.current = target;
2477
- setReading(next);
2507
+ setAccountReadings(next);
2478
2508
  });
2479
2509
  };
2480
2510
  refresh();
@@ -2592,10 +2622,9 @@ function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBalance, us
2592
2622
  ]
2593
2623
  });
2594
2624
  }
2595
- if (reading === void 0) return null;
2596
- if (reading.window.usedPercent === void 0) return null;
2597
- formatReset(reading.window.resetsAt, now);
2598
- const short = shortLabel(reading.provider, reading.label);
2625
+ if (accountReadings === void 0 || accountReadings.length === 0) return null;
2626
+ const short = shortLabel(selected ?? "", selected ?? "");
2627
+ const merged = mergeAccountWindows(accountReadings);
2599
2628
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2600
2629
  className: "dsh-subscription-usage-chip",
2601
2630
  children: [
@@ -2604,7 +2633,7 @@ function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBalance, us
2604
2633
  type: "button",
2605
2634
  className: "dsh-subscription-usage-chip-trigger",
2606
2635
  "aria-expanded": open,
2607
- title: t("usageChipTitle", { provider: reading.label }),
2636
+ title: t("usageChipTitle", { provider: selected ?? "" }),
2608
2637
  onClick: (event) => {
2609
2638
  event.stopPropagation();
2610
2639
  setOpen((was) => !was);
@@ -2612,7 +2641,7 @@ function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBalance, us
2612
2641
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2613
2642
  className: "dsh-subscription-usage-chip-label",
2614
2643
  children: short
2615
- }), reading.windows.map((window$1) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2644
+ }), merged.map((window$1) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2616
2645
  className: "dsh-subscription-usage-chip-cell",
2617
2646
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2618
2647
  className: "dsh-subscription-usage-chip-window",
@@ -2626,22 +2655,26 @@ function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBalance, us
2626
2655
  open && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2627
2656
  className: "dsh-subscription-usage-chip-panel",
2628
2657
  role: "status",
2629
- children: [
2630
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2631
- className: "dsh-subscription-usage-chip-full",
2632
- children: t("usageChipFull", { provider: reading.label })
2633
- }),
2634
- reading.windows.map((window$1) => {
2658
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2659
+ className: "dsh-subscription-usage-chip-full",
2660
+ children: t("usageChipFull", { provider: selected ?? "" })
2661
+ }), accountReadings.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2662
+ className: "dsh-subscription-usage-chip-account",
2663
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2664
+ className: "dsh-subscription-usage-chip-meterHead",
2665
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: account.plan ?? account.account })
2666
+ }), account.windows.map((window$1) => {
2635
2667
  const windowReset = formatReset(window$1.resetsAt, now);
2668
+ const amount = window$1.usedPercent === void 0 && window$1.used !== void 0 && window$1.limit !== void 0 ? `${String(window$1.used)}/${String(window$1.limit)}` : `${String(Math.round(window$1.usedPercent ?? 0))}%`;
2636
2669
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2637
2670
  className: "dsh-subscription-usage-chip-meter",
2638
2671
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2639
2672
  className: "dsh-subscription-usage-chip-meterHead",
2640
2673
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: window$1.label }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2641
2674
  className: "dsh-subscription-usage-chip-detail",
2642
- children: `${String(Math.round(window$1.usedPercent ?? 0))}%${windowReset === void 0 ? "" : ` · ${windowReset}`}`
2675
+ children: `${amount}${windowReset === void 0 ? "" : ` · ${windowReset}`}`
2643
2676
  })]
2644
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2677
+ }), window$1.usedPercent !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2645
2678
  className: "dsh-subscription-usage-chip-track",
2646
2679
  "aria-hidden": "true",
2647
2680
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -2650,12 +2683,8 @@ function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBalance, us
2650
2683
  })
2651
2684
  })]
2652
2685
  }, window$1.label);
2653
- }),
2654
- reading.plan === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2655
- className: "dsh-subscription-usage-chip-detail",
2656
- children: reading.plan
2657
- })
2658
- ]
2686
+ })]
2687
+ }, account.account))]
2659
2688
  })
2660
2689
  ]
2661
2690
  });