| author | |
| committer | |
| log | b273d1cfe9515f905d37a9146bb275b905d14399 |
| tree | 14495405932936b886ced1f8123579b28a9a5e4f |
| parent | d924fe266483e1d400775e3c3ce784f8e90f355f |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 1823 insertions(+), 246 deletions(-)
apps/server/src/usage/cliproxyUsageLimits.test.ts+16| ... | ... | @@ -102,6 +102,22 @@ describe("cliproxyStatusToAccounts", () => { |
| 102 | 102 | }, |
| 103 | 103 | ]); |
| 104 | 104 | }); |
| 105 | ||
| 106 | it("names a Codex five-hour window `primary`, as the Codex driver does", () => { | |
| 107 | const accounts = cliproxyStatusToAccounts( | |
| 108 | { | |
| 109 | accounts: { | |
| 110 | "codex-abc-someone@example.com-pro.json": { | |
| 111 | provider: "codex", | |
| 112 | plan: "pro", | |
| 113 | five_hour: { hard_limited: false, known: true, used_percent: 40 }, | |
| 114 | }, | |
| 115 | }, | |
| 116 | }, | |
| 117 | checkedAt, | |
| 118 | ); | |
| 119 | expect(accounts[0]?.usageLimits.windows.map((window) => window.id)).toEqual(["primary"]); | |
| 120 | }); | |
| 105 | 121 | }); |
| 106 | 122 | |
| 107 | 123 | describe("accountEmailFromAuthFile", () => { |
apps/server/src/usage/cliproxyUsageLimits.ts+3-1| ... | ... | @@ -124,7 +124,9 @@ export function cliproxyAccountToUsageLimits( |
| 124 | 124 | if (!window || window.known === false) continue; |
| 125 | 125 | const resetsAt = isoFromHub(window.reset_at); |
| 126 | 126 | windows.push({ |
| 127 | id: spec.id, | |
| 127 | // Codex names its five-hour window by position, so a hub row and a | |
| 128 | // native row for the same account pool together. | |
| 129 | id: spec.key === "five_hour" && account.provider === "codex" ? "primary" : spec.id, | |
| 128 | 130 | kind: spec.kind, |
| 129 | 131 | label: spec.label, |
| 130 | 132 | windowDurationMins: spec.windowDurationMins, |
apps/web/src/components/usage/UsageLimits.tsx+93-234| ... | ... | @@ -5,21 +5,16 @@ import { |
| 5 | 5 | ServerProvider, |
| 6 | 6 | ServerProviderResetCredits, |
| 7 | 7 | ServerProviderUsageWindow, |
| 8 | UsageLimitSourceAccount, | |
| 9 | UsageLimitSourceSnapshot, | |
| 10 | 8 | UsageProviderKind, |
| 11 | 9 | } from "@t3tools/contracts"; |
| 12 | 10 | import { useAtomValue } from "@effect/atom-react"; |
| 13 | 11 | import { |
| 14 | collectLimitSources, | |
| 15 | collectLimitsGroups, | |
| 12 | collectLimitAccounts, | |
| 16 | 13 | elapsedShare, |
| 17 | 14 | formatDuration, |
| 18 | 15 | formatResetsIn, |
| 19 | limitsNotice, | |
| 20 | 16 | type LimitPace, |
| 21 | 17 | paceOf, |
| 22 | providerLimitsLabel, | |
| 23 | 18 | remainingPercent, |
| 24 | 19 | } from "@t3tools/shared/usageLimits"; |
| 25 | 20 | import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; |
| ... | ... | @@ -30,9 +25,6 @@ import { environmentPresentations } from "../../state/presentation"; |
| 30 | 25 | import { serverEnvironment } from "../../state/server"; |
| 31 | 26 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 32 | 27 | import { formatUpcomingTimestamp } from "../../timestampFormat"; |
| 33 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; | |
| 34 | import { getDriverOption } from "../settings/providerDriverMeta"; | |
| 35 | import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; | |
| 36 | 28 | import { |
| 37 | 29 | AlertDialog, |
| 38 | 30 | AlertDialogClose, |
| ... | ... | @@ -44,6 +36,8 @@ import { |
| 44 | 36 | } from "../ui/alert-dialog"; |
| 45 | 37 | import { Button } from "../ui/button"; |
| 46 | 38 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 39 | import type { makeLimitsFixture } from "./usageLimitsFixture"; | |
| 40 | import { UsageLimitsPooled } from "./UsageLimitsPooled"; | |
| 47 | 41 | import { PROVIDER_PRESENTATION } from "./usageProviders"; |
| 48 | 42 | |
| 49 | 43 | const PACE: Record<LimitPace, { readonly label: string; readonly icon: typeof GaugeIcon }> = { |
| ... | ... | @@ -53,14 +47,14 @@ const PACE: Record<LimitPace, { readonly label: string; readonly icon: typeof Ga |
| 53 | 47 | }; |
| 54 | 48 | |
| 55 | 49 | /** The series colour the cost chart uses for this driver, so the two views read as one. */ |
| 56 | function barColor(driver: ServerProvider["driver"]): string { | |
| 50 | export function barColor(driver: ServerProvider["driver"]): string { | |
| 57 | 51 | const kind: UsageProviderKind | undefined = |
| 58 | 52 | driver === "codex" ? "codex" : driver === "claudeAgent" ? "claude" : undefined; |
| 59 | 53 | return kind ? PROVIDER_PRESENTATION[kind].color : "var(--foreground)"; |
| 60 | 54 | } |
| 61 | 55 | |
| 62 | 56 | /** Pace as a glyph with the words on hover. */ |
| 63 | function PaceIcon({ pace }: { readonly pace: LimitPace }) { | |
| 57 | export function PaceIcon({ pace }: { readonly pace: LimitPace }) { | |
| 64 | 58 | const Icon = PACE[pace].icon; |
| 65 | 59 | return ( |
| 66 | 60 | <Tooltip> |
| ... | ... | @@ -202,94 +196,6 @@ export function LimitWindows({ |
| 202 | 196 | ); |
| 203 | 197 | } |
| 204 | 198 | |
| 205 | /** | |
| 206 | * Heading shared by local providers and source accounts: icon, driver, instance, plan, | |
| 207 | * and the signed-in email blurred until clicked, as provider settings do. | |
| 208 | */ | |
| 209 | function AccountHeading({ | |
| 210 | driver, | |
| 211 | label, | |
| 212 | instanceLabel, | |
| 213 | plan, | |
| 214 | email, | |
| 215 | accentColor, | |
| 216 | }: { | |
| 217 | readonly driver: ServerProvider["driver"]; | |
| 218 | readonly label: string; | |
| 219 | readonly instanceLabel: string; | |
| 220 | readonly plan: string | undefined; | |
| 221 | readonly email: string | undefined; | |
| 222 | readonly accentColor?: string | undefined; | |
| 223 | }) { | |
| 224 | return ( | |
| 225 | <h2 className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm font-medium text-foreground"> | |
| 226 | <ProviderInstanceIcon | |
| 227 | driverKind={driver} | |
| 228 | displayName={instanceLabel} | |
| 229 | accentColor={accentColor} | |
| 230 | showBadge={Boolean(accentColor)} | |
| 231 | indicatorBackground="var(--background)" | |
| 232 | className="size-5" | |
| 233 | iconClassName="size-4 text-foreground/80" | |
| 234 | /> | |
| 235 | <span className="truncate">{label}</span> | |
| 236 | {instanceLabel !== label ? ( | |
| 237 | <span className="min-w-0 truncate text-xs font-normal text-muted-foreground"> | |
| 238 | · {instanceLabel} | |
| 239 | </span> | |
| 240 | ) : null} | |
| 241 | {plan ? <span className="font-normal text-muted-foreground">· {plan}</span> : null} | |
| 242 | {email ? ( | |
| 243 | <RedactedSensitiveText | |
| 244 | value={email} | |
| 245 | ariaLabel="Toggle account email visibility" | |
| 246 | revealTooltip="Click to reveal email" | |
| 247 | hideTooltip="Click to hide email" | |
| 248 | /> | |
| 249 | ) : null} | |
| 250 | </h2> | |
| 251 | ); | |
| 252 | } | |
| 253 | ||
| 254 | function ProviderLimits({ | |
| 255 | provider, | |
| 256 | environmentId, | |
| 257 | now, | |
| 258 | }: { | |
| 259 | readonly provider: ServerProvider; | |
| 260 | readonly environmentId: EnvironmentId; | |
| 261 | readonly now: number; | |
| 262 | }) { | |
| 263 | const limits = provider.usageLimits; | |
| 264 | if (!limits) return null; | |
| 265 | const notice = limitsNotice(limits); | |
| 266 | return ( | |
| 267 | <section className="flex flex-col gap-3"> | |
| 268 | <AccountHeading | |
| 269 | driver={provider.driver} | |
| 270 | label={getDriverOption(provider.driver)?.label ?? String(provider.driver)} | |
| 271 | instanceLabel={providerLimitsLabel(provider, (driver) => getDriverOption(driver)?.label)} | |
| 272 | plan={provider.auth.label} | |
| 273 | email={provider.auth.email} | |
| 274 | accentColor={provider.accentColor} | |
| 275 | /> | |
| 276 | {notice ? ( | |
| 277 | <span className="text-xs text-muted-foreground">{notice}</span> | |
| 278 | ) : ( | |
| 279 | <LimitWindows driver={provider.driver} windows={limits.windows} now={now} /> | |
| 280 | )} | |
| 281 | {limits.resetCredits ? ( | |
| 282 | <ResetCredits | |
| 283 | environmentId={environmentId} | |
| 284 | instanceId={provider.instanceId} | |
| 285 | credits={limits.resetCredits} | |
| 286 | now={now} | |
| 287 | /> | |
| 288 | ) : null} | |
| 289 | </section> | |
| 290 | ); | |
| 291 | } | |
| 292 | ||
| 293 | 199 | const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = { |
| 294 | 200 | reset: "Reset applied. Your windows have cleared.", |
| 295 | 201 | nothingToReset: "Nothing to reset right now.", |
| ... | ... | @@ -297,36 +203,12 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = { |
| 297 | 203 | alreadyRedeemed: "That credit was already redeemed.", |
| 298 | 204 | }; |
| 299 | 205 | |
| 300 | /** | |
| 301 | * Banked reset credits with a confirmed redeem action. Redeeming spends a | |
| 302 | * credit the provider granted the user, so it never fires on a bare click. | |
| 303 | */ | |
| 304 | export function ResetCredits({ | |
| 305 | environmentId, | |
| 306 | instanceId, | |
| 307 | credits, | |
| 308 | now, | |
| 309 | }: { | |
| 310 | readonly environmentId: EnvironmentId; | |
| 311 | readonly instanceId: ProviderInstanceId; | |
| 312 | readonly credits: ServerProviderResetCredits; | |
| 313 | readonly now: number; | |
| 314 | }) { | |
| 206 | /** Everything a redeem needs: where to send it and what to say afterwards. */ | |
| 207 | export function useResetCredit(environmentId: EnvironmentId, instanceId: ProviderInstanceId) { | |
| 315 | 208 | const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false }); |
| 316 | 209 | const [confirming, setConfirming] = useState(false); |
| 317 | 210 | const [busy, setBusy] = useState(false); |
| 318 | 211 | const [status, setStatus] = useState<string | null>(null); |
| 319 | if (credits.availableCount === 0 && status === null) return null; | |
| 320 | ||
| 321 | const expiresIn = credits.nextExpiresAt | |
| 322 | ? formatDuration(Date.parse(credits.nextExpiresAt) - now) | |
| 323 | : null; | |
| 324 | const summary = | |
| 325 | credits.availableCount === 0 | |
| 326 | ? "No reset credits banked" | |
| 327 | : `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${ | |
| 328 | expiresIn ? ` · next expires in ${expiresIn}` : "" | |
| 329 | }`; | |
| 330 | 212 | |
| 331 | 213 | const redeem = async () => { |
| 332 | 214 | setConfirming(false); |
| ... | ... | @@ -345,138 +227,115 @@ export function ResetCredits({ |
| 345 | 227 | ); |
| 346 | 228 | }; |
| 347 | 229 | |
| 348 | return ( | |
| 349 | <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"> | |
| 350 | <span className="tabular-nums">{summary}</span> | |
| 351 | {credits.availableCount > 0 ? ( | |
| 352 | <Button size="xs" variant="outline" disabled={busy} onClick={() => setConfirming(true)}> | |
| 353 | {busy ? "Using…" : "Use reset"} | |
| 354 | </Button> | |
| 355 | ) : null} | |
| 356 | {status ? <span className="text-foreground">{status}</span> : null} | |
| 357 | <AlertDialog open={confirming} onOpenChange={setConfirming}> | |
| 358 | <AlertDialogPopup> | |
| 359 | <AlertDialogHeader> | |
| 360 | <AlertDialogTitle>Use a reset credit?</AlertDialogTitle> | |
| 361 | <AlertDialogDescription> | |
| 362 | This redeems one credit on your account and clears the current rate-limit windows. It | |
| 363 | cannot be undone. | |
| 364 | </AlertDialogDescription> | |
| 365 | </AlertDialogHeader> | |
| 366 | <AlertDialogFooter> | |
| 367 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> | |
| 368 | <Button onClick={() => void redeem()}>Use credit</Button> | |
| 369 | </AlertDialogFooter> | |
| 370 | </AlertDialogPopup> | |
| 371 | </AlertDialog> | |
| 372 | </div> | |
| 373 | ); | |
| 230 | return { confirming, setConfirming, busy, status, redeem }; | |
| 374 | 231 | } |
| 375 | 232 | |
| 376 | /** One account pooled by a usage-limit source, drawn like a provider row. */ | |
| 377 | function SourceAccountLimits({ | |
| 378 | account, | |
| 379 | sourceKind, | |
| 380 | now, | |
| 233 | /** | |
| 234 | * The confirm for a redeem. Redeeming spends a credit the provider granted the | |
| 235 | * user, so it never fires on a bare click. Mount it outside any popover that | |
| 236 | * holds the button: dialogs stack under popovers, and closing the popover | |
| 237 | * would unmount a dialog rendered inside it. | |
| 238 | */ | |
| 239 | export function ResetCreditDialog({ | |
| 240 | open, | |
| 241 | onOpenChange, | |
| 242 | onConfirm, | |
| 381 | 243 | }: { |
| 382 | readonly account: UsageLimitSourceAccount; | |
| 383 | readonly sourceKind: string; | |
| 384 | readonly now: number; | |
| 244 | readonly open: boolean; | |
| 245 | readonly onOpenChange: (open: boolean) => void; | |
| 246 | readonly onConfirm: () => void; | |
| 385 | 247 | }) { |
| 386 | const notice = limitsNotice(account.usageLimits); | |
| 387 | 248 | return ( |
| 388 | <section className="flex flex-col gap-3"> | |
| 389 | <AccountHeading | |
| 390 | driver={account.driver} | |
| 391 | label={getDriverOption(account.driver)?.label ?? String(account.driver)} | |
| 392 | instanceLabel={sourceKind} | |
| 393 | plan={account.plan} | |
| 394 | email={account.email} | |
| 395 | /> | |
| 396 | {notice ? ( | |
| 397 | <span className="text-xs text-muted-foreground">{notice}</span> | |
| 398 | ) : ( | |
| 399 | <LimitWindows driver={account.driver} windows={account.usageLimits.windows} now={now} /> | |
| 400 | )} | |
| 401 | </section> | |
| 249 | <AlertDialog open={open} onOpenChange={onOpenChange}> | |
| 250 | <AlertDialogPopup> | |
| 251 | <AlertDialogHeader> | |
| 252 | <AlertDialogTitle>Use a reset credit?</AlertDialogTitle> | |
| 253 | <AlertDialogDescription> | |
| 254 | This redeems one credit on your account and clears the current rate-limit windows. It | |
| 255 | cannot be undone. | |
| 256 | </AlertDialogDescription> | |
| 257 | </AlertDialogHeader> | |
| 258 | <AlertDialogFooter> | |
| 259 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> | |
| 260 | <Button onClick={onConfirm}>Use credit</Button> | |
| 261 | </AlertDialogFooter> | |
| 262 | </AlertDialogPopup> | |
| 263 | </AlertDialog> | |
| 402 | 264 | ); |
| 403 | 265 | } |
| 404 | 266 | |
| 405 | const SOURCE_KIND_LABEL: Record<UsageLimitSourceSnapshot["kind"], string> = { | |
| 406 | cliproxy: "CLI Proxy", | |
| 407 | }; | |
| 408 | ||
| 409 | type LimitsSource = ReturnType<typeof collectLimitSources>[number]; | |
| 267 | /** `2 reset credits banked · next expires in 27d 23h`, or the short form for a popover. */ | |
| 268 | export function resetCreditsSummary( | |
| 269 | credits: ServerProviderResetCredits, | |
| 270 | now: number, | |
| 271 | compact = false, | |
| 272 | ): string { | |
| 273 | const expiresIn = credits.nextExpiresAt | |
| 274 | ? formatDuration(Date.parse(credits.nextExpiresAt) - now) | |
| 275 | : null; | |
| 276 | if (credits.availableCount === 0) return "No reset credits banked"; | |
| 277 | if (compact) | |
| 278 | return `${credits.availableCount} banked${expiresIn ? ` · expires in ${expiresIn}` : ""}`; | |
| 279 | return `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${ | |
| 280 | expiresIn ? ` · next expires in ${expiresIn}` : "" | |
| 281 | }`; | |
| 282 | } | |
| 410 | 283 | |
| 411 | /** Read-only accounts pooled by a configured usage source. */ | |
| 412 | function SourceLimits({ source, now }: { readonly source: LimitsSource; readonly now: number }) { | |
| 413 | const kind = SOURCE_KIND_LABEL[source.kind]; | |
| 284 | /** Banked reset credits with the redeem button and its confirm, self-contained. */ | |
| 285 | export function ResetCredits({ | |
| 286 | environmentId, | |
| 287 | instanceId, | |
| 288 | credits, | |
| 289 | now, | |
| 290 | }: { | |
| 291 | readonly environmentId: EnvironmentId; | |
| 292 | readonly instanceId: ProviderInstanceId; | |
| 293 | readonly credits: ServerProviderResetCredits; | |
| 294 | readonly now: number; | |
| 295 | }) { | |
| 296 | const { confirming, setConfirming, busy, status, redeem } = useResetCredit( | |
| 297 | environmentId, | |
| 298 | instanceId, | |
| 299 | ); | |
| 300 | if (credits.availableCount === 0 && status === null) return null; | |
| 414 | 301 | return ( |
| 415 | <div className="flex flex-col gap-6"> | |
| 416 | {source.error ? ( | |
| 417 | <span className="text-xs text-muted-foreground">{source.error}</span> | |
| 418 | ) : source.accounts.length === 0 ? ( | |
| 419 | <span className="text-xs text-muted-foreground"> | |
| 420 | {source.hiddenAccountCount > 0 | |
| 421 | ? "All accounts are shown by connected providers." | |
| 422 | : "No accounts reported."} | |
| 423 | </span> | |
| 424 | ) : ( | |
| 425 | source.accounts.map((account) => ( | |
| 426 | <SourceAccountLimits key={account.id} account={account} sourceKind={kind} now={now} /> | |
| 427 | )) | |
| 428 | )} | |
| 302 | <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"> | |
| 303 | <span className="tabular-nums">{resetCreditsSummary(credits, now)}</span> | |
| 304 | {credits.availableCount > 0 ? ( | |
| 305 | <Button size="xs" variant="outline" disabled={busy} onClick={() => setConfirming(true)}> | |
| 306 | {busy ? "Using…" : "Use reset"} | |
| 307 | </Button> | |
| 308 | ) : null} | |
| 309 | {status ? <span className="text-foreground">{status}</span> : null} | |
| 310 | <ResetCreditDialog | |
| 311 | open={confirming} | |
| 312 | onOpenChange={setConfirming} | |
| 313 | onConfirm={() => void redeem()} | |
| 314 | /> | |
| 429 | 315 | </div> |
| 430 | 316 | ); |
| 431 | 317 | } |
| 432 | 318 | |
| 433 | 319 | /** |
| 434 | * Subscription quota windows from every connected environment's providers. | |
| 435 | * Countdowns anchor to render time rather than ticking: a live clock would | |
| 436 | * repaint the page every minute for no decision-changing gain. | |
| 320 | * Subscription quota across every connected environment's providers and hubs, | |
| 321 | * pooled per provider. Countdowns anchor to render time rather than ticking: a | |
| 322 | * live clock would repaint the page every minute for no decision-changing gain. | |
| 437 | 323 | */ |
| 438 | 324 | export function UsageLimitsSection({ |
| 439 | 325 | selectedEnvironmentIds, |
| 326 | fixture = null, | |
| 440 | 327 | }: { |
| 441 | 328 | readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null; |
| 329 | /** Dev-only synthetic presentations standing in for the live ones. */ | |
| 330 | readonly fixture?: ReturnType<typeof makeLimitsFixture> | null; | |
| 442 | 331 | }) { |
| 443 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); | |
| 332 | const live = useAtomValue(environmentPresentations.presentationsAtom); | |
| 333 | // Anchored once per mount on purpose: countdowns must not tick (see above). | |
| 334 | const [now] = useState(() => Date.now()); | |
| 335 | const presentations: Parameters<typeof collectLimitAccounts>[0] = fixture ?? live; | |
| 444 | 336 | const selected = |
| 445 | 337 | selectedEnvironmentIds === null |
| 446 | 338 | ? presentations |
| 447 | 339 | : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); |
| 448 | const groups = collectLimitsGroups(selected); | |
| 449 | const sources = collectLimitSources(selected); | |
| 450 | // Anchored once per mount on purpose: countdowns must not tick (see below). | |
| 451 | const [now] = useState(() => Date.now()); | |
| 452 | ||
| 453 | return ( | |
| 454 | <div className="flex flex-col gap-8"> | |
| 455 | {groups.length === 0 && sources.length === 0 ? ( | |
| 456 | <p className="text-sm text-muted-foreground"> | |
| 457 | No provider on the selected environments reports subscription limits. | |
| 458 | </p> | |
| 459 | ) : null} | |
| 460 | {sources.map((source) => ( | |
| 461 | <SourceLimits key={source.key} source={source} now={now} /> | |
| 462 | ))} | |
| 463 | {groups.map((group) => ( | |
| 464 | <div key={group.environmentId} className="flex flex-col gap-6"> | |
| 465 | {group.environmentLabel ? ( | |
| 466 | <h2 className="text-xs tracking-wide text-muted-foreground uppercase"> | |
| 467 | {group.environmentLabel} | |
| 468 | </h2> | |
| 469 | ) : null} | |
| 470 | {group.providers.map((provider) => ( | |
| 471 | <ProviderLimits | |
| 472 | key={provider.instanceId} | |
| 473 | provider={provider} | |
| 474 | environmentId={group.environmentId} | |
| 475 | now={now} | |
| 476 | /> | |
| 477 | ))} | |
| 478 | </div> | |
| 479 | ))} | |
| 480 | </div> | |
| 481 | ); | |
| 340 | return <UsageLimitsPooled presentations={selected} now={now} />; | |
| 482 | 341 | } |
apps/web/src/components/usage/UsageLimitsPooled.tsx created+570| ... | ... | @@ -0,0 +1,570 @@ |
| 1 | import { | |
| 2 | collectLimitAccounts, | |
| 3 | collectLimitNotices, | |
| 4 | collectLimitPools, | |
| 5 | formatDuration, | |
| 6 | formatResetsIn, | |
| 7 | type LimitAccount, | |
| 8 | type LimitPool, | |
| 9 | type LimitPoolMember, | |
| 10 | type LimitPoolWindow, | |
| 11 | remainingPercent, | |
| 12 | } from "@t3tools/shared/usageLimits"; | |
| 13 | import { TicketIcon } from "lucide-react"; | |
| 14 | import { type ReactNode, useState } from "react"; | |
| 15 | ||
| 16 | import { usePrimarySettings } from "../../hooks/useSettings"; | |
| 17 | import { cn } from "../../lib/utils"; | |
| 18 | import { formatUpcomingTimestamp } from "../../timestampFormat"; | |
| 19 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; | |
| 20 | import { getDriverOption } from "../settings/providerDriverMeta"; | |
| 21 | import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; | |
| 22 | import { Button } from "../ui/button"; | |
| 23 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | |
| 24 | import { | |
| 25 | PaceIcon, | |
| 26 | ResetCreditDialog, | |
| 27 | barColor, | |
| 28 | resetCreditsSummary, | |
| 29 | useResetCredit, | |
| 30 | } from "./UsageLimits"; | |
| 31 | ||
| 32 | /** `someone@example.com` → `SE`: enough to tell accounts apart, too little to identify one. */ | |
| 33 | function accountInitials(email: string): string { | |
| 34 | const [local = "", domain = ""] = email.split("@"); | |
| 35 | return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "?"; | |
| 36 | } | |
| 37 | ||
| 38 | /** A stable hue per email, so the same account gets the same chip on every visit. */ | |
| 39 | function accountHue(email: string): number { | |
| 40 | let hash = 0; | |
| 41 | for (let index = 0; index < email.length; index += 1) { | |
| 42 | hash = (hash * 31 + email.charCodeAt(index)) | 0; | |
| 43 | } | |
| 44 | return Math.abs(hash) % 360; | |
| 45 | } | |
| 46 | ||
| 47 | /** The two-letter chip for an email, coloured by a stable hue per address. */ | |
| 48 | function AccountChip({ email }: { readonly email: string }) { | |
| 49 | const hue = accountHue(email); | |
| 50 | return ( | |
| 51 | <span | |
| 52 | role="img" | |
| 53 | aria-label={`Account ${accountInitials(email)}`} | |
| 54 | className="inline-flex size-4 shrink-0 items-center justify-center rounded-full text-[9px] leading-none font-semibold" | |
| 55 | style={{ backgroundColor: `oklch(0.85 0.08 ${hue})`, color: `oklch(0.35 0.1 ${hue})` }} | |
| 56 | > | |
| 57 | {accountInitials(email)} | |
| 58 | </span> | |
| 59 | ); | |
| 60 | } | |
| 61 | ||
| 62 | /** | |
| 63 | * The same mark the model picker uses for a native instance (provider glyph, | |
| 64 | * initials badge, accent); hub accounts have no instance, so they get the chip. | |
| 65 | */ | |
| 66 | function AccountAvatar({ | |
| 67 | account, | |
| 68 | className, | |
| 69 | }: { | |
| 70 | readonly account: LimitAccount; | |
| 71 | readonly className?: string; | |
| 72 | }) { | |
| 73 | if (account.redeem) { | |
| 74 | return ( | |
| 75 | <ProviderInstanceIcon | |
| 76 | driverKind={account.driver} | |
| 77 | displayName={ | |
| 78 | account.displayName ?? getDriverOption(account.driver)?.label ?? String(account.driver) | |
| 79 | } | |
| 80 | accentColor={account.accentColor} | |
| 81 | showBadge={Boolean(account.displayName)} | |
| 82 | indicatorBackground="var(--popover)" | |
| 83 | className={cn("size-5", className)} | |
| 84 | iconClassName="size-4 text-foreground/80" | |
| 85 | /> | |
| 86 | ); | |
| 87 | } | |
| 88 | return account.email ? <AccountChip email={account.email} /> : null; | |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * Who an account is, without printing the email: the instance name when there | |
| 93 | * is one, else a two-letter chip. The address itself is revealed on demand in | |
| 94 | * the segment's popover. | |
| 95 | */ | |
| 96 | function AccountName({ | |
| 97 | account, | |
| 98 | className, | |
| 99 | }: { | |
| 100 | readonly account: LimitAccount; | |
| 101 | readonly className?: string; | |
| 102 | }) { | |
| 103 | if (account.displayName) return <span className={className}>{account.displayName}</span>; | |
| 104 | if (account.email) { | |
| 105 | return ( | |
| 106 | <span className={cn("inline-flex min-w-0 items-center", className)}> | |
| 107 | <AccountChip email={account.email} /> | |
| 108 | </span> | |
| 109 | ); | |
| 110 | } | |
| 111 | return ( | |
| 112 | <span className={className}> | |
| 113 | {getDriverOption(account.driver)?.label ?? String(account.driver)} | |
| 114 | </span> | |
| 115 | ); | |
| 116 | } | |
| 117 | ||
| 118 | function Row({ label, children }: { readonly label: string; readonly children: ReactNode }) { | |
| 119 | return ( | |
| 120 | <div className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-x-3"> | |
| 121 | <span className="text-muted-foreground">{label}</span> | |
| 122 | <span className="min-w-0 text-foreground tabular-nums">{children}</span> | |
| 123 | </div> | |
| 124 | ); | |
| 125 | } | |
| 126 | ||
| 127 | /** | |
| 128 | * Everything about one account in one window: plan, where it is signed in, | |
| 129 | * the email on request, reset time and share of the pool it restores, and the | |
| 130 | * reset-credit action. Opens on hover for a glance, on click to act. | |
| 131 | */ | |
| 132 | function SegmentPopover({ | |
| 133 | account, | |
| 134 | window, | |
| 135 | reset, | |
| 136 | now, | |
| 137 | redeem, | |
| 138 | onRedeem, | |
| 139 | }: { | |
| 140 | readonly account: LimitAccount; | |
| 141 | readonly window: LimitPoolMember["window"]; | |
| 142 | readonly reset: LimitPoolWindow["resets"][number] | undefined; | |
| 143 | readonly now: number; | |
| 144 | /** Redeem state owned by the segment, since the confirm lives outside this popover. */ | |
| 145 | readonly redeem: ReturnType<typeof useResetCredit> | null; | |
| 146 | readonly onRedeem: () => void; | |
| 147 | }) { | |
| 148 | const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); | |
| 149 | const remaining = remainingPercent(window); | |
| 150 | const resetsIn = formatResetsIn(window, now); | |
| 151 | const where = | |
| 152 | account.environments.length > 0 | |
| 153 | ? account.environments.map((environment) => environment.label).join(", ") | |
| 154 | : account.sourceLabel; | |
| 155 | const credits = | |
| 156 | redeem && account.limits.resetCredits?.availableCount ? account.limits.resetCredits : null; | |
| 157 | return ( | |
| 158 | <div className="flex w-72 max-w-[calc(100vw-3rem)] flex-col gap-2.5 text-xs"> | |
| 159 | <div className="flex min-w-0 flex-col gap-0.5"> | |
| 160 | <span className="flex items-center gap-2 text-sm font-medium text-foreground"> | |
| 161 | <AccountAvatar account={account} /> | |
| 162 | <span className="truncate"> | |
| 163 | {account.displayName ?? getDriverOption(account.driver)?.label ?? account.driver} | |
| 164 | </span> | |
| 165 | </span> | |
| 166 | {account.email ? ( | |
| 167 | <RedactedSensitiveText | |
| 168 | value={account.email} | |
| 169 | ariaLabel="Toggle account email visibility" | |
| 170 | revealTooltip="Click to reveal email" | |
| 171 | hideTooltip="Click to hide email" | |
| 172 | className="w-fit" | |
| 173 | /> | |
| 174 | ) : null} | |
| 175 | </div> | |
| 176 | <div className="flex flex-col gap-1 border-t border-border/60 pt-2.5"> | |
| 177 | {account.plan ? <Row label="Plan">{account.plan}</Row> : null} | |
| 178 | {where ? ( | |
| 179 | <Row label={account.environments.length > 0 ? "Signed in" : "Via"}>{where}</Row> | |
| 180 | ) : null} | |
| 181 | </div> | |
| 182 | <div className="flex flex-col gap-1 border-t border-border/60 pt-2.5"> | |
| 183 | <Row label="Left">{remaining}%</Row> | |
| 184 | {window.resetsAt ? ( | |
| 185 | <Row label="Resets"> | |
| 186 | {formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)} | |
| 187 | {resetsIn ? ` · ${resetsIn.replace("resets in ", "in ")}` : ""} | |
| 188 | </Row> | |
| 189 | ) : null} | |
| 190 | {reset && reset.restoresPercent > 0 ? ( | |
| 191 | <Row label="Restores">+{reset.restoresPercent}% of pool</Row> | |
| 192 | ) : null} | |
| 193 | </div> | |
| 194 | {credits && redeem ? ( | |
| 195 | <div className="border-t border-border/60 pt-2.5 text-muted-foreground"> | |
| 196 | <span className="flex items-center gap-3"> | |
| 197 | <span className="tabular-nums">{resetCreditsSummary(credits, now, true)}</span> | |
| 198 | <Button | |
| 199 | size="xs" | |
| 200 | variant="outline" | |
| 201 | disabled={redeem.busy} | |
| 202 | className="ms-auto" | |
| 203 | onClick={onRedeem} | |
| 204 | > | |
| 205 | {redeem.busy ? "Using…" : "Use reset"} | |
| 206 | </Button> | |
| 207 | </span> | |
| 208 | </div> | |
| 209 | ) : null} | |
| 210 | </div> | |
| 211 | ); | |
| 212 | } | |
| 213 | ||
| 214 | /** | |
| 215 | * One account's share of one pooled window: the segment, its popover, and the | |
| 216 | * reset confirm. The confirm is a sibling of the popover, not a child: dialogs | |
| 217 | * stack under popovers, and the popover closes as the confirm opens. | |
| 218 | */ | |
| 219 | function PoolSegment({ | |
| 220 | account, | |
| 221 | window, | |
| 222 | reset, | |
| 223 | color, | |
| 224 | now, | |
| 225 | index, | |
| 226 | }: { | |
| 227 | readonly account: LimitAccount; | |
| 228 | readonly window: LimitPoolMember["window"]; | |
| 229 | readonly reset: LimitPoolWindow["resets"][number] | undefined; | |
| 230 | readonly color: string; | |
| 231 | readonly now: number; | |
| 232 | /** 1-based position in the bar, shown on the strip and its legend row to tie them together. */ | |
| 233 | readonly index: number; | |
| 234 | }) { | |
| 235 | const [open, setOpen] = useState(false); | |
| 236 | const remaining = remainingPercent(window); | |
| 237 | const resetsIn = formatResetsIn(window, now); | |
| 238 | const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0; | |
| 239 | return ( | |
| 240 | <Popover open={open} onOpenChange={setOpen}> | |
| 241 | <PopoverTrigger | |
| 242 | openOnHover | |
| 243 | render={ | |
| 244 | <button | |
| 245 | type="button" | |
| 246 | style={{ gridColumn: index, gridRow: 1 }} | |
| 247 | aria-label={`${account.displayName ?? (account.email ? accountInitials(account.email) : account.driver)}: ${remaining}% left${resetsIn ? `, ${resetsIn}` : ""}${credits ? `, ${credits} reset ${credits === 1 ? "credit" : "credits"} banked` : ""}`} | |
| 248 | className="relative h-5 min-w-0 cursor-pointer overflow-hidden rounded-md bg-muted text-start outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[popup-open]:ring-1 data-[popup-open]:ring-border @2xl/pool:h-8" | |
| 249 | /> | |
| 250 | } | |
| 251 | > | |
| 252 | {/* Translucent so the label reads over the fill for any provider colour and theme. */} | |
| 253 | <div | |
| 254 | aria-hidden | |
| 255 | className="absolute inset-y-0 left-0 rounded-md opacity-35" | |
| 256 | style={{ width: `${remaining}%`, backgroundColor: color }} | |
| 257 | /> | |
| 258 | {/* The spent share is hatched, not blank: it is what the countdown restores. */} | |
| 259 | {remaining < 100 && reset ? ( | |
| 260 | <div | |
| 261 | aria-hidden | |
| 262 | className="absolute inset-y-0 right-0 opacity-20" | |
| 263 | style={{ | |
| 264 | width: `${100 - remaining}%`, | |
| 265 | backgroundImage: `repeating-linear-gradient(135deg, ${color} 0 1px, transparent 1px 5px)`, | |
| 266 | }} | |
| 267 | /> | |
| 268 | ) : null} | |
| 269 | <span | |
| 270 | aria-hidden | |
| 271 | className="absolute inset-0 flex items-center justify-center text-[10px] leading-none font-semibold text-foreground/80 tabular-nums @2xl/pool:hidden" | |
| 272 | > | |
| 273 | {index} | |
| 274 | </span> | |
| 275 | <div className="relative hidden h-full min-w-0 items-center gap-1.5 px-2 text-xs @2xl/pool:flex"> | |
| 276 | <AccountName account={account} className="min-w-0 truncate font-medium text-foreground" /> | |
| 277 | <span className="shrink-0 font-semibold text-foreground tabular-nums">{remaining}%</span> | |
| 278 | {/* Countdown and badge get their own plate: fill and hatching run under them otherwise. */} | |
| 279 | <span className="ms-auto flex shrink-0 items-center gap-1.5 rounded-sm bg-background/85 px-1.5 py-0.5 text-[11px] text-foreground tabular-nums"> | |
| 280 | {resetsIn?.replace("resets in ", "↻ ") ?? ""} | |
| 281 | {credits ? ( | |
| 282 | <> | |
| 283 | {resetsIn ? ( | |
| 284 | <span aria-hidden className="text-muted-foreground"> | |
| 285 | · | |
| 286 | </span> | |
| 287 | ) : null} | |
| 288 | <span aria-hidden className="inline-flex items-center gap-0.5 font-semibold"> | |
| 289 | <TicketIcon className="size-3" aria-hidden /> | |
| 290 | {credits} | |
| 291 | </span> | |
| 292 | </> | |
| 293 | ) : null} | |
| 294 | </span> | |
| 295 | </div> | |
| 296 | </PopoverTrigger> | |
| 297 | <LegendRow account={account} window={window} color={color} now={now} index={index} /> | |
| 298 | {account.redeem ? ( | |
| 299 | <RedeemableSegmentPopup | |
| 300 | account={account} | |
| 301 | window={window} | |
| 302 | reset={reset} | |
| 303 | now={now} | |
| 304 | redeemAt={account.redeem} | |
| 305 | closePopover={() => setOpen(false)} | |
| 306 | /> | |
| 307 | ) : ( | |
| 308 | <PopoverPopup side="top" sideOffset={6}> | |
| 309 | <SegmentPopover | |
| 310 | account={account} | |
| 311 | window={window} | |
| 312 | reset={reset} | |
| 313 | now={now} | |
| 314 | redeem={null} | |
| 315 | onRedeem={() => {}} | |
| 316 | /> | |
| 317 | </PopoverPopup> | |
| 318 | )} | |
| 319 | </Popover> | |
| 320 | ); | |
| 321 | } | |
| 322 | ||
| 323 | /** | |
| 324 | * Below the strip at narrow widths: one row per account in bar order, carrying | |
| 325 | * the text the segment has no room for. Tapping a row opens the same popover | |
| 326 | * as its segment, so the two are one control with two handles. | |
| 327 | */ | |
| 328 | function LegendRow({ | |
| 329 | account, | |
| 330 | window, | |
| 331 | color, | |
| 332 | now, | |
| 333 | index, | |
| 334 | }: { | |
| 335 | readonly account: LimitAccount; | |
| 336 | readonly window: LimitPoolMember["window"]; | |
| 337 | readonly color: string; | |
| 338 | readonly now: number; | |
| 339 | readonly index: number; | |
| 340 | }) { | |
| 341 | const remaining = remainingPercent(window); | |
| 342 | const resetsIn = formatResetsIn(window, now); | |
| 343 | const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0; | |
| 344 | return ( | |
| 345 | <PopoverTrigger | |
| 346 | style={{ gridColumn: "1 / -1", gridRow: index + 1 }} | |
| 347 | className="flex min-h-7 min-w-0 cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-start text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring @2xl/pool:hidden" | |
| 348 | > | |
| 349 | <span className="relative inline-flex size-4 shrink-0 items-center justify-center rounded-sm text-[10px] leading-none font-semibold text-foreground/80 tabular-nums"> | |
| 350 | <span | |
| 351 | aria-hidden | |
| 352 | className="absolute inset-0 rounded-sm opacity-35" | |
| 353 | style={{ backgroundColor: color }} | |
| 354 | /> | |
| 355 | <span className="sr-only">Segment </span> | |
| 356 | <span className="relative">{index}</span> | |
| 357 | </span> | |
| 358 | <AccountName account={account} className="min-w-0 truncate font-medium text-foreground" /> | |
| 359 | <span className="shrink-0 font-semibold text-foreground tabular-nums">{remaining}%</span> | |
| 360 | <span className="ms-auto flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground tabular-nums"> | |
| 361 | {resetsIn?.replace("resets in ", "↻ ") ?? ""} | |
| 362 | {credits ? ( | |
| 363 | <> | |
| 364 | {resetsIn ? <span aria-hidden>·</span> : null} | |
| 365 | <span | |
| 366 | aria-hidden | |
| 367 | className="inline-flex items-center gap-0.5 font-semibold text-foreground" | |
| 368 | > | |
| 369 | <TicketIcon className="size-3" aria-hidden /> | |
| 370 | {credits} | |
| 371 | </span> | |
| 372 | <span className="sr-only"> | |
| 373 | {credits} reset {credits === 1 ? "credit" : "credits"} banked | |
| 374 | </span> | |
| 375 | </> | |
| 376 | ) : null} | |
| 377 | </span> | |
| 378 | </PopoverTrigger> | |
| 379 | ); | |
| 380 | } | |
| 381 | ||
| 382 | /** Split out so the redeem hook only runs for accounts that can redeem. */ | |
| 383 | function RedeemableSegmentPopup({ | |
| 384 | account, | |
| 385 | window, | |
| 386 | reset, | |
| 387 | now, | |
| 388 | redeemAt, | |
| 389 | closePopover, | |
| 390 | }: { | |
| 391 | readonly account: LimitAccount; | |
| 392 | readonly window: LimitPoolMember["window"]; | |
| 393 | readonly reset: LimitPoolWindow["resets"][number] | undefined; | |
| 394 | readonly now: number; | |
| 395 | readonly redeemAt: NonNullable<LimitAccount["redeem"]>; | |
| 396 | readonly closePopover: () => void; | |
| 397 | }) { | |
| 398 | const redeem = useResetCredit(redeemAt.environmentId, redeemAt.instanceId); | |
| 399 | return ( | |
| 400 | <> | |
| 401 | <PopoverPopup side="top" sideOffset={6}> | |
| 402 | <SegmentPopover | |
| 403 | account={account} | |
| 404 | window={window} | |
| 405 | reset={reset} | |
| 406 | now={now} | |
| 407 | redeem={redeem} | |
| 408 | onRedeem={() => { | |
| 409 | closePopover(); | |
| 410 | redeem.setConfirming(true); | |
| 411 | }} | |
| 412 | /> | |
| 413 | </PopoverPopup> | |
| 414 | <ResetCreditDialog | |
| 415 | open={redeem.confirming} | |
| 416 | onOpenChange={redeem.setConfirming} | |
| 417 | onConfirm={() => void redeem.redeem()} | |
| 418 | /> | |
| 419 | {/* The popover closed before the confirm, so the outcome needs a home outside it. */} | |
| 420 | {redeem.status ? ( | |
| 421 | <span role="status" className="col-span-full text-xs text-muted-foreground"> | |
| 422 | <AccountName account={account} className="font-medium text-foreground" /> {redeem.status} | |
| 423 | </span> | |
| 424 | ) : null} | |
| 425 | </> | |
| 426 | ); | |
| 427 | } | |
| 428 | ||
| 429 | /** | |
| 430 | * One pooled window as equal-width segments, one per account, each filled by | |
| 431 | * the share of that account's quota still open. Equal widths are honest: every | |
| 432 | * account contributes the same share of the pool, whatever its plan. | |
| 433 | * | |
| 434 | * Wide, each segment carries its own label. Narrow, the bar is a bare strip | |
| 435 | * and a legend below lists the accounts in the same order; both open the | |
| 436 | * same popover. | |
| 437 | */ | |
| 438 | function PoolBar({ | |
| 439 | pool, | |
| 440 | color, | |
| 441 | now, | |
| 442 | }: { | |
| 443 | readonly pool: LimitPoolWindow; | |
| 444 | readonly color: string; | |
| 445 | readonly now: number; | |
| 446 | }) { | |
| 447 | const restores = new Map(pool.resets.map((reset) => [reset.member.account.key, reset])); | |
| 448 | return ( | |
| 449 | <div className="@container/pool min-w-0"> | |
| 450 | <div | |
| 451 | className="grid gap-x-1 gap-y-1" | |
| 452 | style={{ gridTemplateColumns: `repeat(${pool.members.length}, minmax(0, 1fr))` }} | |
| 453 | > | |
| 454 | {pool.members.map(({ account, window }, position) => ( | |
| 455 | <PoolSegment | |
| 456 | key={account.key} | |
| 457 | account={account} | |
| 458 | window={window} | |
| 459 | reset={restores.get(account.key)} | |
| 460 | color={color} | |
| 461 | now={now} | |
| 462 | index={position + 1} | |
| 463 | /> | |
| 464 | ))} | |
| 465 | </div> | |
| 466 | </div> | |
| 467 | ); | |
| 468 | } | |
| 469 | ||
| 470 | /** | |
| 471 | * Big pooled number and the segment bar. The bar is sorted by reset, so who | |
| 472 | * refills next is its left edge; the exact time and share restored live in | |
| 473 | * each segment's popover rather than a list restating the bar. | |
| 474 | */ | |
| 475 | function PoolWindowCard({ | |
| 476 | pool, | |
| 477 | color, | |
| 478 | now, | |
| 479 | }: { | |
| 480 | readonly pool: LimitPoolWindow; | |
| 481 | readonly color: string; | |
| 482 | readonly now: number; | |
| 483 | }) { | |
| 484 | // The soonest reset that hands anything back; an untouched account resets to no effect. | |
| 485 | const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); | |
| 486 | return ( | |
| 487 | <div className="grid items-center gap-x-6 gap-y-3 rounded-lg border border-border/60 p-4 md:grid-cols-[11rem_minmax(0,1fr)]"> | |
| 488 | <div className="flex flex-col gap-1"> | |
| 489 | <span className="text-sm font-medium text-foreground">{pool.label}</span> | |
| 490 | <span className="flex items-baseline gap-2"> | |
| 491 | <span className="text-3xl font-semibold text-foreground tabular-nums"> | |
| 492 | {pool.remainingPercent}% | |
| 493 | </span> | |
| 494 | <span className="text-sm text-muted-foreground">left</span> | |
| 495 | {pool.pace ? <PaceIcon pace={pool.pace} /> : null} | |
| 496 | </span> | |
| 497 | {nextRefill ? ( | |
| 498 | <span className="text-xs text-muted-foreground tabular-nums"> | |
| 499 | <span className="font-medium text-foreground">↻ +{nextRefill.restoresPercent}%</span>{" "} | |
| 500 | {nextRefill.at <= now ? "now" : `in ${formatDuration(nextRefill.at - now)}`} | |
| 501 | </span> | |
| 502 | ) : null} | |
| 503 | </div> | |
| 504 | <PoolBar pool={pool} color={color} now={now} /> | |
| 505 | </div> | |
| 506 | ); | |
| 507 | } | |
| 508 | ||
| 509 | function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) { | |
| 510 | const color = barColor(pool.driver); | |
| 511 | const label = getDriverOption(pool.driver)?.label ?? String(pool.driver); | |
| 512 | return ( | |
| 513 | <section className="flex flex-col gap-3"> | |
| 514 | <h2 className="flex items-center gap-2 text-sm font-medium text-foreground"> | |
| 515 | <ProviderInstanceIcon | |
| 516 | driverKind={pool.driver} | |
| 517 | displayName={label} | |
| 518 | indicatorBackground="var(--background)" | |
| 519 | className="size-5" | |
| 520 | iconClassName="size-4 text-foreground/80" | |
| 521 | /> | |
| 522 | {label} | |
| 523 | </h2> | |
| 524 | {pool.windows.map((window) => ( | |
| 525 | <PoolWindowCard key={`${window.kind}:${window.id}`} pool={window} color={color} now={now} /> | |
| 526 | ))} | |
| 527 | </section> | |
| 528 | ); | |
| 529 | } | |
| 530 | ||
| 531 | /** | |
| 532 | * Accounts pooled per provider: what is open across all of them, who resets | |
| 533 | * next, and how much of the pool that hands back. Answers "can I keep going" | |
| 534 | * before "on which account". | |
| 535 | */ | |
| 536 | export function UsageLimitsPooled({ | |
| 537 | presentations, | |
| 538 | now, | |
| 539 | }: { | |
| 540 | readonly presentations: Parameters<typeof collectLimitAccounts>[0]; | |
| 541 | readonly now: number; | |
| 542 | }) { | |
| 543 | const pools = collectLimitPools(collectLimitAccounts(presentations), now); | |
| 544 | const notices = collectLimitNotices(presentations); | |
| 545 | return ( | |
| 546 | <div className="flex flex-col gap-8"> | |
| 547 | {pools.length === 0 ? ( | |
| 548 | <p className="text-sm text-muted-foreground"> | |
| 549 | No provider on the selected environments reports subscription limits. | |
| 550 | </p> | |
| 551 | ) : null} | |
| 552 | {pools.map((pool) => ( | |
| 553 | <PoolSection key={pool.driver} pool={pool} now={now} /> | |
| 554 | ))} | |
| 555 | <LimitNotices notices={notices} /> | |
| 556 | </div> | |
| 557 | ); | |
| 558 | } | |
| 559 | ||
| 560 | /** Sources and providers that could not be read, so a missing bar is not mistaken for a full one. */ | |
| 561 | function LimitNotices({ notices }: { readonly notices: readonly string[] }) { | |
| 562 | if (notices.length === 0) return null; | |
| 563 | return ( | |
| 564 | <ul className="flex flex-col gap-1 text-xs text-muted-foreground"> | |
| 565 | {notices.map((notice) => ( | |
| 566 | <li key={notice}>{notice}</li> | |
| 567 | ))} | |
| 568 | </ul> | |
| 569 | ); | |
| 570 | } |
apps/web/src/components/usage/UsagePage.tsx+34-5| ... | ... | @@ -59,6 +59,7 @@ import { |
| 59 | 59 | import { WorkspacePageContainer } from "../WorkspacePageContainer"; |
| 60 | 60 | import { WorkspacePageHeader } from "../WorkspacePageHeader"; |
| 61 | 61 | import { UsageLimitsSection } from "./UsageLimits"; |
| 62 | import { makeLimitsFixture } from "./usageLimitsFixture"; | |
| 62 | 63 | import { UsagePriceOverrides } from "./UsagePriceOverrides"; |
| 63 | 64 | import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; |
| 64 | 65 | import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; |
| ... | ... | @@ -109,10 +110,33 @@ export function UsagePage() { |
| 109 | 110 | useState<ReadonlySet<EnvironmentId> | null>(null); |
| 110 | 111 | const { days: windowDays, window } = windowSelection; |
| 111 | 112 | const isPast24Hours = windowDays === 1; |
| 112 | const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage( | |
| 113 | window, | |
| 114 | selectedEnvironmentIds, | |
| 115 | ); | |
| 113 | const usage = useUsage(window, selectedEnvironmentIds); | |
| 114 | // Dev only: `/usage?limitsFixture=<name>` lists synthetic environments in the | |
| 115 | // picker and feeds the Limits view from them, so merge rules can be eyeballed. | |
| 116 | const [fixture] = useState(() => { | |
| 117 | if (!import.meta.env.DEV) return null; | |
| 118 | const name = new URLSearchParams(globalThis.location.search).get("limitsFixture"); | |
| 119 | return name ? makeLimitsFixture(name, Date.now()) : null; | |
| 120 | }); | |
| 121 | const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = | |
| 122 | useMemo(() => { | |
| 123 | if (!fixture || !showingLimits) return usage; | |
| 124 | const all = [...fixture].map(([environmentId, presentation]) => ({ | |
| 125 | environmentId, | |
| 126 | label: presentation.entry.target.label, | |
| 127 | isPending: false, | |
| 128 | error: null, | |
| 129 | summary: null, | |
| 130 | })); | |
| 131 | return { | |
| 132 | ...usage, | |
| 133 | environments: all, | |
| 134 | selectedEnvironments: | |
| 135 | selectedEnvironmentIds === null | |
| 136 | ? all | |
| 137 | : all.filter((environment) => selectedEnvironmentIds.has(environment.environmentId)), | |
| 138 | }; | |
| 139 | }, [fixture, selectedEnvironmentIds, showingLimits, usage]); | |
| 116 | 140 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); |
| 117 | 141 | const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { |
| 118 | 142 | reportFailure: false, |
| ... | ... | @@ -166,6 +190,8 @@ export function UsagePage() { |
| 166 | 190 | if (refreshingRef.current) return; |
| 167 | 191 | |
| 168 | 192 | if (showingLimits) { |
| 193 | // Synthetic data has nothing to re-read. | |
| 194 | if (fixture) return; | |
| 169 | 195 | refreshingRef.current = true; |
| 170 | 196 | setIsRefreshing(true); |
| 171 | 197 | void Promise.all( |
| ... | ... | @@ -349,7 +375,10 @@ export function UsagePage() { |
| 349 | 375 | : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`} |
| 350 | 376 | </p> |
| 351 | 377 | ) : showingLimits ? ( |
| 352 | <UsageLimitsSection selectedEnvironmentIds={selectedEnvironmentIds} /> | |
| 378 | <UsageLimitsSection | |
| 379 | selectedEnvironmentIds={selectedEnvironmentIds} | |
| 380 | fixture={fixture} | |
| 381 | /> | |
| 353 | 382 | ) : isPending ? ( |
| 354 | 383 | <UsageSkeleton /> |
| 355 | 384 | ) : ( |
apps/web/src/components/usage/usageLimitsFixture.ts created+413| ... | ... | @@ -0,0 +1,413 @@ |
| 1 | /** | |
| 2 | * Dev-only stand-ins for `environmentPresentations` that exercise the pooled | |
| 3 | * Limits view's merge rules, one scenario per named fixture. Reached with | |
| 4 | * `/usage?limitsFixture=<name>` on a dev build; never bundled otherwise. | |
| 5 | */ | |
| 6 | import { | |
| 7 | EnvironmentId, | |
| 8 | ProviderDriverKind, | |
| 9 | ProviderInstanceId, | |
| 10 | type ServerProvider, | |
| 11 | type ServerProviderUsageWindow, | |
| 12 | type UsageLimitSourceSnapshot, | |
| 13 | UsageLimitSourceId, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | ||
| 16 | const MINUTE = 60_000; | |
| 17 | const HOUR = 60 * MINUTE; | |
| 18 | const DAY = 24 * HOUR; | |
| 19 | ||
| 20 | interface Presentation { | |
| 21 | readonly entry: { readonly target: { readonly label: string } }; | |
| 22 | readonly serverConfig: { | |
| 23 | readonly providers: readonly ServerProvider[]; | |
| 24 | readonly usageLimitSources: readonly UsageLimitSourceSnapshot[]; | |
| 25 | }; | |
| 26 | } | |
| 27 | ||
| 28 | type Fixture = ReadonlyMap<EnvironmentId, Presentation>; | |
| 29 | ||
| 30 | const codex = ProviderDriverKind.make("codex"); | |
| 31 | const claude = ProviderDriverKind.make("claudeAgent"); | |
| 32 | ||
| 33 | function makeHelpers(now: number) { | |
| 34 | const at = (ms: number) => new Date(now + ms).toISOString(); | |
| 35 | const checked = (agoMs: number) => new Date(now - agoMs).toISOString(); | |
| 36 | ||
| 37 | const session = (used: number, resetsInMs: number): ServerProviderUsageWindow => ({ | |
| 38 | id: "five_hour", | |
| 39 | kind: "session", | |
| 40 | label: "Session", | |
| 41 | usedPercent: used, | |
| 42 | windowDurationMins: 300, | |
| 43 | resetsAt: at(resetsInMs), | |
| 44 | }); | |
| 45 | const weekly = ( | |
| 46 | id: string, | |
| 47 | label: string, | |
| 48 | used: number, | |
| 49 | resetsInMs: number, | |
| 50 | ): ServerProviderUsageWindow => ({ | |
| 51 | id, | |
| 52 | kind: "weekly", | |
| 53 | label, | |
| 54 | usedPercent: used, | |
| 55 | windowDurationMins: 7 * 24 * 60, | |
| 56 | resetsAt: at(resetsInMs), | |
| 57 | }); | |
| 58 | /** Codex names its five-hour window `primary` and its weekly one `secondary`. */ | |
| 59 | const codexSession = (used: number, resetsInMs: number): ServerProviderUsageWindow => ({ | |
| 60 | ...session(used, resetsInMs), | |
| 61 | id: "primary", | |
| 62 | }); | |
| 63 | const codexWeekly = (used: number, resetsInMs: number) => | |
| 64 | weekly("secondary", "Weekly", used, resetsInMs); | |
| 65 | ||
| 66 | const provider = ( | |
| 67 | overrides: Partial<ServerProvider> & Pick<ServerProvider, "instanceId" | "driver">, | |
| 68 | ): ServerProvider => ({ | |
| 69 | enabled: true, | |
| 70 | installed: true, | |
| 71 | version: null, | |
| 72 | status: "ready", | |
| 73 | auth: { status: "authenticated" }, | |
| 74 | checkedAt: checked(0), | |
| 75 | models: [], | |
| 76 | slashCommands: [], | |
| 77 | skills: [], | |
| 78 | ...overrides, | |
| 79 | }); | |
| 80 | ||
| 81 | const codexInstance = (input: { | |
| 82 | readonly instanceId: string; | |
| 83 | readonly displayName?: string; | |
| 84 | readonly accentColor?: string; | |
| 85 | readonly email: string; | |
| 86 | readonly plan?: string; | |
| 87 | readonly checkedAgoMs?: number; | |
| 88 | readonly windows: readonly ServerProviderUsageWindow[]; | |
| 89 | readonly credits?: number; | |
| 90 | }) => | |
| 91 | provider({ | |
| 92 | instanceId: ProviderInstanceId.make(input.instanceId), | |
| 93 | driver: codex, | |
| 94 | ...(input.displayName ? { displayName: input.displayName } : {}), | |
| 95 | ...(input.accentColor ? { accentColor: input.accentColor } : {}), | |
| 96 | auth: { | |
| 97 | status: "authenticated", | |
| 98 | label: input.plan ?? "ChatGPT Pro 20x Subscription", | |
| 99 | email: input.email, | |
| 100 | }, | |
| 101 | usageLimits: { | |
| 102 | checkedAt: checked(input.checkedAgoMs ?? MINUTE), | |
| 103 | windows: input.windows, | |
| 104 | ...(input.credits | |
| 105 | ? { resetCredits: { availableCount: input.credits, nextExpiresAt: at(28 * DAY) } } | |
| 106 | : {}), | |
| 107 | }, | |
| 108 | }); | |
| 109 | ||
| 110 | const claudeHubAccount = ( | |
| 111 | email: string | null, | |
| 112 | windows: readonly ServerProviderUsageWindow[], | |
| 113 | checkedAgoMs = 4 * MINUTE, | |
| 114 | ): UsageLimitSourceSnapshot["accounts"][number] => ({ | |
| 115 | id: email ? `claude-${email}.json` : "claude-team-seat.json", | |
| 116 | driver: claude, | |
| 117 | ...(email ? { email } : {}), | |
| 118 | plan: "Claude Subscription", | |
| 119 | usageLimits: { checkedAt: checked(checkedAgoMs), windows }, | |
| 120 | }); | |
| 121 | ||
| 122 | const hub = ( | |
| 123 | id: string, | |
| 124 | label: string, | |
| 125 | accounts: UsageLimitSourceSnapshot["accounts"], | |
| 126 | error?: string, | |
| 127 | ): UsageLimitSourceSnapshot => ({ | |
| 128 | id: UsageLimitSourceId.make(id), | |
| 129 | kind: "cliproxy", | |
| 130 | label, | |
| 131 | checkedAt: checked(2 * MINUTE), | |
| 132 | accounts, | |
| 133 | ...(error ? { error } : {}), | |
| 134 | }); | |
| 135 | ||
| 136 | const environment = ( | |
| 137 | id: string, | |
| 138 | label: string, | |
| 139 | providers: readonly ServerProvider[], | |
| 140 | usageLimitSources: readonly UsageLimitSourceSnapshot[] = [], | |
| 141 | ): readonly [EnvironmentId, Presentation] => [ | |
| 142 | EnvironmentId.make(id), | |
| 143 | { entry: { target: { label } }, serverConfig: { providers, usageLimitSources } }, | |
| 144 | ]; | |
| 145 | ||
| 146 | return { | |
| 147 | at, | |
| 148 | checked, | |
| 149 | session, | |
| 150 | weekly, | |
| 151 | codexSession, | |
| 152 | codexWeekly, | |
| 153 | provider, | |
| 154 | codexInstance, | |
| 155 | claudeHubAccount, | |
| 156 | hub, | |
| 157 | environment, | |
| 158 | }; | |
| 159 | } | |
| 160 | ||
| 161 | const FIXTURES: Record<string, (now: number) => Fixture> = { | |
| 162 | /** | |
| 163 | * The same Codex account signed in on two machines with different snapshot | |
| 164 | * ages, plus a hub that also reports it. Must collapse to one segment with | |
| 165 | * the freshest figures and both machines listed. | |
| 166 | */ | |
| 167 | "same-account": (now) => { | |
| 168 | const h = makeHelpers(now); | |
| 169 | const email = "main@example.com"; | |
| 170 | const hubAccounts: UsageLimitSourceSnapshot["accounts"] = [ | |
| 171 | { | |
| 172 | id: `codex-abc-${email}-pro.json`, | |
| 173 | driver: codex, | |
| 174 | email, | |
| 175 | plan: "ChatGPT Pro 20x Subscription", | |
| 176 | usageLimits: { checkedAt: h.checked(14 * MINUTE), windows: [h.codexWeekly(70, 5 * DAY)] }, | |
| 177 | }, | |
| 178 | ]; | |
| 179 | return new Map([ | |
| 180 | h.environment( | |
| 181 | "env-macbook", | |
| 182 | "MacBook Pro", | |
| 183 | [ | |
| 184 | h.codexInstance({ | |
| 185 | instanceId: "codex", | |
| 186 | displayName: "Codex Personal", | |
| 187 | accentColor: "#6366f1", | |
| 188 | email, | |
| 189 | windows: [h.codexSession(10, 3 * HOUR), h.codexWeekly(66, 5 * DAY)], | |
| 190 | credits: 2, | |
| 191 | }), | |
| 192 | ], | |
| 193 | [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)], | |
| 194 | ), | |
| 195 | h.environment("env-nucbox", "nucbox-1", [ | |
| 196 | h.codexInstance({ | |
| 197 | instanceId: "codex", | |
| 198 | email, | |
| 199 | checkedAgoMs: 9 * MINUTE, | |
| 200 | windows: [h.codexSession(30, 3 * HOUR), h.codexWeekly(60, 5 * DAY)], | |
| 201 | credits: 2, | |
| 202 | }), | |
| 203 | ]), | |
| 204 | ]); | |
| 205 | }, | |
| 206 | ||
| 207 | /** | |
| 208 | * Three machines, no two alike: one has only Codex, one only Claude via a | |
| 209 | * hub, one has both natively. Filtering to any single environment should | |
| 210 | * drop whole provider sections. | |
| 211 | */ | |
| 212 | "uneven-environments": (now) => { | |
| 213 | const h = makeHelpers(now); | |
| 214 | return new Map([ | |
| 215 | h.environment("env-macbook", "MacBook Pro", [ | |
| 216 | h.codexInstance({ | |
| 217 | instanceId: "codex", | |
| 218 | displayName: "Codex Personal", | |
| 219 | accentColor: "#6366f1", | |
| 220 | email: "main@example.com", | |
| 221 | windows: [h.codexSession(10, 3 * HOUR), h.codexWeekly(66, 5 * DAY)], | |
| 222 | credits: 2, | |
| 223 | }), | |
| 224 | h.provider({ | |
| 225 | instanceId: ProviderInstanceId.make("claude"), | |
| 226 | driver: claude, | |
| 227 | auth: { status: "authenticated", label: "Claude Max", email: "main@example.com" }, | |
| 228 | usageLimits: { | |
| 229 | checkedAt: h.checked(MINUTE), | |
| 230 | windows: [ | |
| 231 | h.session(2, 4 * HOUR), | |
| 232 | h.weekly("seven_day", "Weekly", 38, 4 * DAY), | |
| 233 | h.weekly("seven_day_fable", "Weekly · Fable", 69, 4 * DAY), | |
| 234 | ], | |
| 235 | }, | |
| 236 | }), | |
| 237 | ]), | |
| 238 | h.environment( | |
| 239 | "env-nucbox", | |
| 240 | "nucbox-1", | |
| 241 | [], | |
| 242 | [ | |
| 243 | h.hub("cliproxy-nucbox", "CLI Proxy", [ | |
| 244 | h.claudeHubAccount("personal@example.com", [ | |
| 245 | h.session(100, 4 * HOUR), | |
| 246 | h.weekly("seven_day", "Weekly", 50, DAY), | |
| 247 | h.weekly("seven_day_fable", "Weekly · Fable", 96, DAY), | |
| 248 | ]), | |
| 249 | h.claudeHubAccount("second@example.org", [ | |
| 250 | h.session(63, 2 * HOUR), | |
| 251 | h.weekly("seven_day", "Weekly", 37, 4 * DAY), | |
| 252 | h.weekly("seven_day_fable", "Weekly · Fable", 72, 4 * DAY), | |
| 253 | ]), | |
| 254 | ]), | |
| 255 | ], | |
| 256 | ), | |
| 257 | h.environment("env-macmini", "Mac Mini", [ | |
| 258 | h.codexInstance({ | |
| 259 | instanceId: "codex", | |
| 260 | displayName: "Codex Work", | |
| 261 | email: "work@example.com", | |
| 262 | windows: [h.codexSession(0, 5 * HOUR), h.codexWeekly(95, 5 * DAY)], | |
| 263 | credits: 1, | |
| 264 | }), | |
| 265 | ]), | |
| 266 | ]); | |
| 267 | }, | |
| 268 | ||
| 269 | /** | |
| 270 | * Codex plans that report only one window (Go reports a monthly allowance; | |
| 271 | * a hub often has no five-hour figure for an account) mixed with a plan that | |
| 272 | * reports both. Each pool lists only the accounts that have that window. | |
| 273 | */ | |
| 274 | "codex-window-mix": (now) => { | |
| 275 | const h = makeHelpers(now); | |
| 276 | return new Map([ | |
| 277 | h.environment( | |
| 278 | "env-macbook", | |
| 279 | "MacBook Pro", | |
| 280 | [ | |
| 281 | h.codexInstance({ | |
| 282 | instanceId: "codex", | |
| 283 | displayName: "Codex Personal", | |
| 284 | accentColor: "#6366f1", | |
| 285 | email: "main@example.com", | |
| 286 | windows: [h.codexSession(40, 2 * HOUR), h.codexWeekly(55, 3 * DAY)], | |
| 287 | credits: 2, | |
| 288 | }), | |
| 289 | h.codexInstance({ | |
| 290 | instanceId: "codex-go", | |
| 291 | displayName: "Codex Go", | |
| 292 | accentColor: "#10b981", | |
| 293 | email: "go@example.com", | |
| 294 | plan: "ChatGPT Go Subscription", | |
| 295 | windows: [ | |
| 296 | { | |
| 297 | id: "primary", | |
| 298 | kind: "monthly", | |
| 299 | label: "Monthly", | |
| 300 | usedPercent: 82, | |
| 301 | windowDurationMins: 30 * 24 * 60, | |
| 302 | resetsAt: h.at(11 * DAY), | |
| 303 | }, | |
| 304 | ], | |
| 305 | }), | |
| 306 | ], | |
| 307 | [ | |
| 308 | h.hub("cliproxy-nucbox", "CLI Proxy", [ | |
| 309 | { | |
| 310 | id: "codex-def-work@example.com-pro.json", | |
| 311 | driver: codex, | |
| 312 | email: "work@example.com", | |
| 313 | plan: "ChatGPT Pro 20x Subscription", | |
| 314 | usageLimits: { | |
| 315 | checkedAt: h.checked(3 * MINUTE), | |
| 316 | windows: [h.codexWeekly(88, 6 * DAY)], | |
| 317 | }, | |
| 318 | }, | |
| 319 | { | |
| 320 | id: "codex-ghi-team@example.net-plus.json", | |
| 321 | driver: codex, | |
| 322 | email: "team@example.net", | |
| 323 | plan: "ChatGPT Plus Subscription", | |
| 324 | usageLimits: { | |
| 325 | checkedAt: h.checked(3 * MINUTE), | |
| 326 | windows: [h.codexWeekly(12, DAY)], | |
| 327 | }, | |
| 328 | }, | |
| 329 | ]), | |
| 330 | ], | |
| 331 | ), | |
| 332 | ]); | |
| 333 | }, | |
| 334 | ||
| 335 | /** | |
| 336 | * A hub configured on two environments, a hub that is down, a provider | |
| 337 | * whose probe failed, an API-key account, and a hub account with no email. | |
| 338 | */ | |
| 339 | "failures-and-strays": (now) => { | |
| 340 | const h = makeHelpers(now); | |
| 341 | const hubAccounts: UsageLimitSourceSnapshot["accounts"] = [ | |
| 342 | h.claudeHubAccount("main@example.com", [ | |
| 343 | h.session(2, 4 * HOUR), | |
| 344 | h.weekly("seven_day", "Weekly", 38, 4 * DAY), | |
| 345 | h.weekly("seven_day_fable", "Weekly · Fable", 69, 4 * DAY), | |
| 346 | ]), | |
| 347 | h.claudeHubAccount(null, [ | |
| 348 | h.session(40, 2 * HOUR), | |
| 349 | h.weekly("seven_day", "Weekly", 20, 6 * DAY), | |
| 350 | ]), | |
| 351 | ]; | |
| 352 | return new Map([ | |
| 353 | h.environment( | |
| 354 | "env-macbook", | |
| 355 | "MacBook Pro", | |
| 356 | [ | |
| 357 | h.provider({ | |
| 358 | instanceId: ProviderInstanceId.make("claude"), | |
| 359 | driver: claude, | |
| 360 | auth: { status: "authenticated", label: "Claude API Key" }, | |
| 361 | usageLimits: { | |
| 362 | checkedAt: h.checked(MINUTE), | |
| 363 | windows: [], | |
| 364 | unavailable: { | |
| 365 | reason: "unsupported", | |
| 366 | message: "This account has no subscription limits.", | |
| 367 | }, | |
| 368 | }, | |
| 369 | }), | |
| 370 | ], | |
| 371 | [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)], | |
| 372 | ), | |
| 373 | h.environment( | |
| 374 | "env-nucbox", | |
| 375 | "nucbox-1", | |
| 376 | [], | |
| 377 | [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)], | |
| 378 | ), | |
| 379 | h.environment( | |
| 380 | "env-macmini", | |
| 381 | "Mac Mini", | |
| 382 | [ | |
| 383 | h.provider({ | |
| 384 | instanceId: ProviderInstanceId.make("claude"), | |
| 385 | driver: claude, | |
| 386 | auth: { | |
| 387 | status: "authenticated", | |
| 388 | label: "Claude Max", | |
| 389 | email: "work@example.com", | |
| 390 | }, | |
| 391 | usageLimits: { | |
| 392 | checkedAt: h.checked(MINUTE), | |
| 393 | windows: [], | |
| 394 | unavailable: { reason: "probeFailed", message: "Claude timed out reading usage." }, | |
| 395 | }, | |
| 396 | }), | |
| 397 | ], | |
| 398 | [ | |
| 399 | h.hub( | |
| 400 | "cliproxy-aws", | |
| 401 | "AWS proxy", | |
| 402 | [], | |
| 403 | "fetch failed: connect ECONNREFUSED 10.0.0.4:8318", | |
| 404 | ), | |
| 405 | ], | |
| 406 | ), | |
| 407 | ]); | |
| 408 | }, | |
| 409 | }; | |
| 410 | ||
| 411 | export function makeLimitsFixture(name: string, now: number): Fixture | null { | |
| 412 | return Object.hasOwn(FIXTURES, name) ? FIXTURES[name]!(now) : null; | |
| 413 | } |
docs/user/usage.md+12-3| ... | ... | @@ -39,9 +39,18 @@ the dialog. |
| 39 | 39 | |
| 40 | 40 | ## Track subscription limits |
| 41 | 41 | |
| 42 | **Usage → Limits** shows how much quota is left in each window and when it resets, for Codex and | |
| 43 | Claude subscriptions. For windows with timing data, each bar also marks how much of the window is | |
| 44 | left, so you can judge your pace before the next reset. | |
| 42 | On web and desktop, **Usage → Limits** pools every subscription account it can see per provider, so with several Codex | |
| 43 | or Claude accounts across your environments and hubs you read one number per window rather than a | |
| 44 | list. Each window card shows how much of the pool is left and a bar with one segment per account, | |
| 45 | ordered by which resets soonest; when the provider reports reset times, the card also says when | |
| 46 | the next reset lands and how much it hands back. The hatched | |
| 47 | part of a segment is what that reset restores. Tap or hover a segment for the account's plan, where it is | |
| 48 | signed in, and its reset time; Codex accounts with banked reset credits show a ticket count on the | |
| 49 | segment and the **Use reset** action in that popover. On narrow screens, numbered rows below | |
| 50 | the bar show each account's quota, countdown, and credits. Tap a row to open its details. | |
| 51 | ||
| 52 | The same account signed in on more than one environment, or reported by a hub as well, counts once. | |
| 53 | Filter with the environment dropdown to see what a single machine has. | |
| 45 | 54 | |
| 46 | 55 | If a window looks stale, refresh Limits to re-check every provider and hub. |
| 47 | 56 |
packages/shared/src/usageLimits.test.ts+378| ... | ... | @@ -13,6 +13,9 @@ import { |
| 13 | 13 | collectProviderUsageLimits, |
| 14 | 14 | sameUsageLimitCommandCoverage, |
| 15 | 15 | withUsageLimitsCommands, |
| 16 | collectLimitAccounts, | |
| 17 | collectLimitNotices, | |
| 18 | collectLimitPools, | |
| 16 | 19 | collectLimitSources, |
| 17 | 20 | collectLimitsGroups, |
| 18 | 21 | elapsedShare, |
| ... | ... | @@ -310,6 +313,381 @@ describe("collectLimitSources", () => { |
| 310 | 313 | }); |
| 311 | 314 | }); |
| 312 | 315 | |
| 316 | describe("pools", () => { | |
| 317 | const checkedAt = "2026-09-03T11:00:00.000Z"; | |
| 318 | const weekly = { | |
| 319 | id: "seven_day", | |
| 320 | kind: "weekly", | |
| 321 | label: "Weekly", | |
| 322 | windowDurationMins: 7 * 24 * 60, | |
| 323 | resetsAt: "2026-09-06T12:00:00.000Z", | |
| 324 | } as const; | |
| 325 | const claude = ProviderDriverKind.make("claudeAgent"); | |
| 326 | const source = { | |
| 327 | id: UsageLimitSourceId.make("hub"), | |
| 328 | kind: "cliproxy" as const, | |
| 329 | label: "hub", | |
| 330 | checkedAt, | |
| 331 | }; | |
| 332 | const laptop = { entry: { target: { label: "Laptop" } } }; | |
| 333 | ||
| 334 | it("merges one account reported natively on two environments and by a hub into one entry", () => { | |
| 335 | const native = provider({ | |
| 336 | driver: claude, | |
| 337 | instanceId: ProviderInstanceId.make("claude"), | |
| 338 | auth: { status: "authenticated", email: "Same@example.com" }, | |
| 339 | usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 40 }] }, | |
| 340 | }); | |
| 341 | const input = new Map([ | |
| 342 | [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [native] } }], | |
| 343 | [ | |
| 344 | EnvironmentId.make("env-b"), | |
| 345 | { | |
| 346 | entry: { target: { label: "Desktop" } }, | |
| 347 | serverConfig: { | |
| 348 | providers: [ | |
| 349 | { | |
| 350 | ...native, | |
| 351 | usageLimits: { | |
| 352 | checkedAt: "2026-09-03T11:30:00.000Z", | |
| 353 | windows: [{ ...window, usedPercent: 55 }], | |
| 354 | }, | |
| 355 | }, | |
| 356 | ], | |
| 357 | usageLimitSources: [ | |
| 358 | { | |
| 359 | ...source, | |
| 360 | accounts: [ | |
| 361 | { | |
| 362 | id: "claude-same@example.com.json", | |
| 363 | driver: claude, | |
| 364 | email: "same@example.com", | |
| 365 | plan: "Claude Subscription", | |
| 366 | usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 10 }] }, | |
| 367 | }, | |
| 368 | ], | |
| 369 | }, | |
| 370 | ], | |
| 371 | }, | |
| 372 | }, | |
| 373 | ], | |
| 374 | ]); | |
| 375 | const accounts = collectLimitAccounts(input); | |
| 376 | expect(accounts).toHaveLength(1); | |
| 377 | expect(accounts[0]).toMatchObject({ | |
| 378 | key: "env-a:claude", | |
| 379 | sourceLabel: null, | |
| 380 | // Desktop's read is fresher, so its credits and its redeem are the ones on show. | |
| 381 | redeem: { environmentId: "env-b", instanceId: "claude" }, | |
| 382 | environments: [ | |
| 383 | { environmentId: "env-a", label: "Laptop" }, | |
| 384 | { environmentId: "env-b", label: "Desktop" }, | |
| 385 | ], | |
| 386 | }); | |
| 387 | // The fresher native snapshot wins; the hub row is pre-filtered by email. | |
| 388 | expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(55); | |
| 389 | }); | |
| 390 | ||
| 391 | it("takes windows from a fresher hub read but credits and redeem from the native instance", () => { | |
| 392 | const native = provider({ | |
| 393 | driver: claude, | |
| 394 | instanceId: ProviderInstanceId.make("claude"), | |
| 395 | auth: { status: "authenticated", email: "same@example.com" }, | |
| 396 | usageLimits: { | |
| 397 | checkedAt, | |
| 398 | windows: [{ ...window, usedPercent: 40 }], | |
| 399 | resetCredits: { availableCount: 2 }, | |
| 400 | }, | |
| 401 | }); | |
| 402 | const input = new Map([ | |
| 403 | [ | |
| 404 | EnvironmentId.make("env-a"), | |
| 405 | { | |
| 406 | ...laptop, | |
| 407 | serverConfig: { | |
| 408 | providers: [native], | |
| 409 | usageLimitSources: [ | |
| 410 | { | |
| 411 | ...source, | |
| 412 | accounts: [ | |
| 413 | { | |
| 414 | id: "claude-same@example.com.json", | |
| 415 | driver: claude, | |
| 416 | email: "same@example.com", | |
| 417 | usageLimits: { | |
| 418 | checkedAt: "2026-09-03T11:30:00.000Z", | |
| 419 | windows: [{ ...window, usedPercent: 55 }], | |
| 420 | }, | |
| 421 | }, | |
| 422 | ], | |
| 423 | }, | |
| 424 | ], | |
| 425 | }, | |
| 426 | }, | |
| 427 | ], | |
| 428 | ]); | |
| 429 | const [account] = collectLimitAccounts(input); | |
| 430 | expect(account?.limits.windows[0]?.usedPercent).toBe(55); | |
| 431 | expect(account?.limits.resetCredits?.availableCount).toBe(2); | |
| 432 | expect(account?.redeem).toEqual({ environmentId: "env-a", instanceId: "claude" }); | |
| 433 | expect(account?.environments).toEqual([{ environmentId: "env-a", label: "Laptop" }]); | |
| 434 | }); | |
| 435 | ||
| 436 | it("redeems on the environment whose snapshot supplied the credits on show", () => { | |
| 437 | const stale = provider({ | |
| 438 | auth: { status: "authenticated", email: "same@example.com" }, | |
| 439 | usageLimits: { | |
| 440 | checkedAt, | |
| 441 | windows: [window], | |
| 442 | resetCredits: { availableCount: 0 }, | |
| 443 | }, | |
| 444 | }); | |
| 445 | const fresh = { | |
| 446 | ...stale, | |
| 447 | usageLimits: { | |
| 448 | checkedAt: "2026-09-03T11:30:00.000Z", | |
| 449 | windows: [window], | |
| 450 | resetCredits: { availableCount: 2 }, | |
| 451 | }, | |
| 452 | }; | |
| 453 | const input = new Map([ | |
| 454 | [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [stale] } }], | |
| 455 | [ | |
| 456 | EnvironmentId.make("env-b"), | |
| 457 | { entry: { target: { label: "Desktop" } }, serverConfig: { providers: [fresh] } }, | |
| 458 | ], | |
| 459 | ]); | |
| 460 | const [account] = collectLimitAccounts(input); | |
| 461 | expect(account?.limits.resetCredits?.availableCount).toBe(2); | |
| 462 | expect(account?.redeem).toEqual({ environmentId: "env-b", instanceId: "codex" }); | |
| 463 | }); | |
| 464 | ||
| 465 | it("names an environment once however many of its instances share the account", () => { | |
| 466 | const shared = provider({ | |
| 467 | auth: { status: "authenticated", email: "same@example.com" }, | |
| 468 | usageLimits: { checkedAt, windows: [window] }, | |
| 469 | }); | |
| 470 | const input = new Map([ | |
| 471 | [ | |
| 472 | EnvironmentId.make("env-a"), | |
| 473 | { | |
| 474 | ...laptop, | |
| 475 | serverConfig: { | |
| 476 | providers: [shared, { ...shared, instanceId: ProviderInstanceId.make("work") }], | |
| 477 | }, | |
| 478 | }, | |
| 479 | ], | |
| 480 | ]); | |
| 481 | expect(collectLimitAccounts(input)[0]?.environments).toEqual([ | |
| 482 | { environmentId: "env-a", label: "Laptop" }, | |
| 483 | ]); | |
| 484 | }); | |
| 485 | ||
| 486 | it("keys a hub account without an email by hub, so two environments on one hub share it", () => { | |
| 487 | const seat = { | |
| 488 | id: "claude-team-seat.json", | |
| 489 | driver: claude, | |
| 490 | usageLimits: { checkedAt, windows: [window] }, | |
| 491 | }; | |
| 492 | const hub = { ...source, accounts: [seat] }; | |
| 493 | const input = new Map([ | |
| 494 | [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { usageLimitSources: [hub] } }], | |
| 495 | [ | |
| 496 | EnvironmentId.make("env-b"), | |
| 497 | { entry: { target: { label: "Desktop" } }, serverConfig: { usageLimitSources: [hub] } }, | |
| 498 | ], | |
| 499 | ]); | |
| 500 | const accounts = collectLimitAccounts(input); | |
| 501 | expect(accounts.map((account) => account.key)).toEqual(["hub:claude-team-seat.json"]); | |
| 502 | expect(accounts[0]?.displayName).toBe("claude-team-seat"); | |
| 503 | }); | |
| 504 | ||
| 505 | it("pools windows by id across accounts and orders resets by when they land", () => { | |
| 506 | const input = new Map([ | |
| 507 | [ | |
| 508 | EnvironmentId.make("env-a"), | |
| 509 | { | |
| 510 | ...laptop, | |
| 511 | serverConfig: { | |
| 512 | providers: [], | |
| 513 | usageLimitSources: [ | |
| 514 | { | |
| 515 | ...source, | |
| 516 | accounts: [ | |
| 517 | { | |
| 518 | id: "a", | |
| 519 | driver: claude, | |
| 520 | usageLimits: { | |
| 521 | checkedAt, | |
| 522 | windows: [ | |
| 523 | { ...window, usedPercent: 80, resetsAt: "2026-09-03T13:00:00.000Z" }, | |
| 524 | { ...weekly, usedPercent: 20 }, | |
| 525 | ], | |
| 526 | }, | |
| 527 | }, | |
| 528 | { | |
| 529 | id: "b", | |
| 530 | driver: claude, | |
| 531 | usageLimits: { | |
| 532 | checkedAt, | |
| 533 | windows: [{ ...window, usedPercent: 40 }], | |
| 534 | }, | |
| 535 | }, | |
| 536 | { | |
| 537 | id: "c", | |
| 538 | driver: ProviderDriverKind.make("codex"), | |
| 539 | usageLimits: { checkedAt, windows: [{ ...weekly, usedPercent: 50 }] }, | |
| 540 | }, | |
| 541 | { | |
| 542 | id: "unsupported", | |
| 543 | driver: claude, | |
| 544 | usageLimits: { | |
| 545 | checkedAt, | |
| 546 | windows: [], | |
| 547 | unavailable: { reason: "unsupported" as const }, | |
| 548 | }, | |
| 549 | }, | |
| 550 | ], | |
| 551 | }, | |
| 552 | ], | |
| 553 | }, | |
| 554 | }, | |
| 555 | ], | |
| 556 | ]); | |
| 557 | const pools = collectLimitPools(collectLimitAccounts(input), now); | |
| 558 | expect(pools.map((pool) => [pool.driver, pool.accounts.length])).toEqual([ | |
| 559 | ["claudeAgent", 2], | |
| 560 | ["codex", 1], | |
| 561 | ]); | |
| 562 | const [session, week] = pools[0]!.windows; | |
| 563 | // A member with no reset has no clock, so it does not vote on pace. | |
| 564 | const untimed = collectLimitPools( | |
| 565 | collectLimitAccounts(input).map((account) => | |
| 566 | account.key === "hub:b" | |
| 567 | ? { | |
| 568 | ...account, | |
| 569 | limits: { | |
| 570 | ...account.limits, | |
| 571 | windows: account.limits.windows.map((w) => ({ ...w, resetsAt: undefined })), | |
| 572 | }, | |
| 573 | } | |
| 574 | : account, | |
| 575 | ), | |
| 576 | now, | |
| 577 | ); | |
| 578 | // Only a votes: 80% used, 80% elapsed. | |
| 579 | expect(untimed[0]?.windows[0]?.pace).toBe("on"); | |
| 580 | // a is 80% through its window and b 60%: the pool is 70% elapsed, 60% used. | |
| 581 | expect(session).toMatchObject({ | |
| 582 | id: "five_hour", | |
| 583 | remainingPercent: 40, | |
| 584 | usedPercent: 60, | |
| 585 | pace: "under", | |
| 586 | }); | |
| 587 | expect( | |
| 588 | session?.resets.map((reset) => [reset.member.account.key, reset.restoresPercent]), | |
| 589 | ).toEqual([ | |
| 590 | ["hub:a", 40], | |
| 591 | ["hub:b", 20], | |
| 592 | ]); | |
| 593 | expect(week).toMatchObject({ id: "seven_day", remainingPercent: 80, members: [{}] }); | |
| 594 | // Codex reports `primary` for both its five-hour and (on Go) monthly window. | |
| 595 | const mixed = collectLimitPools( | |
| 596 | [ | |
| 597 | ...collectLimitAccounts(input), | |
| 598 | { | |
| 599 | key: "go", | |
| 600 | driver: claude, | |
| 601 | displayName: "Go", | |
| 602 | email: undefined, | |
| 603 | plan: undefined, | |
| 604 | accentColor: undefined, | |
| 605 | environments: [], | |
| 606 | sourceLabel: null, | |
| 607 | redeem: null, | |
| 608 | limits: { | |
| 609 | checkedAt, | |
| 610 | windows: [ | |
| 611 | { | |
| 612 | id: "five_hour", | |
| 613 | kind: "monthly", | |
| 614 | label: "Monthly", | |
| 615 | usedPercent: 82, | |
| 616 | windowDurationMins: 30 * 24 * 60, | |
| 617 | resetsAt: "2026-09-14T12:00:00.000Z", | |
| 618 | }, | |
| 619 | ], | |
| 620 | }, | |
| 621 | }, | |
| 622 | ], | |
| 623 | now, | |
| 624 | ); | |
| 625 | expect(mixed[0]?.windows.map((window) => [window.kind, window.members.length])).toEqual([ | |
| 626 | ["session", 2], | |
| 627 | ["weekly", 1], | |
| 628 | ["monthly", 1], | |
| 629 | ]); | |
| 630 | // Segments read left to right as "who refills next", matching the reset list. | |
| 631 | expect(session?.members.map((member) => member.account.key)).toEqual(["hub:a", "hub:b"]); | |
| 632 | expect(pools[0]?.accounts.map((account) => account.key)).toEqual(["hub:a", "hub:b"]); | |
| 633 | }); | |
| 634 | }); | |
| 635 | ||
| 636 | describe("collectLimitNotices", () => { | |
| 637 | const checkedAt = "2026-09-03T11:00:00.000Z"; | |
| 638 | const claude = ProviderDriverKind.make("claudeAgent"); | |
| 639 | const laptop = { entry: { target: { label: "Laptop" } } }; | |
| 640 | const hub = { | |
| 641 | id: UsageLimitSourceId.make("hub"), | |
| 642 | kind: "cliproxy" as const, | |
| 643 | label: "hub", | |
| 644 | checkedAt, | |
| 645 | accounts: [], | |
| 646 | }; | |
| 647 | ||
| 648 | it("names failures and silence, skips unsupported accounts, and labels environments only when several", () => { | |
| 649 | const failed = provider({ | |
| 650 | instanceId: ProviderInstanceId.make("claude"), | |
| 651 | driver: claude, | |
| 652 | displayName: "Claude Max", | |
| 653 | usageLimits: { checkedAt, windows: [], unavailable: { reason: "probeFailed" } }, | |
| 654 | }); | |
| 655 | const apiKey = provider({ | |
| 656 | instanceId: ProviderInstanceId.make("api"), | |
| 657 | driver: claude, | |
| 658 | usageLimits: { checkedAt, windows: [], unavailable: { reason: "unsupported" } }, | |
| 659 | }); | |
| 660 | const silent = provider({ usageLimits: { checkedAt, windows: [] } }); | |
| 661 | const one = new Map([ | |
| 662 | [ | |
| 663 | EnvironmentId.make("env-a"), | |
| 664 | { | |
| 665 | ...laptop, | |
| 666 | serverConfig: { | |
| 667 | providers: [failed, apiKey, silent], | |
| 668 | usageLimitSources: [ | |
| 669 | hub, | |
| 670 | { ...hub, id: UsageLimitSourceId.make("down"), label: "down", error: "ECONNREFUSED" }, | |
| 671 | ], | |
| 672 | }, | |
| 673 | }, | |
| 674 | ], | |
| 675 | ]); | |
| 676 | expect(collectLimitNotices(one)).toEqual([ | |
| 677 | "Claude Max: Could not read limits.", | |
| 678 | "codex: No limits reported.", | |
| 679 | "hub: No accounts reported.", | |
| 680 | "down: ECONNREFUSED", | |
| 681 | ]); | |
| 682 | ||
| 683 | one.set(EnvironmentId.make("env-b"), { | |
| 684 | entry: { target: { label: "Desktop" } }, | |
| 685 | serverConfig: { providers: [], usageLimitSources: [] }, | |
| 686 | }); | |
| 687 | expect(collectLimitNotices(one)[0]).toBe("Laptop · Claude Max: Could not read limits."); | |
| 688 | }); | |
| 689 | }); | |
| 690 | ||
| 313 | 691 | describe("/usage-limits", () => { |
| 314 | 692 | const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [window] }; |
| 315 | 693 | const selected = provider({ |
packages/shared/src/usageLimits.ts+304-3| ... | ... | @@ -58,7 +58,9 @@ export function collectLimitsGroups( |
| 58 | 58 | EnvironmentId, |
| 59 | 59 | { |
| 60 | 60 | readonly entry: { readonly target: { readonly label: string } }; |
| 61 | readonly serverConfig: { readonly providers: readonly ServerProvider[] } | null; | |
| 61 | readonly serverConfig: { | |
| 62 | readonly providers?: readonly ServerProvider[] | undefined; | |
| 63 | } | null; | |
| 62 | 64 | } |
| 63 | 65 | >, |
| 64 | 66 | ): readonly LimitsGroup[] { |
| ... | ... | @@ -147,6 +149,302 @@ function accountKey(driver: ServerProvider["driver"], email: string | undefined) |
| 147 | 149 | return normalizedEmail ? `${driver}:${normalizedEmail}` : null; |
| 148 | 150 | } |
| 149 | 151 | |
| 152 | /** | |
| 153 | * One subscription account as the pooled views see it, whichever way it was | |
| 154 | * reported. The same email signed in natively on two environments, or reported | |
| 155 | * by a hub as well as natively, is one account: its quota is one bucket, so | |
| 156 | * counting it twice would misstate what is left. | |
| 157 | */ | |
| 158 | export interface LimitAccount { | |
| 159 | readonly key: string; | |
| 160 | readonly driver: ServerProvider["driver"]; | |
| 161 | /** The instance's configured name, which is not sensitive; null for hub accounts. */ | |
| 162 | readonly displayName: string | null; | |
| 163 | readonly email: string | undefined; | |
| 164 | readonly plan: string | undefined; | |
| 165 | readonly accentColor: string | undefined; | |
| 166 | /** Environments the account is signed in on; empty when only a hub reports it. */ | |
| 167 | readonly environments: ReadonlyArray<{ | |
| 168 | readonly environmentId: EnvironmentId; | |
| 169 | readonly label: string; | |
| 170 | }>; | |
| 171 | /** The hub that reported it, when no environment has it natively. */ | |
| 172 | readonly sourceLabel: string | null; | |
| 173 | /** Where a reset credit can be redeemed; only native instances can. */ | |
| 174 | readonly redeem: { | |
| 175 | readonly environmentId: EnvironmentId; | |
| 176 | readonly instanceId: ProviderInstanceId; | |
| 177 | } | null; | |
| 178 | readonly limits: ServerProviderUsageLimits; | |
| 179 | } | |
| 180 | ||
| 181 | /** | |
| 182 | * Every account with usable windows across the connected environments, one | |
| 183 | * entry per distinct account. Native instances win over hub reports, and the | |
| 184 | * freshest snapshot wins when the same account is reported twice. | |
| 185 | */ | |
| 186 | export function collectLimitAccounts( | |
| 187 | presentations: Parameters<typeof collectLimitSources>[0], | |
| 188 | ): readonly LimitAccount[] { | |
| 189 | const accounts = new Map<string, LimitAccount>(); | |
| 190 | const merge = (key: string, next: LimitAccount) => { | |
| 191 | const previous = accounts.get(key); | |
| 192 | if (!previous) { | |
| 193 | accounts.set(key, next); | |
| 194 | return; | |
| 195 | } | |
| 196 | const fresher = Date.parse(next.limits.checkedAt) > Date.parse(previous.limits.checkedAt); | |
| 197 | // Two instances on one machine sharing an account still name it once. | |
| 198 | const environments = [ | |
| 199 | ...previous.environments, | |
| 200 | ...next.environments.filter( | |
| 201 | (candidate) => | |
| 202 | !previous.environments.some((seen) => seen.environmentId === candidate.environmentId), | |
| 203 | ), | |
| 204 | ]; | |
| 205 | const winner = fresher ? next : previous; | |
| 206 | // Windows come from the freshest snapshot, wherever it was read. Reset | |
| 207 | // credits only ever come from a native instance, and the redeem must go | |
| 208 | // to the instance whose credits are on show, so the two travel together: | |
| 209 | // the freshest native snapshot supplies both, or neither. | |
| 210 | const native = [previous, next] | |
| 211 | .filter((candidate) => candidate.redeem !== null) | |
| 212 | .toSorted((a, b) => Date.parse(b.limits.checkedAt) - Date.parse(a.limits.checkedAt))[0]; | |
| 213 | accounts.set(key, { | |
| 214 | ...previous, | |
| 215 | displayName: previous.displayName ?? next.displayName, | |
| 216 | plan: previous.plan ?? next.plan, | |
| 217 | accentColor: previous.accentColor ?? next.accentColor, | |
| 218 | environments, | |
| 219 | // A hub only names the account when no environment has it natively. | |
| 220 | sourceLabel: environments.length > 0 ? null : (previous.sourceLabel ?? next.sourceLabel), | |
| 221 | redeem: native?.redeem ?? null, | |
| 222 | limits: { | |
| 223 | ...winner.limits, | |
| 224 | ...(native?.limits.resetCredits | |
| 225 | ? { resetCredits: native.limits.resetCredits } | |
| 226 | : { resetCredits: undefined }), | |
| 227 | }, | |
| 228 | }); | |
| 229 | }; | |
| 230 | for (const [environmentId, presentation] of presentations) { | |
| 231 | const label = presentation.entry.target.label; | |
| 232 | for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { | |
| 233 | if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue; | |
| 234 | merge( | |
| 235 | accountKey(provider.driver, provider.auth.email) ?? | |
| 236 | `${environmentId}:${provider.instanceId}`, | |
| 237 | { | |
| 238 | key: `${environmentId}:${provider.instanceId}`, | |
| 239 | driver: provider.driver, | |
| 240 | displayName: provider.displayName?.trim() || null, | |
| 241 | email: provider.auth.email, | |
| 242 | plan: provider.auth.label, | |
| 243 | accentColor: provider.accentColor, | |
| 244 | environments: [{ environmentId, label }], | |
| 245 | sourceLabel: null, | |
| 246 | redeem: { environmentId, instanceId: provider.instanceId }, | |
| 247 | limits: provider.usageLimits, | |
| 248 | }, | |
| 249 | ); | |
| 250 | } | |
| 251 | } | |
| 252 | // Every hub account, including those a native instance also knows: the hub | |
| 253 | // may hold a fresher read of the same subscription, and the merge above | |
| 254 | // keeps the redeem target consistent with whichever snapshot wins. | |
| 255 | const labelEnvironment = presentations.size > 1; | |
| 256 | for (const presentation of presentations.values()) { | |
| 257 | for (const source of presentation.serverConfig?.usageLimitSources ?? []) { | |
| 258 | const sourceLabel = labelEnvironment | |
| 259 | ? `${presentation.entry.target.label} · ${source.label}` | |
| 260 | : source.label; | |
| 261 | for (const account of source.accounts) { | |
| 262 | if (limitsNotice(account.usageLimits) !== null) continue; | |
| 263 | merge(accountKey(account.driver, account.email) ?? `${source.id}:${account.id}`, { | |
| 264 | key: `${source.id}:${account.id}`, | |
| 265 | driver: account.driver, | |
| 266 | displayName: account.email ? null : account.id.replace(/\.json$/i, ""), | |
| 267 | email: account.email, | |
| 268 | plan: account.plan, | |
| 269 | accentColor: undefined, | |
| 270 | environments: [], | |
| 271 | sourceLabel, | |
| 272 | redeem: null, | |
| 273 | limits: account.usageLimits, | |
| 274 | }); | |
| 275 | } | |
| 276 | } | |
| 277 | } | |
| 278 | return [...accounts.values()]; | |
| 279 | } | |
| 280 | ||
| 281 | /** | |
| 282 | * What the pooled views cannot draw as a bar: a hub that failed to read, a | |
| 283 | * provider whose probe failed. Accounts that can never report (API keys) | |
| 284 | * are left out; there is nothing for the user to act on. The environment | |
| 285 | * is named only when more than one is connected. | |
| 286 | */ | |
| 287 | export function collectLimitNotices( | |
| 288 | presentations: Parameters<typeof collectLimitSources>[0], | |
| 289 | ): readonly string[] { | |
| 290 | const label = (environmentLabel: string, subject: string) => | |
| 291 | presentations.size > 1 ? `${environmentLabel} · ${subject}` : subject; | |
| 292 | const notices: string[] = []; | |
| 293 | for (const presentation of presentations.values()) { | |
| 294 | const environmentLabel = presentation.entry.target.label; | |
| 295 | for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { | |
| 296 | // An account that can never report (API key) is left out; one that | |
| 297 | // failed, or reported nothing at all, is worth a line. | |
| 298 | if (provider.usageLimits?.unavailable?.reason === "unsupported") continue; | |
| 299 | const notice = provider.usageLimits ? limitsNotice(provider.usageLimits) : null; | |
| 300 | const name = provider.displayName?.trim() || String(provider.driver); | |
| 301 | if (notice) notices.push(`${label(environmentLabel, name)}: ${notice}`); | |
| 302 | } | |
| 303 | for (const source of presentation.serverConfig?.usageLimitSources ?? []) { | |
| 304 | if (source.error) { | |
| 305 | notices.push(`${label(environmentLabel, source.label)}: ${source.error}`); | |
| 306 | } else if (source.accounts.length === 0) { | |
| 307 | notices.push(`${label(environmentLabel, source.label)}: No accounts reported.`); | |
| 308 | } | |
| 309 | } | |
| 310 | } | |
| 311 | return notices; | |
| 312 | } | |
| 313 | ||
| 314 | export interface LimitPoolMember { | |
| 315 | readonly account: LimitAccount; | |
| 316 | readonly window: ServerProviderUsageWindow; | |
| 317 | } | |
| 318 | ||
| 319 | /** | |
| 320 | * One window id across every account that reports it: the pooled share left, | |
| 321 | * pace against the clock, and the resets in the order they will land, each | |
| 322 | * with the share of the pool it hands back. | |
| 323 | */ | |
| 324 | export interface LimitPoolWindow { | |
| 325 | readonly id: string; | |
| 326 | readonly kind: ServerProviderUsageWindow["kind"]; | |
| 327 | readonly label: string; | |
| 328 | readonly members: readonly LimitPoolMember[]; | |
| 329 | readonly remainingPercent: number; | |
| 330 | readonly usedPercent: number; | |
| 331 | readonly pace: LimitPace | null; | |
| 332 | readonly resets: ReadonlyArray<{ | |
| 333 | readonly member: LimitPoolMember; | |
| 334 | readonly at: number; | |
| 335 | /** Points of the pool the reset restores: the member's used share over the member count. */ | |
| 336 | readonly restoresPercent: number; | |
| 337 | }>; | |
| 338 | } | |
| 339 | ||
| 340 | export interface LimitPool { | |
| 341 | readonly driver: ServerProvider["driver"]; | |
| 342 | readonly accounts: readonly LimitAccount[]; | |
| 343 | readonly windows: readonly LimitPoolWindow[]; | |
| 344 | } | |
| 345 | ||
| 346 | const WINDOW_KIND_ORDER: Record<ServerProviderUsageWindow["kind"], number> = { | |
| 347 | session: 0, | |
| 348 | weekly: 1, | |
| 349 | monthly: 2, | |
| 350 | other: 3, | |
| 351 | }; | |
| 352 | ||
| 353 | /** | |
| 354 | * Accounts grouped by driver, each with its windows pooled by kind and id. | |
| 355 | * Window ids are stable per provider, so a hub row and a native row for the | |
| 356 | * same window land in the same pool; the kind is part of the key because | |
| 357 | * Codex's `primary` is a position, not a duration (five hours on paid plans, | |
| 358 | * a month on Free/Go), and a monthly allowance must not average into a | |
| 359 | * five-hour pool. Pools order by kind, then first appearance. | |
| 360 | * | |
| 361 | * `accounts` is the table order: instances the user can act on (native, | |
| 362 | * named) before hub-only accounts, each group alphabetical. Each window's | |
| 363 | * `members` sort by reset instead, soonest first, so a bar reads left to | |
| 364 | * right as "who refills next" and matches the reset list under it. | |
| 365 | */ | |
| 366 | export function collectLimitPools( | |
| 367 | accounts: readonly LimitAccount[], | |
| 368 | now: number, | |
| 369 | ): readonly LimitPool[] { | |
| 370 | const byDriver = new Map<ServerProvider["driver"], LimitAccount[]>(); | |
| 371 | for (const account of accounts) { | |
| 372 | const list = byDriver.get(account.driver); | |
| 373 | if (list) list.push(account); | |
| 374 | else byDriver.set(account.driver, [account]); | |
| 375 | } | |
| 376 | return [...byDriver].map(([driver, members]) => { | |
| 377 | const sorted = members.toSorted( | |
| 378 | (left, right) => | |
| 379 | Number(left.redeem === null) - Number(right.redeem === null) || | |
| 380 | accountSortName(left).localeCompare(accountSortName(right)), | |
| 381 | ); | |
| 382 | return { driver, accounts: sorted, windows: poolWindows(sorted, now) }; | |
| 383 | }); | |
| 384 | } | |
| 385 | ||
| 386 | function accountSortName(account: LimitAccount): string { | |
| 387 | return (account.displayName ?? account.email ?? account.key).toLowerCase(); | |
| 388 | } | |
| 389 | ||
| 390 | function poolWindows(accounts: readonly LimitAccount[], now: number): readonly LimitPoolWindow[] { | |
| 391 | const byKey = new Map<string, LimitPoolMember[]>(); | |
| 392 | for (const account of accounts) { | |
| 393 | for (const window of account.limits.windows) { | |
| 394 | const key = `${window.kind}:${window.id}`; | |
| 395 | const list = byKey.get(key); | |
| 396 | if (list) list.push({ account, window }); | |
| 397 | else byKey.set(key, [{ account, window }]); | |
| 398 | } | |
| 399 | } | |
| 400 | const pools = [...byKey.values()].map((unordered): LimitPoolWindow => { | |
| 401 | const members = unordered.toSorted( | |
| 402 | (left, right) => | |
| 403 | (resetMillis(left.window) ?? Number.POSITIVE_INFINITY) - | |
| 404 | (resetMillis(right.window) ?? Number.POSITIVE_INFINITY), | |
| 405 | ); | |
| 406 | const first = members[0]!.window; | |
| 407 | const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length; | |
| 408 | // Pace compares spend against the clock, so it is judged only over the | |
| 409 | // members that have a clock; a window with no reset would otherwise | |
| 410 | // count as spend with no time elapsed and skew the verdict. | |
| 411 | const timed = members.flatMap((m) => { | |
| 412 | const share = elapsedShare(m.window, now); | |
| 413 | return share === null ? [] : [{ used: m.window.usedPercent, elapsed: share }]; | |
| 414 | }); | |
| 415 | const timedUsed = timed.reduce((sum, t) => sum + t.used, 0) / timed.length; | |
| 416 | const meanElapsed = | |
| 417 | timed.length > 0 ? timed.reduce((sum, t) => sum + t.elapsed, 0) / timed.length : null; | |
| 418 | const resets = members | |
| 419 | .flatMap((member) => { | |
| 420 | const at = resetMillis(member.window); | |
| 421 | return at === null | |
| 422 | ? [] | |
| 423 | : [ | |
| 424 | { | |
| 425 | member, | |
| 426 | at, | |
| 427 | restoresPercent: Math.round(member.window.usedPercent / members.length), | |
| 428 | }, | |
| 429 | ]; | |
| 430 | }) | |
| 431 | .toSorted((left, right) => left.at - right.at); | |
| 432 | return { | |
| 433 | id: first.id, | |
| 434 | kind: first.kind, | |
| 435 | label: first.label, | |
| 436 | members, | |
| 437 | usedPercent: Math.round(usedPercent), | |
| 438 | remainingPercent: Math.round(100 - usedPercent), | |
| 439 | pace: meanElapsed === null ? null : paceOfShares(timedUsed, meanElapsed), | |
| 440 | resets, | |
| 441 | }; | |
| 442 | }); | |
| 443 | return pools.toSorted( | |
| 444 | (left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind], | |
| 445 | ); | |
| 446 | } | |
| 447 | ||
| 150 | 448 | /** The instance's configured name, else the driver's, else its raw kind. */ |
| 151 | 449 | export function providerLimitsLabel( |
| 152 | 450 | provider: Pick<ServerProvider, "driver" | "displayName">, |
| ... | ... | @@ -195,8 +493,11 @@ export type LimitPace = "ahead" | "on" | "under"; |
| 195 | 493 | */ |
| 196 | 494 | export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null { |
| 197 | 495 | const elapsed = elapsedShare(window, now); |
| 198 | if (elapsed === null) return null; | |
| 199 | const gap = window.usedPercent - elapsed * 100; | |
| 496 | return elapsed === null ? null : paceOfShares(window.usedPercent, elapsed); | |
| 497 | } | |
| 498 | ||
| 499 | function paceOfShares(usedPercent: number, elapsed: number): LimitPace { | |
| 500 | const gap = usedPercent - elapsed * 100; | |
| 200 | 501 | if (gap > 5) return "ahead"; |
| 201 | 502 | if (gap < -5) return "under"; |
| 202 | 503 | return "on"; |