diff --git a/apps/server/src/usage/cliproxyUsageLimits.test.ts b/apps/server/src/usage/cliproxyUsageLimits.test.ts index 19767f3a9270..5e8d1b1fff7a 100644 --- a/apps/server/src/usage/cliproxyUsageLimits.test.ts +++ b/apps/server/src/usage/cliproxyUsageLimits.test.ts @@ -102,6 +102,22 @@ describe("cliproxyStatusToAccounts", () => { }, ]); }); + + it("names a Codex five-hour window `primary`, as the Codex driver does", () => { + const accounts = cliproxyStatusToAccounts( + { + accounts: { + "codex-abc-someone@example.com-pro.json": { + provider: "codex", + plan: "pro", + five_hour: { hard_limited: false, known: true, used_percent: 40 }, + }, + }, + }, + checkedAt, + ); + expect(accounts[0]?.usageLimits.windows.map((window) => window.id)).toEqual(["primary"]); + }); }); describe("accountEmailFromAuthFile", () => { diff --git a/apps/server/src/usage/cliproxyUsageLimits.ts b/apps/server/src/usage/cliproxyUsageLimits.ts index cd2b1e277da6..47ed200c3f22 100644 --- a/apps/server/src/usage/cliproxyUsageLimits.ts +++ b/apps/server/src/usage/cliproxyUsageLimits.ts @@ -124,7 +124,9 @@ export function cliproxyAccountToUsageLimits( if (!window || window.known === false) continue; const resetsAt = isoFromHub(window.reset_at); windows.push({ - id: spec.id, + // Codex names its five-hour window by position, so a hub row and a + // native row for the same account pool together. + id: spec.key === "five_hour" && account.provider === "codex" ? "primary" : spec.id, kind: spec.kind, label: spec.label, windowDurationMins: spec.windowDurationMins, diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 1a72af35c909..0f15631acb99 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -5,21 +5,16 @@ import { ServerProvider, ServerProviderResetCredits, ServerProviderUsageWindow, - UsageLimitSourceAccount, - UsageLimitSourceSnapshot, UsageProviderKind, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { - collectLimitSources, - collectLimitsGroups, + collectLimitAccounts, elapsedShare, formatDuration, formatResetsIn, - limitsNotice, type LimitPace, paceOf, - providerLimitsLabel, remainingPercent, } from "@t3tools/shared/usageLimits"; import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; @@ -30,9 +25,6 @@ import { environmentPresentations } from "../../state/presentation"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { formatUpcomingTimestamp } from "../../timestampFormat"; -import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; -import { getDriverOption } from "../settings/providerDriverMeta"; -import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; import { AlertDialog, AlertDialogClose, @@ -44,6 +36,8 @@ import { } from "../ui/alert-dialog"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import type { makeLimitsFixture } from "./usageLimitsFixture"; +import { UsageLimitsPooled } from "./UsageLimitsPooled"; import { PROVIDER_PRESENTATION } from "./usageProviders"; const PACE: Record = { @@ -53,14 +47,14 @@ const PACE: Record @@ -202,94 +196,6 @@ export function LimitWindows({ ); } -/** - * Heading shared by local providers and source accounts: icon, driver, instance, plan, - * and the signed-in email blurred until clicked, as provider settings do. - */ -function AccountHeading({ - driver, - label, - instanceLabel, - plan, - email, - accentColor, -}: { - readonly driver: ServerProvider["driver"]; - readonly label: string; - readonly instanceLabel: string; - readonly plan: string | undefined; - readonly email: string | undefined; - readonly accentColor?: string | undefined; -}) { - return ( -

- - {label} - {instanceLabel !== label ? ( - - · {instanceLabel} - - ) : null} - {plan ? · {plan} : null} - {email ? ( - - ) : null} -

- ); -} - -function ProviderLimits({ - provider, - environmentId, - now, -}: { - readonly provider: ServerProvider; - readonly environmentId: EnvironmentId; - readonly now: number; -}) { - const limits = provider.usageLimits; - if (!limits) return null; - const notice = limitsNotice(limits); - return ( -
- getDriverOption(driver)?.label)} - plan={provider.auth.label} - email={provider.auth.email} - accentColor={provider.accentColor} - /> - {notice ? ( - {notice} - ) : ( - - )} - {limits.resetCredits ? ( - - ) : null} -
- ); -} - const OUTCOME_TEXT: Record = { reset: "Reset applied. Your windows have cleared.", nothingToReset: "Nothing to reset right now.", @@ -297,36 +203,12 @@ const OUTCOME_TEXT: Record = { alreadyRedeemed: "That credit was already redeemed.", }; -/** - * Banked reset credits with a confirmed redeem action. Redeeming spends a - * credit the provider granted the user, so it never fires on a bare click. - */ -export function ResetCredits({ - environmentId, - instanceId, - credits, - now, -}: { - readonly environmentId: EnvironmentId; - readonly instanceId: ProviderInstanceId; - readonly credits: ServerProviderResetCredits; - readonly now: number; -}) { +/** Everything a redeem needs: where to send it and what to say afterwards. */ +export function useResetCredit(environmentId: EnvironmentId, instanceId: ProviderInstanceId) { const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false }); const [confirming, setConfirming] = useState(false); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(null); - if (credits.availableCount === 0 && status === null) return null; - - const expiresIn = credits.nextExpiresAt - ? formatDuration(Date.parse(credits.nextExpiresAt) - now) - : null; - const summary = - credits.availableCount === 0 - ? "No reset credits banked" - : `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${ - expiresIn ? ` · next expires in ${expiresIn}` : "" - }`; const redeem = async () => { setConfirming(false); @@ -345,138 +227,115 @@ export function ResetCredits({ ); }; - return ( -
- {summary} - {credits.availableCount > 0 ? ( - - ) : null} - {status ? {status} : null} - - - - Use a reset credit? - - This redeems one credit on your account and clears the current rate-limit windows. It - cannot be undone. - - - - }>Cancel - - - - -
- ); + return { confirming, setConfirming, busy, status, redeem }; } -/** One account pooled by a usage-limit source, drawn like a provider row. */ -function SourceAccountLimits({ - account, - sourceKind, - now, +/** + * The confirm for a redeem. Redeeming spends a credit the provider granted the + * user, so it never fires on a bare click. Mount it outside any popover that + * holds the button: dialogs stack under popovers, and closing the popover + * would unmount a dialog rendered inside it. + */ +export function ResetCreditDialog({ + open, + onOpenChange, + onConfirm, }: { - readonly account: UsageLimitSourceAccount; - readonly sourceKind: string; - readonly now: number; + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly onConfirm: () => void; }) { - const notice = limitsNotice(account.usageLimits); return ( -
- - {notice ? ( - {notice} - ) : ( - - )} -
+ + + + Use a reset credit? + + This redeems one credit on your account and clears the current rate-limit windows. It + cannot be undone. + + + + }>Cancel + + + + ); } -const SOURCE_KIND_LABEL: Record = { - cliproxy: "CLI Proxy", -}; - -type LimitsSource = ReturnType[number]; +/** `2 reset credits banked · next expires in 27d 23h`, or the short form for a popover. */ +export function resetCreditsSummary( + credits: ServerProviderResetCredits, + now: number, + compact = false, +): string { + const expiresIn = credits.nextExpiresAt + ? formatDuration(Date.parse(credits.nextExpiresAt) - now) + : null; + if (credits.availableCount === 0) return "No reset credits banked"; + if (compact) + return `${credits.availableCount} banked${expiresIn ? ` · expires in ${expiresIn}` : ""}`; + return `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${ + expiresIn ? ` · next expires in ${expiresIn}` : "" + }`; +} -/** Read-only accounts pooled by a configured usage source. */ -function SourceLimits({ source, now }: { readonly source: LimitsSource; readonly now: number }) { - const kind = SOURCE_KIND_LABEL[source.kind]; +/** Banked reset credits with the redeem button and its confirm, self-contained. */ +export function ResetCredits({ + environmentId, + instanceId, + credits, + now, +}: { + readonly environmentId: EnvironmentId; + readonly instanceId: ProviderInstanceId; + readonly credits: ServerProviderResetCredits; + readonly now: number; +}) { + const { confirming, setConfirming, busy, status, redeem } = useResetCredit( + environmentId, + instanceId, + ); + if (credits.availableCount === 0 && status === null) return null; return ( -
- {source.error ? ( - {source.error} - ) : source.accounts.length === 0 ? ( - - {source.hiddenAccountCount > 0 - ? "All accounts are shown by connected providers." - : "No accounts reported."} - - ) : ( - source.accounts.map((account) => ( - - )) - )} +
+ {resetCreditsSummary(credits, now)} + {credits.availableCount > 0 ? ( + + ) : null} + {status ? {status} : null} + void redeem()} + />
); } /** - * Subscription quota windows from every connected environment's providers. - * Countdowns anchor to render time rather than ticking: a live clock would - * repaint the page every minute for no decision-changing gain. + * Subscription quota across every connected environment's providers and hubs, + * pooled per provider. Countdowns anchor to render time rather than ticking: a + * live clock would repaint the page every minute for no decision-changing gain. */ export function UsageLimitsSection({ selectedEnvironmentIds, + fixture = null, }: { readonly selectedEnvironmentIds: ReadonlySet | null; + /** Dev-only synthetic presentations standing in for the live ones. */ + readonly fixture?: ReturnType | null; }) { - const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const live = useAtomValue(environmentPresentations.presentationsAtom); + // Anchored once per mount on purpose: countdowns must not tick (see above). + const [now] = useState(() => Date.now()); + const presentations: Parameters[0] = fixture ?? live; const selected = selectedEnvironmentIds === null ? presentations : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); - const groups = collectLimitsGroups(selected); - const sources = collectLimitSources(selected); - // Anchored once per mount on purpose: countdowns must not tick (see below). - const [now] = useState(() => Date.now()); - - return ( -
- {groups.length === 0 && sources.length === 0 ? ( -

- No provider on the selected environments reports subscription limits. -

- ) : null} - {sources.map((source) => ( - - ))} - {groups.map((group) => ( -
- {group.environmentLabel ? ( -

- {group.environmentLabel} -

- ) : null} - {group.providers.map((provider) => ( - - ))} -
- ))} -
- ); + return ; } diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx new file mode 100644 index 000000000000..1d57cdb96340 --- /dev/null +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -0,0 +1,570 @@ +import { + collectLimitAccounts, + collectLimitNotices, + collectLimitPools, + formatDuration, + formatResetsIn, + type LimitAccount, + type LimitPool, + type LimitPoolMember, + type LimitPoolWindow, + remainingPercent, +} from "@t3tools/shared/usageLimits"; +import { TicketIcon } from "lucide-react"; +import { type ReactNode, useState } from "react"; + +import { usePrimarySettings } from "../../hooks/useSettings"; +import { cn } from "../../lib/utils"; +import { formatUpcomingTimestamp } from "../../timestampFormat"; +import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; +import { getDriverOption } from "../settings/providerDriverMeta"; +import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; +import { Button } from "../ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { + PaceIcon, + ResetCreditDialog, + barColor, + resetCreditsSummary, + useResetCredit, +} from "./UsageLimits"; + +/** `someone@example.com` → `SE`: enough to tell accounts apart, too little to identify one. */ +function accountInitials(email: string): string { + const [local = "", domain = ""] = email.split("@"); + return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "?"; +} + +/** A stable hue per email, so the same account gets the same chip on every visit. */ +function accountHue(email: string): number { + let hash = 0; + for (let index = 0; index < email.length; index += 1) { + hash = (hash * 31 + email.charCodeAt(index)) | 0; + } + return Math.abs(hash) % 360; +} + +/** The two-letter chip for an email, coloured by a stable hue per address. */ +function AccountChip({ email }: { readonly email: string }) { + const hue = accountHue(email); + return ( + + {accountInitials(email)} + + ); +} + +/** + * The same mark the model picker uses for a native instance (provider glyph, + * initials badge, accent); hub accounts have no instance, so they get the chip. + */ +function AccountAvatar({ + account, + className, +}: { + readonly account: LimitAccount; + readonly className?: string; +}) { + if (account.redeem) { + return ( + + ); + } + return account.email ? : null; +} + +/** + * Who an account is, without printing the email: the instance name when there + * is one, else a two-letter chip. The address itself is revealed on demand in + * the segment's popover. + */ +function AccountName({ + account, + className, +}: { + readonly account: LimitAccount; + readonly className?: string; +}) { + if (account.displayName) return {account.displayName}; + if (account.email) { + return ( + + + + ); + } + return ( + + {getDriverOption(account.driver)?.label ?? String(account.driver)} + + ); +} + +function Row({ label, children }: { readonly label: string; readonly children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +/** + * Everything about one account in one window: plan, where it is signed in, + * the email on request, reset time and share of the pool it restores, and the + * reset-credit action. Opens on hover for a glance, on click to act. + */ +function SegmentPopover({ + account, + window, + reset, + now, + redeem, + onRedeem, +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly reset: LimitPoolWindow["resets"][number] | undefined; + readonly now: number; + /** Redeem state owned by the segment, since the confirm lives outside this popover. */ + readonly redeem: ReturnType | null; + readonly onRedeem: () => void; +}) { + const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); + const remaining = remainingPercent(window); + const resetsIn = formatResetsIn(window, now); + const where = + account.environments.length > 0 + ? account.environments.map((environment) => environment.label).join(", ") + : account.sourceLabel; + const credits = + redeem && account.limits.resetCredits?.availableCount ? account.limits.resetCredits : null; + return ( +
+
+ + + + {account.displayName ?? getDriverOption(account.driver)?.label ?? account.driver} + + + {account.email ? ( + + ) : null} +
+
+ {account.plan ? {account.plan} : null} + {where ? ( + 0 ? "Signed in" : "Via"}>{where} + ) : null} +
+
+ {remaining}% + {window.resetsAt ? ( + + {formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)} + {resetsIn ? ` · ${resetsIn.replace("resets in ", "in ")}` : ""} + + ) : null} + {reset && reset.restoresPercent > 0 ? ( + +{reset.restoresPercent}% of pool + ) : null} +
+ {credits && redeem ? ( +
+ + {resetCreditsSummary(credits, now, true)} + + +
+ ) : null} +
+ ); +} + +/** + * One account's share of one pooled window: the segment, its popover, and the + * reset confirm. The confirm is a sibling of the popover, not a child: dialogs + * stack under popovers, and the popover closes as the confirm opens. + */ +function PoolSegment({ + account, + window, + reset, + color, + now, + index, +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly reset: LimitPoolWindow["resets"][number] | undefined; + readonly color: string; + readonly now: number; + /** 1-based position in the bar, shown on the strip and its legend row to tie them together. */ + readonly index: number; +}) { + const [open, setOpen] = useState(false); + const remaining = remainingPercent(window); + const resetsIn = formatResetsIn(window, now); + const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0; + return ( + + + } + > + {/* Translucent so the label reads over the fill for any provider colour and theme. */} +
+ {/* The spent share is hatched, not blank: it is what the countdown restores. */} + {remaining < 100 && reset ? ( +
+ ) : null} + + {index} + +
+ + {remaining}% + {/* Countdown and badge get their own plate: fill and hatching run under them otherwise. */} + + {resetsIn?.replace("resets in ", "↻ ") ?? ""} + {credits ? ( + <> + {resetsIn ? ( + + · + + ) : null} + + + {credits} + + + ) : null} + +
+ + + {account.redeem ? ( + setOpen(false)} + /> + ) : ( + + {}} + /> + + )} + + ); +} + +/** + * Below the strip at narrow widths: one row per account in bar order, carrying + * the text the segment has no room for. Tapping a row opens the same popover + * as its segment, so the two are one control with two handles. + */ +function LegendRow({ + account, + window, + color, + now, + index, +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly color: string; + readonly now: number; + readonly index: number; +}) { + const remaining = remainingPercent(window); + const resetsIn = formatResetsIn(window, now); + const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0; + return ( + + + + Segment + {index} + + + {remaining}% + + {resetsIn?.replace("resets in ", "↻ ") ?? ""} + {credits ? ( + <> + {resetsIn ? · : null} + + + {credits} + + + {credits} reset {credits === 1 ? "credit" : "credits"} banked + + + ) : null} + + + ); +} + +/** Split out so the redeem hook only runs for accounts that can redeem. */ +function RedeemableSegmentPopup({ + account, + window, + reset, + now, + redeemAt, + closePopover, +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly reset: LimitPoolWindow["resets"][number] | undefined; + readonly now: number; + readonly redeemAt: NonNullable; + readonly closePopover: () => void; +}) { + const redeem = useResetCredit(redeemAt.environmentId, redeemAt.instanceId); + return ( + <> + + { + closePopover(); + redeem.setConfirming(true); + }} + /> + + void redeem.redeem()} + /> + {/* The popover closed before the confirm, so the outcome needs a home outside it. */} + {redeem.status ? ( + + {redeem.status} + + ) : null} + + ); +} + +/** + * One pooled window as equal-width segments, one per account, each filled by + * the share of that account's quota still open. Equal widths are honest: every + * account contributes the same share of the pool, whatever its plan. + * + * Wide, each segment carries its own label. Narrow, the bar is a bare strip + * and a legend below lists the accounts in the same order; both open the + * same popover. + */ +function PoolBar({ + pool, + color, + now, +}: { + readonly pool: LimitPoolWindow; + readonly color: string; + readonly now: number; +}) { + const restores = new Map(pool.resets.map((reset) => [reset.member.account.key, reset])); + return ( +
+
+ {pool.members.map(({ account, window }, position) => ( + + ))} +
+
+ ); +} + +/** + * Big pooled number and the segment bar. The bar is sorted by reset, so who + * refills next is its left edge; the exact time and share restored live in + * each segment's popover rather than a list restating the bar. + */ +function PoolWindowCard({ + pool, + color, + now, +}: { + readonly pool: LimitPoolWindow; + readonly color: string; + readonly now: number; +}) { + // The soonest reset that hands anything back; an untouched account resets to no effect. + const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); + return ( +
+
+ {pool.label} + + + {pool.remainingPercent}% + + left + {pool.pace ? : null} + + {nextRefill ? ( + + ↻ +{nextRefill.restoresPercent}%{" "} + {nextRefill.at <= now ? "now" : `in ${formatDuration(nextRefill.at - now)}`} + + ) : null} +
+ +
+ ); +} + +function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) { + const color = barColor(pool.driver); + const label = getDriverOption(pool.driver)?.label ?? String(pool.driver); + return ( +
+

+ + {label} +

+ {pool.windows.map((window) => ( + + ))} +
+ ); +} + +/** + * Accounts pooled per provider: what is open across all of them, who resets + * next, and how much of the pool that hands back. Answers "can I keep going" + * before "on which account". + */ +export function UsageLimitsPooled({ + presentations, + now, +}: { + readonly presentations: Parameters[0]; + readonly now: number; +}) { + const pools = collectLimitPools(collectLimitAccounts(presentations), now); + const notices = collectLimitNotices(presentations); + return ( +
+ {pools.length === 0 ? ( +

+ No provider on the selected environments reports subscription limits. +

+ ) : null} + {pools.map((pool) => ( + + ))} + +
+ ); +} + +/** Sources and providers that could not be read, so a missing bar is not mistaken for a full one. */ +function LimitNotices({ notices }: { readonly notices: readonly string[] }) { + if (notices.length === 0) return null; + return ( +
    + {notices.map((notice) => ( +
  • {notice}
  • + ))} +
+ ); +} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index f0dcec49ad90..4d987931509f 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -59,6 +59,7 @@ import { import { WorkspacePageContainer } from "../WorkspacePageContainer"; import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { UsageLimitsSection } from "./UsageLimits"; +import { makeLimitsFixture } from "./usageLimitsFixture"; import { UsagePriceOverrides } from "./UsagePriceOverrides"; import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; @@ -95,10 +96,33 @@ export function UsagePage() { useState | null>(null); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; - const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage( - window, - selectedEnvironmentIds, - ); + const usage = useUsage(window, selectedEnvironmentIds); + // Dev only: `/usage?limitsFixture=` lists synthetic environments in the + // picker and feeds the Limits view from them, so merge rules can be eyeballed. + const [fixture] = useState(() => { + if (!import.meta.env.DEV) return null; + const name = new URLSearchParams(globalThis.location.search).get("limitsFixture"); + return name ? makeLimitsFixture(name, Date.now()) : null; + }); + const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = + useMemo(() => { + if (!fixture || !showingLimits) return usage; + const all = [...fixture].map(([environmentId, presentation]) => ({ + environmentId, + label: presentation.entry.target.label, + isPending: false, + error: null, + summary: null, + })); + return { + ...usage, + environments: all, + selectedEnvironments: + selectedEnvironmentIds === null + ? all + : all.filter((environment) => selectedEnvironmentIds.has(environment.environmentId)), + }; + }, [fixture, selectedEnvironmentIds, showingLimits, usage]); const presentations = useAtomValue(environmentPresentations.presentationsAtom); const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, @@ -143,6 +167,8 @@ export function UsagePage() { if (refreshingRef.current) return; if (showingLimits) { + // Synthetic data has nothing to re-read. + if (fixture) return; refreshingRef.current = true; setIsRefreshing(true); void Promise.all( @@ -326,7 +352,10 @@ export function UsagePage() { : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}

) : showingLimits ? ( - + ) : isPending ? ( ) : ( diff --git a/apps/web/src/components/usage/usageLimitsFixture.ts b/apps/web/src/components/usage/usageLimitsFixture.ts new file mode 100644 index 000000000000..8958707b98de --- /dev/null +++ b/apps/web/src/components/usage/usageLimitsFixture.ts @@ -0,0 +1,413 @@ +/** + * Dev-only stand-ins for `environmentPresentations` that exercise the pooled + * Limits view's merge rules, one scenario per named fixture. Reached with + * `/usage?limitsFixture=` on a dev build; never bundled otherwise. + */ +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type ServerProviderUsageWindow, + type UsageLimitSourceSnapshot, + UsageLimitSourceId, +} from "@t3tools/contracts"; + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +interface Presentation { + readonly entry: { readonly target: { readonly label: string } }; + readonly serverConfig: { + readonly providers: readonly ServerProvider[]; + readonly usageLimitSources: readonly UsageLimitSourceSnapshot[]; + }; +} + +type Fixture = ReadonlyMap; + +const codex = ProviderDriverKind.make("codex"); +const claude = ProviderDriverKind.make("claudeAgent"); + +function makeHelpers(now: number) { + const at = (ms: number) => new Date(now + ms).toISOString(); + const checked = (agoMs: number) => new Date(now - agoMs).toISOString(); + + const session = (used: number, resetsInMs: number): ServerProviderUsageWindow => ({ + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: used, + windowDurationMins: 300, + resetsAt: at(resetsInMs), + }); + const weekly = ( + id: string, + label: string, + used: number, + resetsInMs: number, + ): ServerProviderUsageWindow => ({ + id, + kind: "weekly", + label, + usedPercent: used, + windowDurationMins: 7 * 24 * 60, + resetsAt: at(resetsInMs), + }); + /** Codex names its five-hour window `primary` and its weekly one `secondary`. */ + const codexSession = (used: number, resetsInMs: number): ServerProviderUsageWindow => ({ + ...session(used, resetsInMs), + id: "primary", + }); + const codexWeekly = (used: number, resetsInMs: number) => + weekly("secondary", "Weekly", used, resetsInMs); + + const provider = ( + overrides: Partial & Pick, + ): ServerProvider => ({ + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: checked(0), + models: [], + slashCommands: [], + skills: [], + ...overrides, + }); + + const codexInstance = (input: { + readonly instanceId: string; + readonly displayName?: string; + readonly accentColor?: string; + readonly email: string; + readonly plan?: string; + readonly checkedAgoMs?: number; + readonly windows: readonly ServerProviderUsageWindow[]; + readonly credits?: number; + }) => + provider({ + instanceId: ProviderInstanceId.make(input.instanceId), + driver: codex, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + auth: { + status: "authenticated", + label: input.plan ?? "ChatGPT Pro 20x Subscription", + email: input.email, + }, + usageLimits: { + checkedAt: checked(input.checkedAgoMs ?? MINUTE), + windows: input.windows, + ...(input.credits + ? { resetCredits: { availableCount: input.credits, nextExpiresAt: at(28 * DAY) } } + : {}), + }, + }); + + const claudeHubAccount = ( + email: string | null, + windows: readonly ServerProviderUsageWindow[], + checkedAgoMs = 4 * MINUTE, + ): UsageLimitSourceSnapshot["accounts"][number] => ({ + id: email ? `claude-${email}.json` : "claude-team-seat.json", + driver: claude, + ...(email ? { email } : {}), + plan: "Claude Subscription", + usageLimits: { checkedAt: checked(checkedAgoMs), windows }, + }); + + const hub = ( + id: string, + label: string, + accounts: UsageLimitSourceSnapshot["accounts"], + error?: string, + ): UsageLimitSourceSnapshot => ({ + id: UsageLimitSourceId.make(id), + kind: "cliproxy", + label, + checkedAt: checked(2 * MINUTE), + accounts, + ...(error ? { error } : {}), + }); + + const environment = ( + id: string, + label: string, + providers: readonly ServerProvider[], + usageLimitSources: readonly UsageLimitSourceSnapshot[] = [], + ): readonly [EnvironmentId, Presentation] => [ + EnvironmentId.make(id), + { entry: { target: { label } }, serverConfig: { providers, usageLimitSources } }, + ]; + + return { + at, + checked, + session, + weekly, + codexSession, + codexWeekly, + provider, + codexInstance, + claudeHubAccount, + hub, + environment, + }; +} + +const FIXTURES: Record Fixture> = { + /** + * The same Codex account signed in on two machines with different snapshot + * ages, plus a hub that also reports it. Must collapse to one segment with + * the freshest figures and both machines listed. + */ + "same-account": (now) => { + const h = makeHelpers(now); + const email = "main@example.com"; + const hubAccounts: UsageLimitSourceSnapshot["accounts"] = [ + { + id: `codex-abc-${email}-pro.json`, + driver: codex, + email, + plan: "ChatGPT Pro 20x Subscription", + usageLimits: { checkedAt: h.checked(14 * MINUTE), windows: [h.codexWeekly(70, 5 * DAY)] }, + }, + ]; + return new Map([ + h.environment( + "env-macbook", + "MacBook Pro", + [ + h.codexInstance({ + instanceId: "codex", + displayName: "Codex Personal", + accentColor: "#6366f1", + email, + windows: [h.codexSession(10, 3 * HOUR), h.codexWeekly(66, 5 * DAY)], + credits: 2, + }), + ], + [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)], + ), + h.environment("env-nucbox", "nucbox-1", [ + h.codexInstance({ + instanceId: "codex", + email, + checkedAgoMs: 9 * MINUTE, + windows: [h.codexSession(30, 3 * HOUR), h.codexWeekly(60, 5 * DAY)], + credits: 2, + }), + ]), + ]); + }, + + /** + * Three machines, no two alike: one has only Codex, one only Claude via a + * hub, one has both natively. Filtering to any single environment should + * drop whole provider sections. + */ + "uneven-environments": (now) => { + const h = makeHelpers(now); + return new Map([ + h.environment("env-macbook", "MacBook Pro", [ + h.codexInstance({ + instanceId: "codex", + displayName: "Codex Personal", + accentColor: "#6366f1", + email: "main@example.com", + windows: [h.codexSession(10, 3 * HOUR), h.codexWeekly(66, 5 * DAY)], + credits: 2, + }), + h.provider({ + instanceId: ProviderInstanceId.make("claude"), + driver: claude, + auth: { status: "authenticated", label: "Claude Max", email: "main@example.com" }, + usageLimits: { + checkedAt: h.checked(MINUTE), + windows: [ + h.session(2, 4 * HOUR), + h.weekly("seven_day", "Weekly", 38, 4 * DAY), + h.weekly("seven_day_fable", "Weekly · Fable", 69, 4 * DAY), + ], + }, + }), + ]), + h.environment( + "env-nucbox", + "nucbox-1", + [], + [ + h.hub("cliproxy-nucbox", "CLI Proxy", [ + h.claudeHubAccount("personal@example.com", [ + h.session(100, 4 * HOUR), + h.weekly("seven_day", "Weekly", 50, DAY), + h.weekly("seven_day_fable", "Weekly · Fable", 96, DAY), + ]), + h.claudeHubAccount("second@example.org", [ + h.session(63, 2 * HOUR), + h.weekly("seven_day", "Weekly", 37, 4 * DAY), + h.weekly("seven_day_fable", "Weekly · Fable", 72, 4 * DAY), + ]), + ]), + ], + ), + h.environment("env-macmini", "Mac Mini", [ + h.codexInstance({ + instanceId: "codex", + displayName: "Codex Work", + email: "work@example.com", + windows: [h.codexSession(0, 5 * HOUR), h.codexWeekly(95, 5 * DAY)], + credits: 1, + }), + ]), + ]); + }, + + /** + * Codex plans that report only one window (Go reports a monthly allowance; + * a hub often has no five-hour figure for an account) mixed with a plan that + * reports both. Each pool lists only the accounts that have that window. + */ + "codex-window-mix": (now) => { + const h = makeHelpers(now); + return new Map([ + h.environment( + "env-macbook", + "MacBook Pro", + [ + h.codexInstance({ + instanceId: "codex", + displayName: "Codex Personal", + accentColor: "#6366f1", + email: "main@example.com", + windows: [h.codexSession(40, 2 * HOUR), h.codexWeekly(55, 3 * DAY)], + credits: 2, + }), + h.codexInstance({ + instanceId: "codex-go", + displayName: "Codex Go", + accentColor: "#10b981", + email: "go@example.com", + plan: "ChatGPT Go Subscription", + windows: [ + { + id: "primary", + kind: "monthly", + label: "Monthly", + usedPercent: 82, + windowDurationMins: 30 * 24 * 60, + resetsAt: h.at(11 * DAY), + }, + ], + }), + ], + [ + h.hub("cliproxy-nucbox", "CLI Proxy", [ + { + id: "codex-def-work@example.com-pro.json", + driver: codex, + email: "work@example.com", + plan: "ChatGPT Pro 20x Subscription", + usageLimits: { + checkedAt: h.checked(3 * MINUTE), + windows: [h.codexWeekly(88, 6 * DAY)], + }, + }, + { + id: "codex-ghi-team@example.net-plus.json", + driver: codex, + email: "team@example.net", + plan: "ChatGPT Plus Subscription", + usageLimits: { + checkedAt: h.checked(3 * MINUTE), + windows: [h.codexWeekly(12, DAY)], + }, + }, + ]), + ], + ), + ]); + }, + + /** + * A hub configured on two environments, a hub that is down, a provider + * whose probe failed, an API-key account, and a hub account with no email. + */ + "failures-and-strays": (now) => { + const h = makeHelpers(now); + const hubAccounts: UsageLimitSourceSnapshot["accounts"] = [ + h.claudeHubAccount("main@example.com", [ + h.session(2, 4 * HOUR), + h.weekly("seven_day", "Weekly", 38, 4 * DAY), + h.weekly("seven_day_fable", "Weekly · Fable", 69, 4 * DAY), + ]), + h.claudeHubAccount(null, [ + h.session(40, 2 * HOUR), + h.weekly("seven_day", "Weekly", 20, 6 * DAY), + ]), + ]; + return new Map([ + h.environment( + "env-macbook", + "MacBook Pro", + [ + h.provider({ + instanceId: ProviderInstanceId.make("claude"), + driver: claude, + auth: { status: "authenticated", label: "Claude API Key" }, + usageLimits: { + checkedAt: h.checked(MINUTE), + windows: [], + unavailable: { + reason: "unsupported", + message: "This account has no subscription limits.", + }, + }, + }), + ], + [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)], + ), + h.environment( + "env-nucbox", + "nucbox-1", + [], + [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)], + ), + h.environment( + "env-macmini", + "Mac Mini", + [ + h.provider({ + instanceId: ProviderInstanceId.make("claude"), + driver: claude, + auth: { + status: "authenticated", + label: "Claude Max", + email: "work@example.com", + }, + usageLimits: { + checkedAt: h.checked(MINUTE), + windows: [], + unavailable: { reason: "probeFailed", message: "Claude timed out reading usage." }, + }, + }), + ], + [ + h.hub( + "cliproxy-aws", + "AWS proxy", + [], + "fetch failed: connect ECONNREFUSED 10.0.0.4:8318", + ), + ], + ), + ]); + }, +}; + +export function makeLimitsFixture(name: string, now: number): Fixture | null { + return Object.hasOwn(FIXTURES, name) ? FIXTURES[name]!(now) : null; +} diff --git a/docs/user/usage.md b/docs/user/usage.md index 4e4196a46337..2f3e1013fdce 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -39,9 +39,18 @@ the dialog. ## Track subscription limits -**Usage → Limits** shows how much quota is left in each window and when it resets, for Codex and -Claude subscriptions. For windows with timing data, each bar also marks how much of the window is -left, so you can judge your pace before the next reset. +On web and desktop, **Usage → Limits** pools every subscription account it can see per provider, so with several Codex +or Claude accounts across your environments and hubs you read one number per window rather than a +list. Each window card shows how much of the pool is left and a bar with one segment per account, +ordered by which resets soonest; when the provider reports reset times, the card also says when +the next reset lands and how much it hands back. The hatched +part of a segment is what that reset restores. Tap or hover a segment for the account's plan, where it is +signed in, and its reset time; Codex accounts with banked reset credits show a ticket count on the +segment and the **Use reset** action in that popover. On narrow screens, numbered rows below +the bar show each account's quota, countdown, and credits. Tap a row to open its details. + +The same account signed in on more than one environment, or reported by a hub as well, counts once. +Filter with the environment dropdown to see what a single machine has. If a window looks stale, refresh Limits to re-check every provider and hub. diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index fede8813e913..32fb4eaa9503 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -13,6 +13,9 @@ import { collectProviderUsageLimits, sameUsageLimitCommandCoverage, withUsageLimitsCommands, + collectLimitAccounts, + collectLimitNotices, + collectLimitPools, collectLimitSources, collectLimitsGroups, elapsedShare, @@ -310,6 +313,381 @@ describe("collectLimitSources", () => { }); }); +describe("pools", () => { + const checkedAt = "2026-09-03T11:00:00.000Z"; + const weekly = { + id: "seven_day", + kind: "weekly", + label: "Weekly", + windowDurationMins: 7 * 24 * 60, + resetsAt: "2026-09-06T12:00:00.000Z", + } as const; + const claude = ProviderDriverKind.make("claudeAgent"); + const source = { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "hub", + checkedAt, + }; + const laptop = { entry: { target: { label: "Laptop" } } }; + + it("merges one account reported natively on two environments and by a hub into one entry", () => { + const native = provider({ + driver: claude, + instanceId: ProviderInstanceId.make("claude"), + auth: { status: "authenticated", email: "Same@example.com" }, + usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 40 }] }, + }); + const input = new Map([ + [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [native] } }], + [ + EnvironmentId.make("env-b"), + { + entry: { target: { label: "Desktop" } }, + serverConfig: { + providers: [ + { + ...native, + usageLimits: { + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [{ ...window, usedPercent: 55 }], + }, + }, + ], + usageLimitSources: [ + { + ...source, + accounts: [ + { + id: "claude-same@example.com.json", + driver: claude, + email: "same@example.com", + plan: "Claude Subscription", + usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 10 }] }, + }, + ], + }, + ], + }, + }, + ], + ]); + const accounts = collectLimitAccounts(input); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + key: "env-a:claude", + sourceLabel: null, + // Desktop's read is fresher, so its credits and its redeem are the ones on show. + redeem: { environmentId: "env-b", instanceId: "claude" }, + environments: [ + { environmentId: "env-a", label: "Laptop" }, + { environmentId: "env-b", label: "Desktop" }, + ], + }); + // The fresher native snapshot wins; the hub row is pre-filtered by email. + expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(55); + }); + + it("takes windows from a fresher hub read but credits and redeem from the native instance", () => { + const native = provider({ + driver: claude, + instanceId: ProviderInstanceId.make("claude"), + auth: { status: "authenticated", email: "same@example.com" }, + usageLimits: { + checkedAt, + windows: [{ ...window, usedPercent: 40 }], + resetCredits: { availableCount: 2 }, + }, + }); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [native], + usageLimitSources: [ + { + ...source, + accounts: [ + { + id: "claude-same@example.com.json", + driver: claude, + email: "same@example.com", + usageLimits: { + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [{ ...window, usedPercent: 55 }], + }, + }, + ], + }, + ], + }, + }, + ], + ]); + const [account] = collectLimitAccounts(input); + expect(account?.limits.windows[0]?.usedPercent).toBe(55); + expect(account?.limits.resetCredits?.availableCount).toBe(2); + expect(account?.redeem).toEqual({ environmentId: "env-a", instanceId: "claude" }); + expect(account?.environments).toEqual([{ environmentId: "env-a", label: "Laptop" }]); + }); + + it("redeems on the environment whose snapshot supplied the credits on show", () => { + const stale = provider({ + auth: { status: "authenticated", email: "same@example.com" }, + usageLimits: { + checkedAt, + windows: [window], + resetCredits: { availableCount: 0 }, + }, + }); + const fresh = { + ...stale, + usageLimits: { + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [window], + resetCredits: { availableCount: 2 }, + }, + }; + const input = new Map([ + [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [stale] } }], + [ + EnvironmentId.make("env-b"), + { entry: { target: { label: "Desktop" } }, serverConfig: { providers: [fresh] } }, + ], + ]); + const [account] = collectLimitAccounts(input); + expect(account?.limits.resetCredits?.availableCount).toBe(2); + expect(account?.redeem).toEqual({ environmentId: "env-b", instanceId: "codex" }); + }); + + it("names an environment once however many of its instances share the account", () => { + const shared = provider({ + auth: { status: "authenticated", email: "same@example.com" }, + usageLimits: { checkedAt, windows: [window] }, + }); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [shared, { ...shared, instanceId: ProviderInstanceId.make("work") }], + }, + }, + ], + ]); + expect(collectLimitAccounts(input)[0]?.environments).toEqual([ + { environmentId: "env-a", label: "Laptop" }, + ]); + }); + + it("keys a hub account without an email by hub, so two environments on one hub share it", () => { + const seat = { + id: "claude-team-seat.json", + driver: claude, + usageLimits: { checkedAt, windows: [window] }, + }; + const hub = { ...source, accounts: [seat] }; + const input = new Map([ + [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { usageLimitSources: [hub] } }], + [ + EnvironmentId.make("env-b"), + { entry: { target: { label: "Desktop" } }, serverConfig: { usageLimitSources: [hub] } }, + ], + ]); + const accounts = collectLimitAccounts(input); + expect(accounts.map((account) => account.key)).toEqual(["hub:claude-team-seat.json"]); + expect(accounts[0]?.displayName).toBe("claude-team-seat"); + }); + + it("pools windows by id across accounts and orders resets by when they land", () => { + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [], + usageLimitSources: [ + { + ...source, + accounts: [ + { + id: "a", + driver: claude, + usageLimits: { + checkedAt, + windows: [ + { ...window, usedPercent: 80, resetsAt: "2026-09-03T13:00:00.000Z" }, + { ...weekly, usedPercent: 20 }, + ], + }, + }, + { + id: "b", + driver: claude, + usageLimits: { + checkedAt, + windows: [{ ...window, usedPercent: 40 }], + }, + }, + { + id: "c", + driver: ProviderDriverKind.make("codex"), + usageLimits: { checkedAt, windows: [{ ...weekly, usedPercent: 50 }] }, + }, + { + id: "unsupported", + driver: claude, + usageLimits: { + checkedAt, + windows: [], + unavailable: { reason: "unsupported" as const }, + }, + }, + ], + }, + ], + }, + }, + ], + ]); + const pools = collectLimitPools(collectLimitAccounts(input), now); + expect(pools.map((pool) => [pool.driver, pool.accounts.length])).toEqual([ + ["claudeAgent", 2], + ["codex", 1], + ]); + const [session, week] = pools[0]!.windows; + // A member with no reset has no clock, so it does not vote on pace. + const untimed = collectLimitPools( + collectLimitAccounts(input).map((account) => + account.key === "hub:b" + ? { + ...account, + limits: { + ...account.limits, + windows: account.limits.windows.map((w) => ({ ...w, resetsAt: undefined })), + }, + } + : account, + ), + now, + ); + // Only a votes: 80% used, 80% elapsed. + expect(untimed[0]?.windows[0]?.pace).toBe("on"); + // a is 80% through its window and b 60%: the pool is 70% elapsed, 60% used. + expect(session).toMatchObject({ + id: "five_hour", + remainingPercent: 40, + usedPercent: 60, + pace: "under", + }); + expect( + session?.resets.map((reset) => [reset.member.account.key, reset.restoresPercent]), + ).toEqual([ + ["hub:a", 40], + ["hub:b", 20], + ]); + expect(week).toMatchObject({ id: "seven_day", remainingPercent: 80, members: [{}] }); + // Codex reports `primary` for both its five-hour and (on Go) monthly window. + const mixed = collectLimitPools( + [ + ...collectLimitAccounts(input), + { + key: "go", + driver: claude, + displayName: "Go", + email: undefined, + plan: undefined, + accentColor: undefined, + environments: [], + sourceLabel: null, + redeem: null, + limits: { + checkedAt, + windows: [ + { + id: "five_hour", + kind: "monthly", + label: "Monthly", + usedPercent: 82, + windowDurationMins: 30 * 24 * 60, + resetsAt: "2026-09-14T12:00:00.000Z", + }, + ], + }, + }, + ], + now, + ); + expect(mixed[0]?.windows.map((window) => [window.kind, window.members.length])).toEqual([ + ["session", 2], + ["weekly", 1], + ["monthly", 1], + ]); + // Segments read left to right as "who refills next", matching the reset list. + expect(session?.members.map((member) => member.account.key)).toEqual(["hub:a", "hub:b"]); + expect(pools[0]?.accounts.map((account) => account.key)).toEqual(["hub:a", "hub:b"]); + }); +}); + +describe("collectLimitNotices", () => { + const checkedAt = "2026-09-03T11:00:00.000Z"; + const claude = ProviderDriverKind.make("claudeAgent"); + const laptop = { entry: { target: { label: "Laptop" } } }; + const hub = { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "hub", + checkedAt, + accounts: [], + }; + + it("names failures and silence, skips unsupported accounts, and labels environments only when several", () => { + const failed = provider({ + instanceId: ProviderInstanceId.make("claude"), + driver: claude, + displayName: "Claude Max", + usageLimits: { checkedAt, windows: [], unavailable: { reason: "probeFailed" } }, + }); + const apiKey = provider({ + instanceId: ProviderInstanceId.make("api"), + driver: claude, + usageLimits: { checkedAt, windows: [], unavailable: { reason: "unsupported" } }, + }); + const silent = provider({ usageLimits: { checkedAt, windows: [] } }); + const one = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [failed, apiKey, silent], + usageLimitSources: [ + hub, + { ...hub, id: UsageLimitSourceId.make("down"), label: "down", error: "ECONNREFUSED" }, + ], + }, + }, + ], + ]); + expect(collectLimitNotices(one)).toEqual([ + "Claude Max: Could not read limits.", + "codex: No limits reported.", + "hub: No accounts reported.", + "down: ECONNREFUSED", + ]); + + one.set(EnvironmentId.make("env-b"), { + entry: { target: { label: "Desktop" } }, + serverConfig: { providers: [], usageLimitSources: [] }, + }); + expect(collectLimitNotices(one)[0]).toBe("Laptop · Claude Max: Could not read limits."); + }); +}); + describe("/usage-limits", () => { const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [window] }; const selected = provider({ diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 5cb5303320bd..419e1944c538 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -58,7 +58,9 @@ export function collectLimitsGroups( EnvironmentId, { readonly entry: { readonly target: { readonly label: string } }; - readonly serverConfig: { readonly providers: readonly ServerProvider[] } | null; + readonly serverConfig: { + readonly providers?: readonly ServerProvider[] | undefined; + } | null; } >, ): readonly LimitsGroup[] { @@ -147,6 +149,302 @@ function accountKey(driver: ServerProvider["driver"], email: string | undefined) return normalizedEmail ? `${driver}:${normalizedEmail}` : null; } +/** + * One subscription account as the pooled views see it, whichever way it was + * reported. The same email signed in natively on two environments, or reported + * by a hub as well as natively, is one account: its quota is one bucket, so + * counting it twice would misstate what is left. + */ +export interface LimitAccount { + readonly key: string; + readonly driver: ServerProvider["driver"]; + /** The instance's configured name, which is not sensitive; null for hub accounts. */ + readonly displayName: string | null; + readonly email: string | undefined; + readonly plan: string | undefined; + readonly accentColor: string | undefined; + /** Environments the account is signed in on; empty when only a hub reports it. */ + readonly environments: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly label: string; + }>; + /** The hub that reported it, when no environment has it natively. */ + readonly sourceLabel: string | null; + /** Where a reset credit can be redeemed; only native instances can. */ + readonly redeem: { + readonly environmentId: EnvironmentId; + readonly instanceId: ProviderInstanceId; + } | null; + readonly limits: ServerProviderUsageLimits; +} + +/** + * Every account with usable windows across the connected environments, one + * entry per distinct account. Native instances win over hub reports, and the + * freshest snapshot wins when the same account is reported twice. + */ +export function collectLimitAccounts( + presentations: Parameters[0], +): readonly LimitAccount[] { + const accounts = new Map(); + const merge = (key: string, next: LimitAccount) => { + const previous = accounts.get(key); + if (!previous) { + accounts.set(key, next); + return; + } + const fresher = Date.parse(next.limits.checkedAt) > Date.parse(previous.limits.checkedAt); + // Two instances on one machine sharing an account still name it once. + const environments = [ + ...previous.environments, + ...next.environments.filter( + (candidate) => + !previous.environments.some((seen) => seen.environmentId === candidate.environmentId), + ), + ]; + const winner = fresher ? next : previous; + // Windows come from the freshest snapshot, wherever it was read. Reset + // credits only ever come from a native instance, and the redeem must go + // to the instance whose credits are on show, so the two travel together: + // the freshest native snapshot supplies both, or neither. + const native = [previous, next] + .filter((candidate) => candidate.redeem !== null) + .toSorted((a, b) => Date.parse(b.limits.checkedAt) - Date.parse(a.limits.checkedAt))[0]; + accounts.set(key, { + ...previous, + displayName: previous.displayName ?? next.displayName, + plan: previous.plan ?? next.plan, + accentColor: previous.accentColor ?? next.accentColor, + environments, + // A hub only names the account when no environment has it natively. + sourceLabel: environments.length > 0 ? null : (previous.sourceLabel ?? next.sourceLabel), + redeem: native?.redeem ?? null, + limits: { + ...winner.limits, + ...(native?.limits.resetCredits + ? { resetCredits: native.limits.resetCredits } + : { resetCredits: undefined }), + }, + }); + }; + for (const [environmentId, presentation] of presentations) { + const label = presentation.entry.target.label; + for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { + if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue; + merge( + accountKey(provider.driver, provider.auth.email) ?? + `${environmentId}:${provider.instanceId}`, + { + key: `${environmentId}:${provider.instanceId}`, + driver: provider.driver, + displayName: provider.displayName?.trim() || null, + email: provider.auth.email, + plan: provider.auth.label, + accentColor: provider.accentColor, + environments: [{ environmentId, label }], + sourceLabel: null, + redeem: { environmentId, instanceId: provider.instanceId }, + limits: provider.usageLimits, + }, + ); + } + } + // Every hub account, including those a native instance also knows: the hub + // may hold a fresher read of the same subscription, and the merge above + // keeps the redeem target consistent with whichever snapshot wins. + const labelEnvironment = presentations.size > 1; + for (const presentation of presentations.values()) { + for (const source of presentation.serverConfig?.usageLimitSources ?? []) { + const sourceLabel = labelEnvironment + ? `${presentation.entry.target.label} · ${source.label}` + : source.label; + for (const account of source.accounts) { + if (limitsNotice(account.usageLimits) !== null) continue; + merge(accountKey(account.driver, account.email) ?? `${source.id}:${account.id}`, { + key: `${source.id}:${account.id}`, + driver: account.driver, + displayName: account.email ? null : account.id.replace(/\.json$/i, ""), + email: account.email, + plan: account.plan, + accentColor: undefined, + environments: [], + sourceLabel, + redeem: null, + limits: account.usageLimits, + }); + } + } + } + return [...accounts.values()]; +} + +/** + * What the pooled views cannot draw as a bar: a hub that failed to read, a + * provider whose probe failed. Accounts that can never report (API keys) + * are left out; there is nothing for the user to act on. The environment + * is named only when more than one is connected. + */ +export function collectLimitNotices( + presentations: Parameters[0], +): readonly string[] { + const label = (environmentLabel: string, subject: string) => + presentations.size > 1 ? `${environmentLabel} · ${subject}` : subject; + const notices: string[] = []; + for (const presentation of presentations.values()) { + const environmentLabel = presentation.entry.target.label; + for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { + // An account that can never report (API key) is left out; one that + // failed, or reported nothing at all, is worth a line. + if (provider.usageLimits?.unavailable?.reason === "unsupported") continue; + const notice = provider.usageLimits ? limitsNotice(provider.usageLimits) : null; + const name = provider.displayName?.trim() || String(provider.driver); + if (notice) notices.push(`${label(environmentLabel, name)}: ${notice}`); + } + for (const source of presentation.serverConfig?.usageLimitSources ?? []) { + if (source.error) { + notices.push(`${label(environmentLabel, source.label)}: ${source.error}`); + } else if (source.accounts.length === 0) { + notices.push(`${label(environmentLabel, source.label)}: No accounts reported.`); + } + } + } + return notices; +} + +export interface LimitPoolMember { + readonly account: LimitAccount; + readonly window: ServerProviderUsageWindow; +} + +/** + * One window id across every account that reports it: the pooled share left, + * pace against the clock, and the resets in the order they will land, each + * with the share of the pool it hands back. + */ +export interface LimitPoolWindow { + readonly id: string; + readonly kind: ServerProviderUsageWindow["kind"]; + readonly label: string; + readonly members: readonly LimitPoolMember[]; + readonly remainingPercent: number; + readonly usedPercent: number; + readonly pace: LimitPace | null; + readonly resets: ReadonlyArray<{ + readonly member: LimitPoolMember; + readonly at: number; + /** Points of the pool the reset restores: the member's used share over the member count. */ + readonly restoresPercent: number; + }>; +} + +export interface LimitPool { + readonly driver: ServerProvider["driver"]; + readonly accounts: readonly LimitAccount[]; + readonly windows: readonly LimitPoolWindow[]; +} + +const WINDOW_KIND_ORDER: Record = { + session: 0, + weekly: 1, + monthly: 2, + other: 3, +}; + +/** + * Accounts grouped by driver, each with its windows pooled by kind and id. + * Window ids are stable per provider, so a hub row and a native row for the + * same window land in the same pool; the kind is part of the key because + * Codex's `primary` is a position, not a duration (five hours on paid plans, + * a month on Free/Go), and a monthly allowance must not average into a + * five-hour pool. Pools order by kind, then first appearance. + * + * `accounts` is the table order: instances the user can act on (native, + * named) before hub-only accounts, each group alphabetical. Each window's + * `members` sort by reset instead, soonest first, so a bar reads left to + * right as "who refills next" and matches the reset list under it. + */ +export function collectLimitPools( + accounts: readonly LimitAccount[], + now: number, +): readonly LimitPool[] { + const byDriver = new Map(); + for (const account of accounts) { + const list = byDriver.get(account.driver); + if (list) list.push(account); + else byDriver.set(account.driver, [account]); + } + return [...byDriver].map(([driver, members]) => { + const sorted = members.toSorted( + (left, right) => + Number(left.redeem === null) - Number(right.redeem === null) || + accountSortName(left).localeCompare(accountSortName(right)), + ); + return { driver, accounts: sorted, windows: poolWindows(sorted, now) }; + }); +} + +function accountSortName(account: LimitAccount): string { + return (account.displayName ?? account.email ?? account.key).toLowerCase(); +} + +function poolWindows(accounts: readonly LimitAccount[], now: number): readonly LimitPoolWindow[] { + const byKey = new Map(); + for (const account of accounts) { + for (const window of account.limits.windows) { + const key = `${window.kind}:${window.id}`; + const list = byKey.get(key); + if (list) list.push({ account, window }); + else byKey.set(key, [{ account, window }]); + } + } + const pools = [...byKey.values()].map((unordered): LimitPoolWindow => { + const members = unordered.toSorted( + (left, right) => + (resetMillis(left.window) ?? Number.POSITIVE_INFINITY) - + (resetMillis(right.window) ?? Number.POSITIVE_INFINITY), + ); + const first = members[0]!.window; + const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length; + // Pace compares spend against the clock, so it is judged only over the + // members that have a clock; a window with no reset would otherwise + // count as spend with no time elapsed and skew the verdict. + const timed = members.flatMap((m) => { + const share = elapsedShare(m.window, now); + return share === null ? [] : [{ used: m.window.usedPercent, elapsed: share }]; + }); + const timedUsed = timed.reduce((sum, t) => sum + t.used, 0) / timed.length; + const meanElapsed = + timed.length > 0 ? timed.reduce((sum, t) => sum + t.elapsed, 0) / timed.length : null; + const resets = members + .flatMap((member) => { + const at = resetMillis(member.window); + return at === null + ? [] + : [ + { + member, + at, + restoresPercent: Math.round(member.window.usedPercent / members.length), + }, + ]; + }) + .toSorted((left, right) => left.at - right.at); + return { + id: first.id, + kind: first.kind, + label: first.label, + members, + usedPercent: Math.round(usedPercent), + remainingPercent: Math.round(100 - usedPercent), + pace: meanElapsed === null ? null : paceOfShares(timedUsed, meanElapsed), + resets, + }; + }); + return pools.toSorted( + (left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind], + ); +} + /** The instance's configured name, else the driver's, else its raw kind. */ export function providerLimitsLabel( provider: Pick, @@ -195,8 +493,11 @@ export type LimitPace = "ahead" | "on" | "under"; */ export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null { const elapsed = elapsedShare(window, now); - if (elapsed === null) return null; - const gap = window.usedPercent - elapsed * 100; + return elapsed === null ? null : paceOfShares(window.usedPercent, elapsed); +} + +function paceOfShares(usedPercent: number, elapsed: number): LimitPace { + const gap = usedPercent - elapsed * 100; if (gap > 5) return "ahead"; if (gap < -5) return "under"; return "on";