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