1/**
2 * Selection and pace maths for the provider limits view, shared by web and
3 * mobile so both agree on which providers show, what "ahead of pace" means,
4 * and how a reset is phrased.
5 *
6 * @module usageLimits
7 */
8import {
9 type EnvironmentId,
10 type UsageLimitsReport,
11 type ProviderInstanceId,
12 type ProviderConsumeResetCreditInput,
13 type ServerProviderSlashCommand,
14 isProviderAvailable,
15 type ServerProvider,
16 type ServerProviderUsageLimits,
17 type ServerProviderUsageWindow,
18 type UsageLimitSourceSnapshots,
19} from "@t3tools/contracts";
20
21import * as DateTime from "effect/DateTime";
22
23const MINUTE = 60_000;
24const HOUR = 60 * MINUTE;
25const DAY = 24 * HOUR;
26
27export const CHATGPT_USAGE_URL = "https://chatgpt.com/#settings/Usage";
28
29export function usesChatGptSharing(provider: ServerProvider | null | undefined): boolean {
30 return provider?.auth.status === "authenticated" && provider.auth.subscriptionSharing === true;
31}
32
33export const CURSOR_USAGE_WINDOWS = [
34 {
35 id: "totalPercentUsed",
36 label: "Overall",
37 description: "Combined usage across both allowances, not a third quota.",
38 },
39 {
40 id: "autoPercentUsed",
41 label: "Cursor Models",
42 description: "Grok and Composer use this first. Auto can use either pool.",
43 },
44 {
45 id: "apiPercentUsed",
46 label: "Other Models",
47 description: "Claude, GPT, and Gemini use this pool. Grok and Composer fall back here.",
48 },
49] as const;
50
51export function cursorUsageWindowDetails(id: string) {
52 return CURSOR_USAGE_WINDOWS.find((window) => window.id === id);
53}
54
55export function isUsageLimitWindowVisible(
56 driver: ServerProvider["driver"],
57 window: Pick<ServerProviderUsageWindow, "id" | "label">,
58) {
59 return driver !== "claudeAgent" || !/fable/i.test(`${window.id} ${window.label}`);
60}
61
62function cursorUsageWindowRank(id: string): number {
63 const rank = CURSOR_USAGE_WINDOWS.findIndex((window) => window.id === id);
64 return rank < 0 ? CURSOR_USAGE_WINDOWS.length : rank;
65}
66
67/**
68 * Providers that belong on the Limits view: enabled, installed, and one whose
69 * driver reports subscription usage at all. A driver with no notion of usage
70 * never sets `usageLimits`, so it has no row rather than an empty one.
71 */
72export function providersWithLimits(
73 providers: readonly ServerProvider[],
74): readonly ServerProvider[] {
75 return providers.filter(
76 (provider) =>
77 provider.enabled &&
78 provider.installed &&
79 isProviderAvailable(provider) &&
80 provider.usageLimits !== undefined,
81 );
82}
83
84export interface LimitPresentation {
85 readonly entry: { readonly target: { readonly label: string } };
86 readonly serverConfig: {
87 readonly providers?: readonly ServerProvider[] | undefined;
88 readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined;
89 } | null;
90}
91
92export type LimitPresentations = ReadonlyMap<EnvironmentId, LimitPresentation>;
93
94/** One destination per service, even when several accounts or environments use it. */
95export function collectExternalUsageLinks(presentations: LimitPresentations) {
96 const links = new Map<
97 string,
98 {
99 readonly label: string;
100 readonly url: string;
101 readonly message: string | undefined;
102 readonly accounts: readonly string[];
103 }
104 >();
105 for (const presentation of presentations.values()) {
106 for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) {
107 const external = provider.usageLimits?.externalUsage;
108 if (external && provider.auth.status === "authenticated") {
109 const account = `${provider.displayName ?? provider.instanceId} on ${presentation.entry.target.label}`;
110 links.set(external.url, {
111 ...external,
112 message: provider.usageLimits?.unavailable?.message,
113 accounts: [...new Set([...(links.get(external.url)?.accounts ?? []), account])],
114 });
115 }
116 }
117 }
118 return [...links.values()];
119}
120
121/** Prefer the reported email; use an identical credential when no email is available. */
122function accountKey(
123 driver: ServerProvider["driver"],
124 email: string | undefined,
125 limits?: ServerProviderUsageLimits,
126): string | null {
127 const normalizedEmail = email?.trim().toLowerCase();
128 if (normalizedEmail) return `${driver}:${normalizedEmail}`;
129 return limits?.credentialFingerprint
130 ? `${driver}:credential:${limits.credentialFingerprint}`
131 : null;
132}
133
134interface AccountReport {
135 readonly driver: ServerProvider["driver"];
136 readonly email?: string | undefined;
137 readonly workspaceId?: string | undefined;
138 readonly usageLimits?: ServerProviderUsageLimits | undefined;
139}
140
141const nativeReport = (provider: ServerProvider): AccountReport => ({
142 driver: provider.driver,
143 email: provider.auth.email,
144 workspaceId: provider.auth.workspaceId,
145 usageLimits: provider.usageLimits,
146});
147
148/**
149 * Keys each report by the account it draws on. One email can belong to
150 * several workspaces, each with its own quota, so a report that names its
151 * workspace is keyed by it too. One that does not (a hub, an older server)
152 * joins the only workspace known for its email, and gets no key when there
153 * are several, since it cannot say which one it read. Pass every enabled
154 * login, read or not, so an unreadable workspace still counts as one.
155 */
156function accountKeyResolver(reports: Iterable<AccountReport>) {
157 const workspaces = new Map<string, Set<string>>();
158 for (const report of reports) {
159 const key = accountKey(report.driver, report.email, report.usageLimits);
160 if (key && report.workspaceId) {
161 workspaces.set(key, (workspaces.get(key) ?? new Set()).add(report.workspaceId));
162 }
163 }
164 return (report: AccountReport): string | null => {
165 const key = accountKey(report.driver, report.email, report.usageLimits);
166 if (!key) return null;
167 if (report.workspaceId) return `${key}:${report.workspaceId}`;
168 const known = workspaces.get(key);
169 if (!known) return key;
170 return known.size === 1 ? `${key}:${[...known][0]}` : null;
171 };
172}
173
174/**
175 * One subscription account as the pooled views see it, whichever way it was
176 * reported. Matching emails or credentials across environments name one
177 * account, unless they name different workspaces. Its quota is one bucket, so
178 * counting it twice would misstate what is left.
179 */
180export interface LimitAccount {
181 readonly key: string;
182 readonly driver: ServerProvider["driver"];
183 /** The instance's configured name, which is not sensitive; null for hub accounts. */
184 readonly displayName: string | null;
185 readonly email: string | undefined;
186 readonly plan: string | undefined;
187 readonly accentColor: string | undefined;
188 /** Environments the account is signed in on; empty when only a hub reports it. */
189 readonly environments: ReadonlyArray<{
190 readonly environmentId: EnvironmentId;
191 readonly label: string;
192 }>;
193 /** The hub that reported it, when no environment has it natively. */
194 readonly sourceLabel: string | null;
195 /** Where the displayed reset credit can be redeemed. */
196 readonly redeem: {
197 readonly environmentId: EnvironmentId;
198 readonly input: ProviderConsumeResetCreditInput;
199 } | null;
200 readonly limits: ServerProviderUsageLimits;
201}
202
203/**
204 * Every account with usable windows across the connected environments, one
205 * entry per distinct account. The freshest usable reads supply windows and
206 * credits; native instances supply names and environment labels.
207 */
208export function collectLimitAccounts(
209 presentations: LimitPresentations,
210 options?: { readonly includeUnavailable: boolean },
211): readonly LimitAccount[] {
212 const accounts = new Map<string, LimitAccount>();
213 const creditSources = new Map<string, LimitAccount>();
214 const hubRedeems = new Map<string, LimitAccount>();
215 const merge = (key: string, next: LimitAccount) => {
216 // Redeeming through a hub also clears the routing cooldown that hub holds
217 // for the account. Redeeming natively against the same subscription resets
218 // it upstream but leaves the hub refusing to route to the account until
219 // its own cooldown expires, so a hub target wins the redemption outright
220 // while the displayed balance still follows the freshest read.
221 const previousHub = hubRedeems.get(key);
222 if (
223 next.redeem &&
224 "sourceId" in next.redeem.input &&
225 (!previousHub || Date.parse(next.limits.checkedAt) > Date.parse(previousHub.limits.checkedAt))
226 ) {
227 hubRedeems.set(key, next);
228 }
229 const previousCredit = creditSources.get(key);
230 if (
231 next.limits.resetCredits &&
232 (!previousCredit ||
233 Date.parse(next.limits.checkedAt) > Date.parse(previousCredit.limits.checkedAt))
234 ) {
235 creditSources.set(key, next);
236 }
237 const previous = accounts.get(key);
238 if (!previous) {
239 accounts.set(key, next);
240 return;
241 }
242 const previousUsable = limitsNotice(previous.limits) === null;
243 const nextUsable = limitsNotice(next.limits) === null;
244 const fresher =
245 previousUsable === nextUsable
246 ? Date.parse(next.limits.checkedAt) > Date.parse(previous.limits.checkedAt)
247 : nextUsable;
248 // Two instances on one machine sharing an account still name it once.
249 const environments = [
250 ...previous.environments,
251 ...next.environments.filter(
252 (candidate) =>
253 !previous.environments.some((seen) => seen.environmentId === candidate.environmentId),
254 ),
255 ];
256 const winner = fresher ? next : previous;
257 // Credits and their redemption target travel together. A failed credit
258 // probe must not erase a successful read from another environment.
259 const creditSource = creditSources.get(key);
260 accounts.set(key, {
261 ...previous,
262 displayName: previous.displayName ?? next.displayName,
263 plan: previous.plan ?? next.plan,
264 accentColor: previous.accentColor ?? next.accentColor,
265 environments,
266 // A hub only names the account when no environment has it natively.
267 sourceLabel: environments.length > 0 ? null : (previous.sourceLabel ?? next.sourceLabel),
268 redeem:
269 hubRedeems.get(key)?.redeem ??
270 (creditSource ? creditSource.redeem : (winner.redeem ?? previous.redeem ?? next.redeem)),
271 limits: {
272 ...winner.limits,
273 ...(creditSource?.limits.resetCredits
274 ? { resetCredits: creditSource.limits.resetCredits }
275 : { resetCredits: undefined }),
276 },
277 });
278 };
279 const resolveKey = accountKeyResolver(
280 [...presentations.values()].flatMap((presentation) => [
281 ...(presentation.serverConfig?.providers ?? [])
282 .filter((provider) => provider.enabled)
283 .map(nativeReport),
284 ...(presentation.serverConfig?.usageLimitSources ?? []).flatMap((source) => source.accounts),
285 ]),
286 );
287 for (const [environmentId, presentation] of presentations) {
288 const label = presentation.entry.target.label;
289 const providers = options?.includeUnavailable
290 ? (presentation.serverConfig?.providers ?? []).filter(
291 (provider) =>
292 provider.enabled && provider.installed && provider.usageLimits !== undefined,
293 )
294 : providersWithLimits(presentation.serverConfig?.providers ?? []);
295 for (const provider of providers) {
296 if (
297 !provider.usageLimits ||
298 provider.usageLimits.unavailable?.reason === "unsupported" ||
299 (!options?.includeUnavailable && limitsNotice(provider.usageLimits) !== null)
300 )
301 continue;
302 merge(resolveKey(nativeReport(provider)) ?? `${environmentId}:${provider.instanceId}`, {
303 key: `${environmentId}:${provider.instanceId}`,
304 driver: provider.driver,
305 displayName: provider.displayName?.trim() || null,
306 email: provider.auth.email,
307 plan: provider.auth.label,
308 accentColor: provider.accentColor,
309 environments: [{ environmentId, label }],
310 sourceLabel: null,
311 redeem: { environmentId, input: { instanceId: provider.instanceId } },
312 limits: provider.usageLimits,
313 });
314 }
315 }
316 // Every hub account, including those a native instance also knows: the hub
317 // may hold a fresher read of the same subscription, and the merge above
318 // keeps the redeem target consistent with whichever snapshot wins.
319 const labelEnvironment = presentations.size > 1;
320 for (const [environmentId, presentation] of presentations) {
321 for (const source of presentation.serverConfig?.usageLimitSources ?? []) {
322 const sourceLabel = labelEnvironment
323 ? `${presentation.entry.target.label} · ${source.label}`
324 : source.label;
325 for (const account of source.accounts) {
326 if (
327 account.usageLimits.unavailable?.reason === "unsupported" ||
328 (!options?.includeUnavailable && limitsNotice(account.usageLimits) !== null)
329 )
330 continue;
331 const key = `${source.id}:${account.id}${account.workspaceId ? `:${account.workspaceId}` : ""}`;
332 merge(resolveKey(account) ?? key, {
333 key,
334 driver: account.driver,
335 displayName: account.email ? null : account.id.replace(/\.json$/i, ""),
336 email: account.email,
337 plan: account.plan,
338 accentColor: undefined,
339 environments: [],
340 sourceLabel,
341 redeem: account.usageLimits.resetCredits?.nextCreditId
342 ? {
343 environmentId,
344 input: {
345 sourceId: source.id,
346 accountId: account.id,
347 creditId: account.usageLimits.resetCredits.nextCreditId,
348 },
349 }
350 : null,
351 limits: account.usageLimits,
352 });
353 }
354 }
355 }
356 return [...accounts.values()];
357}
358
359/**
360 * What the pooled views cannot draw as a bar: a hub that failed to read, a
361 * provider whose probe failed. Accounts that can never report (API keys)
362 * are left out; there is nothing for the user to act on. The environment
363 * is named only when more than one is connected.
364 */
365export function collectLimitNotices(presentations: LimitPresentations): readonly string[] {
366 const label = (environmentLabel: string, subject: string) =>
367 presentations.size > 1 ? `${environmentLabel} · ${subject}` : subject;
368 const notices: string[] = [];
369 for (const presentation of presentations.values()) {
370 const environmentLabel = presentation.entry.target.label;
371 for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) {
372 // An account that can never report (API key) is left out; one that
373 // failed, or reported nothing at all, is worth a line.
374 if (provider.usageLimits?.unavailable?.reason === "unsupported") continue;
375 const notice = provider.usageLimits ? limitsNotice(provider.usageLimits) : null;
376 const name = provider.displayName?.trim() || String(provider.driver);
377 if (notice) notices.push(`${label(environmentLabel, name)}: ${notice}`);
378 }
379 for (const source of presentation.serverConfig?.usageLimitSources ?? []) {
380 if (source.error) {
381 notices.push(`${label(environmentLabel, source.label)}: ${source.error}`);
382 } else if (source.accounts.length === 0) {
383 notices.push(`${label(environmentLabel, source.label)}: No accounts reported.`);
384 }
385 }
386 }
387 return notices;
388}
389
390export interface LimitPoolMember {
391 readonly account: LimitAccount;
392 readonly window: ServerProviderUsageWindow;
393}
394
395/**
396 * One window id across every account that reports it: the pooled share left,
397 * pace against the clock, and the resets in the order they will land, each
398 * with the share of the pool it hands back.
399 */
400export interface LimitPoolWindow {
401 readonly id: string;
402 readonly kind: ServerProviderUsageWindow["kind"];
403 readonly label: string;
404 readonly members: readonly LimitPoolMember[];
405 /** Fixed account positions across rows; a null window leaves a gap. */
406 readonly columns: ReadonlyArray<{
407 readonly account: LimitAccount;
408 readonly window: ServerProviderUsageWindow | null;
409 }>;
410 readonly remainingPercent: number;
411 readonly usedPercent: number;
412 readonly pace: LimitPace | null;
413 readonly resets: ReadonlyArray<{
414 readonly member: LimitPoolMember;
415 readonly at: number;
416 /** Points of the pool the reset restores: the member's used share over the member count. */
417 readonly restoresPercent: number;
418 }>;
419}
420
421export interface LimitPool {
422 readonly driver: ServerProvider["driver"];
423 readonly accounts: readonly LimitAccount[];
424 readonly windows: readonly LimitPoolWindow[];
425}
426
427/** Show Cursor's two usable pools instead of a combined percentage when both are available. */
428export function displayLimitWindows(pool: LimitPool) {
429 if (pool.driver === "antigravity") {
430 // Keep each model group's five-hour and weekly buckets next to one another.
431 return [...pool.windows].sort(
432 (left, right) =>
433 Number(!left.id.startsWith("gemini-")) - Number(!right.id.startsWith("gemini-")) ||
434 left.id
435 .replace(/-(5h|weekly|quota)$/, "")
436 .localeCompare(right.id.replace(/-(5h|weekly|quota)$/, "")) ||
437 WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind],
438 );
439 }
440 if (pool.driver !== "cursor") return pool.windows;
441 const hasAuto = pool.windows.some((window) => window.id === "autoPercentUsed");
442 const hasApi = pool.windows.some((window) => window.id === "apiPercentUsed");
443 const hasBothPools = hasAuto && hasApi;
444 return pool.windows
445 .filter((window) => !hasBothPools || window.id !== "totalPercentUsed")
446 .sort((left, right) => cursorUsageWindowRank(left.id) - cursorUsageWindowRank(right.id));
447}
448
449const WINDOW_KIND_ORDER: Record<ServerProviderUsageWindow["kind"], number> = {
450 session: 0,
451 weekly: 1,
452 monthly: 2,
453 other: 3,
454};
455
456/**
457 * Accounts grouped by driver, each with its windows pooled by kind and id.
458 * Window ids are stable per provider, so a hub row and a native row for the
459 * same window land in the same pool; the kind is part of the key because
460 * Codex's `primary` is a position, not a duration (five hours on paid plans,
461 * a month on Free/Go), and a monthly allowance must not average into a
462 * five-hour pool. Pools order by kind, then first appearance.
463 *
464 * Accounts and columns share the session reset order, soonest first. When
465 * no account reports a session window, use the first window by kind instead.
466 * Missing reset times sort last, with account names and keys breaking ties.
467 * Each window's reset list still follows its own clock.
468 */
469export function collectLimitPools(
470 accounts: readonly LimitAccount[],
471 now: number,
472): readonly LimitPool[] {
473 const byDriver = new Map<ServerProvider["driver"], LimitAccount[]>();
474 for (const account of accounts) {
475 const list = byDriver.get(account.driver);
476 if (list) list.push(account);
477 else byDriver.set(account.driver, [account]);
478 }
479 return [...byDriver].map(([driver, members]) => {
480 const orderWindow = members
481 .flatMap((account) => account.limits.windows)
482 .sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind])[0];
483 const orderReset = (account: LimitAccount) => {
484 const window = account.limits.windows.find(
485 (window) => orderWindow && poolKey(driver, window) === poolKey(driver, orderWindow),
486 );
487 return (window ? resetMillis(window) : null) ?? Number.POSITIVE_INFINITY;
488 };
489 const sorted = [...members].sort(
490 (left, right) =>
491 orderReset(left) - orderReset(right) ||
492 accountSortName(left).localeCompare(accountSortName(right)) ||
493 left.key.localeCompare(right.key),
494 );
495 return { driver, accounts: sorted, windows: poolWindows(sorted, now) };
496 });
497}
498
499function accountSortName(account: LimitAccount): string {
500 return (account.displayName ?? account.email ?? account.key).toLowerCase();
501}
502
503/**
504 * Codex ids are slots, not allowances: a weekly-only plan reports its weekly
505 * window as `primary`, other plans as `secondary`. Its windows pool by kind;
506 * other drivers' ids name distinct allowances.
507 */
508function poolKey(driver: ServerProvider["driver"], window: ServerProviderUsageWindow): string {
509 return driver === "codex" ? window.kind : `${window.kind}:${window.id}`;
510}
511
512function poolWindows(accounts: readonly LimitAccount[], now: number): readonly LimitPoolWindow[] {
513 const byKey = new Map<string, LimitPoolMember[]>();
514 for (const account of accounts) {
515 for (const window of account.limits.windows) {
516 const pooled = poolKey(account.driver, window);
517 // An account with two windows of one kind keeps the second apart.
518 const key = byKey.get(pooled)?.some((member) => member.account === account)
519 ? `${window.kind}:${window.id}`
520 : pooled;
521 const list = byKey.get(key);
522 if (list) list.push({ account, window });
523 else byKey.set(key, [{ account, window }]);
524 }
525 }
526 const pools = [...byKey].map(([key, members]): LimitPoolWindow => {
527 const memberByAccount = new Map(members.map((member) => [member.account.key, member]));
528 const first = members[0]!.window;
529 // Pooled Codex slots take the pool's own key, so the id stays the same
530 // whichever account sorts first.
531 const id = members[0]!.account.driver === "codex" ? key : first.id;
532 const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length;
533 // Pace compares spend against the clock, so it is judged only over the
534 // members that have a clock; a window with no reset would otherwise
535 // count as spend with no time elapsed and skew the verdict.
536 const timed = members.flatMap((m) => {
537 const share = elapsedShare(m.window, now);
538 return share === null ? [] : [{ used: m.window.usedPercent, elapsed: share }];
539 });
540 const timedUsed = timed.reduce((sum, t) => sum + t.used, 0) / timed.length;
541 const meanElapsed =
542 timed.length > 0 ? timed.reduce((sum, t) => sum + t.elapsed, 0) / timed.length : null;
543 const resets = members
544 .flatMap((member) => {
545 const at = resetMillis(member.window);
546 return at === null
547 ? []
548 : [
549 {
550 member,
551 at,
552 restoresPercent: Math.round(member.window.usedPercent / members.length),
553 },
554 ];
555 })
556 .sort((left, right) => left.at - right.at);
557 return {
558 id,
559 kind: first.kind,
560 label: first.label,
561 members,
562 columns: accounts.map(
563 (account) => memberByAccount.get(account.key) ?? { account, window: null },
564 ),
565 usedPercent: Math.round(usedPercent),
566 remainingPercent: Math.round(100 - usedPercent),
567 pace: meanElapsed === null ? null : paceOfShares(timedUsed, meanElapsed),
568 resets,
569 };
570 });
571 return pools.sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind]);
572}
573
574/** The one-line status under a provider heading when there are no bars to draw. */
575export function limitsNotice(limits: ServerProviderUsageLimits): string | null {
576 if (limits.unavailable?.reason === "unsupported") {
577 return limits.unavailable.message ?? "This account has no subscription limits.";
578 }
579 if (limits.unavailable?.reason === "probeFailed") {
580 return limits.unavailable.message ?? "Could not read limits.";
581 }
582 return limits.windows.length === 0 ? "No limits reported." : null;
583}
584
585/** Quota left in the window, 0..100. Bars and labels show what remains, as Codex does. */
586export function remainingPercent(window: ServerProviderUsageWindow): number {
587 return Math.round(100 - Math.max(0, Math.min(100, window.usedPercent)));
588}
589
590function resetMillis(window: ServerProviderUsageWindow): number | null {
591 if (window.resetsAt === undefined) return null;
592 const at = Date.parse(window.resetsAt);
593 return Number.isFinite(at) ? at : null;
594}
595
596/** Elapsed share of the window, 0..1, or null when its length or reset is unknown. */
597export function elapsedShare(window: ServerProviderUsageWindow, now: number): number | null {
598 const resetsAt = resetMillis(window);
599 if (resetsAt === null || window.windowDurationMins === undefined) return null;
600 const length = window.windowDurationMins * MINUTE;
601 if (length <= 0) return null;
602 return Math.max(0, Math.min(1, (length - (resetsAt - now)) / length));
603}
604
605export type LimitPace = "ahead" | "on" | "under";
606
607/**
608 * Usage against the clock. Spending evenly leaves the same share of quota as
609 * there is time left in the window; within five points of that counts as on
610 * pace, further ahead means the window may run dry first.
611 */
612export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null {
613 const elapsed = elapsedShare(window, now);
614 return elapsed === null ? null : paceOfShares(window.usedPercent, elapsed);
615}
616
617function paceOfShares(usedPercent: number, elapsed: number): LimitPace {
618 const gap = usedPercent - elapsed * 100;
619 if (gap > 5) return "ahead";
620 if (gap < -5) return "under";
621 return "on";
622}
623
624/** `2h 13m`, `3d 4h`, `12m`. */
625export function formatDuration(ms: number): string {
626 const remaining = Math.max(0, ms);
627 const days = Math.floor(remaining / DAY);
628 const hours = Math.floor((remaining % DAY) / HOUR);
629 const minutes = Math.floor((remaining % HOUR) / MINUTE);
630 if (days > 0) return `${days}d ${hours}h`;
631 if (hours > 0) return `${hours}h ${minutes}m`;
632 return `${minutes}m`;
633}
634
635/** `resets in 2h 13m`, or null when the window has no reset. */
636export function formatResetsIn(window: ServerProviderUsageWindow, now: number): string | null {
637 const resetsAt = resetMillis(window);
638 if (resetsAt === null) return null;
639 if (resetsAt <= now) return "resets now";
640 if (resetsAt - now < MINUTE) return "Resets momentarily";
641 return `resets in ${formatDuration(resetsAt - now)}`;
642}
643
644/** Limit commands are served by T3 from the same snapshots as Usage → Limits. */
645export const USAGE_LIMITS_COMMAND = {
646 name: "usage-limits",
647 description: "Show this provider's usage limits",
648} satisfies ServerProviderSlashCommand;
649
650/** Handled by the client without sending a turn; anything with arguments stays an ordinary prompt. */
651export function isUsageLimitsCommand(prompt: string): boolean {
652 return prompt.trim().toLowerCase() === "/usage-limits";
653}
654
655/**
656 * Whether Limits has anything to say about this driver. A source that failed to
657 * read keeps no accounts, so its error counts for every driver rather than
658 * disappearing until the next successful refresh.
659 */
660export function hasProviderUsageLimits(
661 driver: ServerProvider["driver"],
662 providers: readonly ServerProvider[],
663 sources: UsageLimitSourceSnapshots,
664): boolean {
665 return (
666 providersWithLimits(providers).some((provider) => provider.driver === driver) ||
667 sources.some(
668 (source) =>
669 source.accounts.some((account) => account.driver === driver) ||
670 (source.error !== undefined && source.accounts.length === 0),
671 )
672 );
673}
674
675/**
676 * The drivers a set of sources would offer the command to, where a source that
677 * failed to read counts for every driver. Two snapshots with the same coverage
678 * need no catalog republish, however much their quotas moved.
679 */
680export function sameUsageLimitCommandCoverage(
681 previous: UsageLimitSourceSnapshots,
682 next: UsageLimitSourceSnapshots,
683): boolean {
684 const coverage = (sources: UsageLimitSourceSnapshots) =>
685 new Set(
686 sources.flatMap((source) =>
687 source.error !== undefined && source.accounts.length === 0
688 ? ["*"]
689 : source.accounts.map((account) => String(account.driver)),
690 ),
691 );
692 const before = coverage(previous);
693 const after = coverage(next);
694 return before.size === after.size && [...before].every((driver) => after.has(driver));
695}
696
697/** Advertise on workspace catalogs too, which replace the global command list. */
698export function withUsageLimitsCommands(
699 providers: readonly ServerProvider[],
700 sources: UsageLimitSourceSnapshots,
701): ServerProvider[] {
702 return providers.map((provider) => {
703 if (!hasProviderUsageLimits(provider.driver, providers, sources)) return provider;
704 const commands = (items: readonly ServerProviderSlashCommand[]) => [
705 ...items.filter((command) => command.name !== USAGE_LIMITS_COMMAND.name),
706 USAGE_LIMITS_COMMAND,
707 ];
708 return {
709 ...provider,
710 slashCommands: commands(provider.slashCommands),
711 ...(provider.workspaceSnapshots
712 ? {
713 workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({
714 ...snapshot,
715 slashCommands: commands(snapshot.slashCommands),
716 })),
717 }
718 : {}),
719 };
720 });
721}
722
723/** A point-in-time report; never refreshes or guesses which pooled account serves a turn. */
724export function collectProviderUsageLimits(
725 instanceId: ProviderInstanceId,
726 providers: readonly ServerProvider[],
727 sources: UsageLimitSourceSnapshots,
728 now: number,
729): UsageLimitsReport | null {
730 const selected = providers.find((provider) => provider.instanceId === instanceId);
731 if (!selected || !hasProviderUsageLimits(selected.driver, providers, sources)) return null;
732 const native = providersWithLimits(providers).filter(
733 (provider) => provider.driver === selected.driver,
734 );
735 const resolveKey = accountKeyResolver([
736 ...providers.filter((provider) => provider.enabled).map(nativeReport),
737 ...sources.flatMap((source) => source.accounts),
738 ]);
739 const nativeAccounts = new Set(
740 native.flatMap((provider) => {
741 const key = resolveKey(nativeReport(provider));
742 return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable
743 ? [key]
744 : [];
745 }),
746 );
747 const accounts: Array<UsageLimitsReport["accounts"][number]> = [];
748 const notices: string[] = [];
749 for (const provider of native) {
750 if (!provider.usageLimits) continue;
751 const key = resolveKey(nativeReport(provider));
752 const hubCredits = sources
753 .flatMap((source) => source.accounts.map((account) => ({ source, account })))
754 .filter(
755 ({ account }) =>
756 key !== null &&
757 resolveKey(account) === key &&
758 account.usageLimits.resetCredits &&
759 !limitsNotice(account.usageLimits),
760 )
761 .sort(
762 (a, b) =>
763 Date.parse(b.account.usageLimits.checkedAt) - Date.parse(a.account.usageLimits.checkedAt),
764 )[0];
765 // Two independent decisions. Which balance to *display* follows whichever
766 // snapshot is fresher. Which path to *redeem through* always prefers the
767 // hub, because only the hub path clears the routing cooldown it holds for
768 // that account; redeeming natively against the same account resets the
769 // subscription upstream but leaves the hub refusing to route to it until
770 // its own cooldown expires. A hub credit id that a fresher native redeem
771 // already spent comes back as `alreadyRedeemed`, which still clears the
772 // cooldown, so preferring it is safe even when the hub snapshot is stale.
773 const hubCreditId = hubCredits?.account.usageLimits.resetCredits?.nextCreditId;
774 const showHubCredits =
775 hubCredits &&
776 (!provider.usageLimits.resetCredits ||
777 Date.parse(hubCredits.account.usageLimits.checkedAt) >
778 Date.parse(provider.usageLimits.checkedAt));
779 accounts.push({
780 id: provider.instanceId,
781 driver: provider.driver,
782 label: `${provider.displayName?.trim() || String(provider.driver)} [${provider.instanceId}]`,
783 ...(provider.auth.label ? { plan: provider.auth.label } : {}),
784 instanceId: provider.instanceId,
785 resetCreditInput:
786 hubCreditId && hubCredits
787 ? {
788 sourceId: hubCredits.source.id,
789 accountId: hubCredits.account.id,
790 creditId: hubCreditId,
791 }
792 : { instanceId: provider.instanceId },
793 ...(provider.displayName ? { displayName: provider.displayName } : {}),
794 ...(provider.accentColor ? { accentColor: provider.accentColor } : {}),
795 ...(provider.auth.email ? { email: provider.auth.email } : {}),
796 limits: showHubCredits
797 ? { ...provider.usageLimits, resetCredits: hubCredits.account.usageLimits.resetCredits }
798 : provider.usageLimits,
799 });
800 }
801 for (const source of sources) {
802 const matching = source.accounts.filter((account) => account.driver === selected.driver);
803 for (const account of matching) {
804 const key = resolveKey(account);
805 if (key && nativeAccounts.has(key)) continue;
806 accounts.push({
807 id: `${source.id}:${account.id}`,
808 driver: account.driver,
809 label: `${source.label} · ${account.id}`,
810 sourceLabel: "CLI Proxy",
811 ...(account.usageLimits.resetCredits?.nextCreditId
812 ? {
813 resetCreditInput: {
814 sourceId: source.id,
815 accountId: account.id,
816 creditId: account.usageLimits.resetCredits.nextCreditId,
817 },
818 }
819 : {}),
820 ...(account.plan ? { plan: account.plan } : {}),
821 ...(account.email ? { email: account.email } : {}),
822 limits: account.usageLimits,
823 });
824 }
825 // A source that failed to read has no accounts left to match on, so its
826 // error is reported to every provider rather than silently dropped.
827 if (source.error && (matching.length > 0 || source.accounts.length === 0)) {
828 notices.push(`${source.label}: ${source.error}`);
829 }
830 }
831 return { createdAt: DateTime.formatIso(DateTime.makeUnsafe(now)), accounts, notices };
832}