Open History to see the last commit
1import {
2 type EnvironmentId,
3 type ModelSelection,
4 type ServerSettings,
5 type ProjectId,
6 resolveProjectProviderInstanceEnabled,
7 type UsageLimitsReport,
8 type ProviderInstanceId,
9 type ProviderConsumeResetCreditInput,
10 type ServerProviderSlashCommand,
11 isProviderAvailable,
12 PROVIDER_DISPLAY_NAMES,
13 type ServerProvider,
14 type ServerProviderResetCredits,
15 type ServerProviderUsageLimits,
16 type ServerProviderUsageWindow,
17 type UsageLimitSourceSnapshots,
18} from "@t3tools/contracts";
19
20import * as DateTime from "effect/DateTime";
21
22const MINUTE = 60_000;
23const HOUR = 60 * MINUTE;
24const DAY = 24 * HOUR;
25
26export function exhaustedUsageWindow(
27 provider: ServerProvider | undefined,
28 now: number,
29 model?: string,
30) {
31 const windows = provider?.usageLimits?.windows ?? [];
32 const exhausted = windows.filter(
33 (window) =>
34 (window.usedPercent ?? 0) >= 100 &&
35 (!window.resetsAt ||
36 !Number.isFinite(Date.parse(window.resetsAt)) ||
37 Date.parse(window.resetsAt) > now),
38 );
39 if (model === undefined) return exhausted[0];
40 switch (provider?.driver) {
41 case "cursor": {
42 const api = exhausted.find((window) => window.id === "apiPercentUsed");
43 const auto = exhausted.find((window) => window.id === "autoPercentUsed");
44 return /^(auto|default|grok|composer)/.test(model) ? auto && api : api;
45 }
46 case "antigravity": {
47 const family = model.match(/^(gemini|claude|gpt)-/)?.[1];
48 return exhausted.find((window) => {
49 const pool = window.id.match(/^(gemini|claude|gpt|3p)-/)?.[1];
50 return (
51 pool === undefined ||
52 family === undefined ||
53 pool === family ||
54 (pool === "3p" && (family === "claude" || family === "gpt"))
55 );
56 });
57 }
58 case "codex": {
59 // Seat-based plans spend credits only past their plan windows, so the credit cap blocks only plans without them.
60 const hasPlanWindows = windows.some(
61 (window) => window.id === "primary" || window.id === "secondary",
62 );
63 return exhausted.find((window) => window.id !== "individual" || !hasPlanWindows);
64 }
65 case "opencode":
66 return exhausted.find(
67 (window) => !window.id.startsWith("go_") || model.startsWith("opencode-go/"),
68 );
69 default:
70 return exhausted[0];
71 }
72}
73
74export function resolveNewThreadModelSelection(input: {
75 readonly defaultSelection: ModelSelection | null;
76 readonly explicitSelection?: ModelSelection | null;
77 readonly fallbackSelection: ModelSelection | null;
78 readonly providers: readonly ServerProvider[];
79 readonly now: number;
80 readonly settings?: ServerSettings;
81 readonly projectId?: ProjectId | null;
82}) {
83 const selection = input.explicitSelection ?? input.defaultSelection;
84 const unchanged = { selection, fallback: null };
85 if (input.explicitSelection || !selection || !input.fallbackSelection) return unchanged;
86 const provider = input.providers.find((entry) => entry.instanceId === selection.instanceId);
87 const window = exhaustedUsageWindow(provider, input.now, selection.model);
88 const fallback = input.providers.find(
89 (entry) => entry.instanceId === input.fallbackSelection?.instanceId,
90 );
91 if (
92 !provider ||
93 !window ||
94 !fallback?.enabled ||
95 !fallback.installed ||
96 fallback.auth.status === "unauthenticated" ||
97 !isProviderAvailable(fallback) ||
98 exhaustedUsageWindow(fallback, input.now, input.fallbackSelection.model) ||
99 (input.settings &&
100 !resolveProjectProviderInstanceEnabled(
101 input.settings,
102 input.projectId ?? null,
103 fallback.instanceId,
104 ))
105 )
106 return unchanged;
107 return {
108 selection: input.fallbackSelection,
109 fallback: { provider, resetsAt: window.resetsAt ?? null },
110 };
111}
112
113export const CHATGPT_USAGE_URL = "https://chatgpt.com/#settings/Usage";
114export const CHATGPT_USAGE_LIMIT_CODE = "subscription_sharing_usage_limit_exceeded";
115/** A ChatGPT Business admin caps plan use in other apps; only the admin can raise it. */
116export const CHATGPT_WORKSPACE_USAGE_LIMIT_CODE =
117 "subscription_sharing_workplace_individual_usage_limit_exceeded";
118/** OpenAI's partner guidelines fix this wording; the user sends it to their admin themselves. */
119export const CHATGPT_WORKSPACE_ADMIN_REQUEST =
120 "I've reached the limit for using my ChatGPT plan in T3 Code. Could you increase our workspace's usage limits so I can continue?\n\nGo to admin.openai.com → Workspace → Usage limits. Thank you!";
121
122export function usesChatGptSharing(provider: ServerProvider | null | undefined): boolean {
123 return provider?.auth.status === "authenticated" && provider.auth.subscriptionSharing === true;
124}
125
126export const CURSOR_USAGE_WINDOWS = [
127 {
128 id: "totalPercentUsed",
129 label: "Overall",
130 description: "All usage on your plan this billing cycle.",
131 },
132 {
133 id: "autoPercentUsed",
134 label: "Cursor Models",
135 description: "Grok, Composer, and Auto use this allowance.",
136 },
137 {
138 id: "apiPercentUsed",
139 label: "Other Models",
140 description: "Usage allowance for third-party models such as Claude, GPT, and Gemini.",
141 },
142] as const;
143
144export function cursorUsageWindowDetails(id: string, pool: LimitPool) {
145 const details = CURSOR_USAGE_WINDOWS.find((window) => window.id === id);
146 return details &&
147 ((pool.windows.length === 1 && id === "totalPercentUsed") || hasDuplicateCursorTotal(pool))
148 ? { ...details, label: null, description: undefined }
149 : details;
150}
151
152function cursorUsageWindowRank(id: string): number {
153 const rank = CURSOR_USAGE_WINDOWS.findIndex((window) => window.id === id);
154 return rank < 0 ? CURSOR_USAGE_WINDOWS.length : rank;
155}
156
157function providersWithLimits(providers: readonly ServerProvider[]): readonly ServerProvider[] {
158 return providers.filter(
159 (provider) =>
160 provider.enabled &&
161 provider.installed &&
162 isProviderAvailable(provider) &&
163 provider.usageLimits !== undefined,
164 );
165}
166
167export type LimitPresentations = ReadonlyMap<
168 EnvironmentId,
169 {
170 readonly entry: { readonly target: { readonly label: string } };
171 readonly serverConfig: {
172 readonly providers: readonly ServerProvider[];
173 readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined;
174 } | null;
175 }
176>;
177
178export function collectExternalUsageLinks(presentations: LimitPresentations) {
179 const links = new Map<
180 string,
181 {
182 readonly label: string;
183 readonly url: string;
184 readonly message: string | undefined;
185 readonly accounts: readonly string[];
186 }
187 >();
188 for (const presentation of presentations.values()) {
189 for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) {
190 const external = provider.usageLimits?.externalUsage;
191 if (external && provider.auth.status === "authenticated") {
192 const account = `${provider.displayName ?? provider.instanceId} on ${presentation.entry.target.label}`;
193 links.set(external.url, {
194 ...external,
195 message: provider.usageLimits?.unavailable?.message,
196 accounts: [...new Set([...(links.get(external.url)?.accounts ?? []), account])],
197 });
198 }
199 }
200 }
201 return [...links.values()];
202}
203
204function accountKey(
205 driver: ServerProvider["driver"],
206 email: string | undefined,
207 limits?: ServerProviderUsageLimits,
208): string | null {
209 const normalizedEmail = email?.trim().toLowerCase();
210 if (normalizedEmail) return `${driver}:${normalizedEmail}`;
211 return limits?.credentialFingerprint
212 ? `${driver}:credential:${limits.credentialFingerprint}`
213 : null;
214}
215
216interface AccountReport {
217 readonly driver: ServerProvider["driver"];
218 readonly email?: string | undefined;
219 readonly workspaceId?: string | undefined;
220 readonly usageLimits?: ServerProviderUsageLimits | undefined;
221}
222
223const nativeReport = (provider: ServerProvider): AccountReport => ({
224 driver: provider.driver,
225 email: provider.auth.email,
226 workspaceId: provider.auth.workspaceId,
227 usageLimits: provider.usageLimits,
228});
229
230/** Resolve the selected login across native duplicates and linked usage sources. */
231export function resolveProviderUsageLimits(
232 instanceId: ProviderInstanceId,
233 providers: readonly ServerProvider[],
234 sources: UsageLimitSourceSnapshots,
235): ServerProviderUsageLimits | undefined {
236 const selected = providers.find((provider) => provider.instanceId === instanceId);
237 if (!selected) return undefined;
238 const reports = [
239 ...providers.filter((provider) => provider.enabled).map(nativeReport),
240 ...sources.flatMap((source) => source.accounts),
241 ];
242 const resolveKey = accountKeyResolver(reports);
243 const key = resolveKey(nativeReport(selected));
244 const candidates = key === null ? [] : reports.filter((report) => resolveKey(report) === key);
245 return (
246 candidates
247 .flatMap((report) =>
248 report.usageLimits && limitsNotice(report.usageLimits) === null ? [report.usageLimits] : [],
249 )
250 .sort((a, b) => Date.parse(b.checkedAt) - Date.parse(a.checkedAt))[0] ?? selected.usageLimits
251 );
252}
253
254// Include every enabled login, even unreadable workspaces.
255function accountKeyResolver(reports: Iterable<AccountReport>) {
256 const workspaces = new Map<string, Set<string>>();
257 for (const report of reports) {
258 const key = accountKey(report.driver, report.email, report.usageLimits);
259 if (key && report.workspaceId) {
260 workspaces.set(key, (workspaces.get(key) ?? new Set()).add(report.workspaceId));
261 }
262 }
263 return (report: AccountReport): string | null => {
264 const key = accountKey(report.driver, report.email, report.usageLimits);
265 if (!key) return null;
266 if (report.workspaceId) return `${key}:${report.workspaceId}`;
267 const known = workspaces.get(key);
268 if (!known) return key;
269 return known.size === 1 ? `${key}:${[...known][0]}` : null;
270 };
271}
272
273const hubAccountName = (account: { readonly id: string }) => account.id.replace(/\.json$/i, "");
274
275export interface LimitAccount {
276 readonly key: string;
277 readonly driver: ServerProvider["driver"];
278 readonly displayName: string | null;
279 readonly email: string | undefined;
280 readonly plan: string | undefined;
281 readonly accentColor: string | undefined;
282 readonly environments: ReadonlyArray<{
283 readonly environmentId: EnvironmentId;
284 readonly label: string;
285 }>;
286 readonly sourceLabel: string | null;
287 readonly redeem: {
288 readonly environmentId: EnvironmentId;
289 readonly input: ProviderConsumeResetCreditInput;
290 } | null;
291 readonly limits: ServerProviderUsageLimits;
292}
293
294export function collectLimitAccounts(presentations: LimitPresentations): readonly LimitAccount[] {
295 const accounts = new Map<string, LimitAccount>();
296 const creditSources = new Map<string, LimitAccount>();
297 const hubRedeems = new Map<string, LimitAccount>();
298 const planSources = new Map<string, LimitAccount>();
299 const merge = (key: string, next: LimitAccount) => {
300 // Prefer hub redemption to clear its routing cooldown too; displayed balance still follows the freshest read.
301 const previousHub = hubRedeems.get(key);
302 if (
303 next.redeem &&
304 "sourceId" in next.redeem.input &&
305 (!previousHub || Date.parse(next.limits.checkedAt) > Date.parse(previousHub.limits.checkedAt))
306 ) {
307 hubRedeems.set(key, next);
308 }
309 const previousCredit = creditSources.get(key);
310 if (
311 next.limits.resetCredits &&
312 (!previousCredit ||
313 Date.parse(next.limits.checkedAt) > Date.parse(previousCredit.limits.checkedAt))
314 ) {
315 creditSources.set(key, next);
316 }
317 const previousPlan = planSources.get(key);
318 if (
319 next.plan &&
320 (!previousPlan ||
321 Date.parse(next.limits.checkedAt) > Date.parse(previousPlan.limits.checkedAt))
322 ) {
323 planSources.set(key, next);
324 }
325 const previous = accounts.get(key);
326 if (!previous) {
327 accounts.set(key, next);
328 return;
329 }
330 const fresher = Date.parse(next.limits.checkedAt) > Date.parse(previous.limits.checkedAt);
331 const environments = [
332 ...previous.environments,
333 ...next.environments.filter(
334 (candidate) =>
335 !previous.environments.some((seen) => seen.environmentId === candidate.environmentId),
336 ),
337 ];
338 const winner = fresher ? next : previous;
339 const creditSource = creditSources.get(key);
340 // A hub e-mail names the account better than the driver's default instance name.
341 const hubEmail = [previous, next].some(
342 (account) => account.environments.length === 0 && account.email,
343 );
344 const displayName = previous.displayName ?? next.displayName;
345 accounts.set(key, {
346 ...previous,
347 displayName:
348 hubEmail &&
349 displayName?.toLowerCase() === PROVIDER_DISPLAY_NAMES[previous.driver]?.toLowerCase()
350 ? null
351 : displayName,
352 plan: planSources.get(key)?.plan ?? previous.plan ?? next.plan,
353 accentColor: previous.accentColor ?? next.accentColor,
354 environments,
355 sourceLabel: environments.length > 0 ? null : (previous.sourceLabel ?? next.sourceLabel),
356 redeem:
357 hubRedeems.get(key)?.redeem ??
358 (creditSource ? creditSource.redeem : (winner.redeem ?? previous.redeem ?? next.redeem)),
359 limits: {
360 ...winner.limits,
361 ...(creditSource?.limits.resetCredits
362 ? { resetCredits: creditSource.limits.resetCredits }
363 : { resetCredits: undefined }),
364 },
365 });
366 };
367 const resolveKey = accountKeyResolver(
368 [...presentations.values()].flatMap((presentation) => [
369 ...(presentation.serverConfig?.providers ?? [])
370 .filter((provider) => provider.enabled)
371 .map(nativeReport),
372 ...(presentation.serverConfig?.usageLimitSources ?? []).flatMap((source) => source.accounts),
373 ]),
374 );
375 for (const [environmentId, presentation] of presentations) {
376 const label = presentation.entry.target.label;
377 for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) {
378 if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue;
379 merge(resolveKey(nativeReport(provider)) ?? `${environmentId}:${provider.instanceId}`, {
380 key: `${environmentId}:${provider.instanceId}`,
381 driver: provider.driver,
382 displayName: provider.displayName?.trim() || null,
383 email: provider.auth.email,
384 plan: provider.auth.label,
385 accentColor: provider.accentColor,
386 environments: [{ environmentId, label }],
387 sourceLabel: null,
388 redeem: { environmentId, input: { instanceId: provider.instanceId } },
389 limits: provider.usageLimits,
390 });
391 }
392 }
393 const labelEnvironment = presentations.size > 1;
394 for (const [environmentId, presentation] of presentations) {
395 for (const source of presentation.serverConfig?.usageLimitSources ?? []) {
396 const sourceLabel = labelEnvironment
397 ? `${presentation.entry.target.label} · ${source.label}`
398 : source.label;
399 for (const account of source.accounts) {
400 if (limitsNotice(account.usageLimits) !== null) continue;
401 const key = `${source.id}:${account.id}${account.workspaceId ? `:${account.workspaceId}` : ""}`;
402 merge(resolveKey(account) ?? key, {
403 key,
404 driver: account.driver,
405 displayName: account.email ? null : hubAccountName(account),
406 email: account.email,
407 plan: account.plan,
408 accentColor: undefined,
409 environments: [],
410 sourceLabel,
411 redeem: account.usageLimits.resetCredits?.nextCreditId
412 ? {
413 environmentId,
414 input: {
415 sourceId: source.id,
416 accountId: account.id,
417 creditId: account.usageLimits.resetCredits.nextCreditId,
418 },
419 }
420 : null,
421 limits: account.usageLimits,
422 });
423 }
424 }
425 }
426 return [...accounts.values()];
427}
428
429export function collectLimitNotices(presentations: LimitPresentations): readonly string[] {
430 const label = (environmentLabel: string, subject: string) =>
431 presentations.size > 1 ? `${environmentLabel} · ${subject}` : subject;
432 const notices = new Set<string>();
433 for (const presentation of presentations.values()) {
434 const environmentLabel = presentation.entry.target.label;
435 for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) {
436 if (provider.usageLimits?.unavailable?.reason === "unsupported") continue;
437 const notice = provider.usageLimits ? limitsNotice(provider.usageLimits) : null;
438 const name = provider.displayName?.trim() || String(provider.driver);
439 if (notice) notices.add(`${label(environmentLabel, name)}: ${notice}`);
440 }
441 for (const source of presentation.serverConfig?.usageLimitSources ?? []) {
442 if (source.error) {
443 notices.add(`${label(environmentLabel, source.label)}: ${source.error}`);
444 } else if (source.accounts.length === 0) {
445 notices.add(`${label(environmentLabel, source.label)}: No accounts reported.`);
446 }
447 }
448 }
449 return [...notices];
450}
451
452export interface LimitPoolMember {
453 readonly account: LimitAccount;
454 readonly window: ServerProviderUsageWindow;
455 readonly refillPercent: number;
456}
457
458export interface LimitPoolWindow {
459 readonly id: string;
460 readonly kind: ServerProviderUsageWindow["kind"];
461 readonly label: string;
462 readonly members: readonly LimitPoolMember[];
463 readonly columns: ReadonlyArray<
464 LimitPoolMember | { readonly account: LimitAccount; readonly window: null }
465 >;
466 readonly remainingPercent: number;
467 readonly usedPercent: number;
468 readonly pace: LimitPace | null;
469 readonly resets: ReadonlyArray<{
470 readonly member: LimitPoolMember;
471 readonly at: number;
472 readonly restoresPercent: number;
473 }>;
474}
475
476export interface LimitPool {
477 readonly driver: ServerProvider["driver"];
478 readonly accounts: readonly LimitAccount[];
479 readonly windows: readonly LimitPoolWindow[];
480}
481
482function hasDuplicateCursorTotal(pool: LimitPool): boolean {
483 if (pool.windows.length !== 2) return false;
484 const total = pool.windows.find((window) => window.id === "totalPercentUsed");
485 const auto = pool.windows.find((window) => window.id === "autoPercentUsed");
486 return (
487 total !== undefined &&
488 auto !== undefined &&
489 total.members.length === auto.members.length &&
490 total.members.every(({ account, window }) =>
491 account.limits.windows.some(
492 (candidate) =>
493 candidate.id === auto.id &&
494 candidate.kind === auto.kind &&
495 candidate.usedPercent === window.usedPercent &&
496 candidate.resetsAt === window.resetsAt,
497 ),
498 )
499 );
500}
501
502export function displayLimitWindows(pool: LimitPool) {
503 if (pool.driver === "antigravity") {
504 return [...pool.windows].sort(
505 (left, right) =>
506 Number(!left.id.startsWith("gemini-")) - Number(!right.id.startsWith("gemini-")) ||
507 left.id
508 .replace(/-(5h|weekly|quota)$/, "")
509 .localeCompare(right.id.replace(/-(5h|weekly|quota)$/, "")) ||
510 WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind],
511 );
512 }
513 if (pool.driver !== "cursor") return pool.windows;
514 const hasAuto = pool.windows.some((window) => window.id === "autoPercentUsed");
515 const hasApi = pool.windows.some((window) => window.id === "apiPercentUsed");
516 const hideTotal = (hasAuto && hasApi) || hasDuplicateCursorTotal(pool);
517 return pool.windows
518 .filter((window) => !hideTotal || window.id !== "totalPercentUsed")
519 .sort((left, right) => cursorUsageWindowRank(left.id) - cursorUsageWindowRank(right.id));
520}
521
522const WINDOW_KIND_ORDER: Record<ServerProviderUsageWindow["kind"], number> = {
523 session: 0,
524 weekly: 1,
525 monthly: 2,
526 other: 3,
527};
528
529export function collectLimitPools(
530 accounts: readonly LimitAccount[],
531 now: number,
532): readonly LimitPool[] {
533 const byDriver = new Map<ServerProvider["driver"], LimitAccount[]>();
534 for (const account of accounts) {
535 const list = byDriver.get(account.driver);
536 if (list) list.push(account);
537 else byDriver.set(account.driver, [account]);
538 }
539 return [...byDriver].map(([driver, members]) => {
540 const orderWindow = members
541 .flatMap((account) => account.limits.windows)
542 .sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind])[0];
543 const orderReset = (account: LimitAccount) => {
544 const window = account.limits.windows.find(
545 (window) => orderWindow && poolKey(driver, window) === poolKey(driver, orderWindow),
546 );
547 return (
548 (window ? resetMillis(poolMember(account, window, now).window) : null) ??
549 Number.POSITIVE_INFINITY
550 );
551 };
552 const sorted = [...members].sort(
553 (left, right) =>
554 orderReset(left) - orderReset(right) ||
555 accountSortName(left).localeCompare(accountSortName(right)) ||
556 left.key.localeCompare(right.key),
557 );
558 return { driver, accounts: sorted, windows: poolWindows(sorted, now) };
559 });
560}
561
562function accountSortName(account: LimitAccount): string {
563 return (account.displayName ?? account.email ?? account.key).toLowerCase();
564}
565
566const CODEX_PLAN_CAPACITY: Record<string, number> = {
567 "ChatGPT Plus Subscription": 1,
568 "ChatGPT Pro 100 Subscription": 5,
569 "ChatGPT Pro 200 Subscription": 20,
570};
571// Claude's published plan multipliers describe the session allowance only.
572const CLAUDE_SESSION_CAPACITY: Record<string, number> = { Pro: 1, Max: 5, Ultra: 20 };
573
574function planCapacity(account: LimitAccount, kind: ServerProviderUsageWindow["kind"]) {
575 if (!account.plan) return undefined;
576 if (account.driver === "codex" && (kind === "session" || kind === "weekly")) {
577 return CODEX_PLAN_CAPACITY[account.plan];
578 }
579 if (account.driver === "claudeAgent" && kind === "session") {
580 return CLAUDE_SESSION_CAPACITY[account.plan];
581 }
582 return undefined;
583}
584
585// Codex ids name slots, so pool by kind; other drivers' ids identify distinct allowances.
586function poolKey(driver: ServerProvider["driver"], window: ServerProviderUsageWindow): string {
587 return driver === "codex" ? window.kind : `${window.kind}:${window.id}`;
588}
589
590function poolMember(account: LimitAccount, reported: ServerProviderUsageWindow, now: number) {
591 const spentWeekly =
592 reported.kind === "session"
593 ? account.limits.windows.find(
594 (window) =>
595 window.usedPercent >= 100 &&
596 (account.driver === "claudeAgent"
597 ? window.id === "seven_day"
598 : account.driver === "codex" && window.kind === "weekly"),
599 )
600 : undefined;
601 if (!spentWeekly) return { account, window: reported, refillPercent: reported.usedPercent };
602
603 const weeklyReset = resetMillis(spentWeekly);
604 const sessionReset = resetMillis(reported);
605 const cutoff = Math.max(now, Date.parse(account.limits.checkedAt));
606 let resetsAt: string | undefined;
607 let refillPercent = 0;
608 // A stale or missing clock cannot tell us when blocked quota becomes usable.
609 if (
610 weeklyReset !== null &&
611 sessionReset !== null &&
612 weeklyReset > cutoff &&
613 sessionReset > cutoff
614 ) {
615 if (sessionReset <= weeklyReset) {
616 resetsAt = spentWeekly.resetsAt;
617 refillPercent = 100;
618 } else if (reported.usedPercent >= 100) {
619 resetsAt = reported.resetsAt;
620 refillPercent = 100;
621 } else {
622 resetsAt = spentWeekly.resetsAt;
623 refillPercent = 100 - Math.max(0, Math.min(100, reported.usedPercent));
624 }
625 }
626 return {
627 account,
628 // Suppression is not session spending, so it has no session pace clock.
629 window: { ...reported, usedPercent: 100, resetsAt, windowDurationMins: undefined },
630 refillPercent,
631 };
632}
633
634function poolWindows(accounts: readonly LimitAccount[], now: number): readonly LimitPoolWindow[] {
635 const byKey = new Map<string, LimitPoolMember[]>();
636 for (const account of accounts) {
637 for (const reported of account.limits.windows) {
638 const member = poolMember(account, reported, now);
639 const { window } = member;
640 const pooled = poolKey(account.driver, window);
641 const key = byKey.get(pooled)?.some((member) => member.account === account)
642 ? `${window.kind}:${window.id}`
643 : pooled;
644 const list = byKey.get(key);
645 if (list) list.push(member);
646 else byKey.set(key, [member]);
647 }
648 }
649 const pools = [...byKey].map(([key, members]): LimitPoolWindow => {
650 const memberByAccount = new Map(members.map((member) => [member.account.key, member]));
651 const first = members[0]!.window;
652 const id = members[0]!.account.driver === "codex" ? key : first.id;
653 // One unknown plan makes the pool incomparable by plan, so every member then counts once.
654 const capacities = members.map((m) => planCapacity(m.account, first.kind));
655 const weights = capacities.every((capacity) => capacity !== undefined)
656 ? capacities
657 : members.map(() => 1);
658 const weightOf = new Map(members.map((m, index) => [m, weights[index]!]));
659 const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
660 const usedPercent =
661 members.reduce((sum, m) => sum + m.window.usedPercent * weightOf.get(m)!, 0) / totalWeight;
662 // Judge pace only for windows with reset clocks; others add spend without elapsed time.
663 const timed = members.flatMap((m) => {
664 const share = elapsedShare(m.window, now);
665 return share === null
666 ? []
667 : [{ used: m.window.usedPercent, elapsed: share, weight: weightOf.get(m)! }];
668 });
669 const timedWeight = timed.reduce((sum, t) => sum + t.weight, 0);
670 const timedUsed = timed.reduce((sum, t) => sum + t.used * t.weight, 0) / timedWeight;
671 const meanElapsed =
672 timed.length > 0
673 ? timed.reduce((sum, t) => sum + t.elapsed * t.weight, 0) / timedWeight
674 : null;
675 const resets = members
676 .flatMap((member) => {
677 const at = resetMillis(member.window);
678 return at === null
679 ? []
680 : [
681 {
682 member,
683 at,
684 restoresPercent: Math.round(
685 (member.refillPercent * weightOf.get(member)!) / totalWeight,
686 ),
687 },
688 ];
689 })
690 .sort((left, right) => left.at - right.at);
691 return {
692 id,
693 kind: first.kind,
694 label: first.label,
695 members,
696 columns: accounts.map(
697 (account) => memberByAccount.get(account.key) ?? { account, window: null },
698 ),
699 usedPercent: Math.round(usedPercent),
700 remainingPercent: Math.round(100 - usedPercent),
701 pace: meanElapsed === null ? null : paceOfShares(timedUsed, meanElapsed),
702 resets,
703 };
704 });
705 return pools.sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind]);
706}
707
708export function limitsNotice(limits: ServerProviderUsageLimits): string | null {
709 if (limits.unavailable?.reason === "unsupported") {
710 return limits.unavailable.message ?? "This account has no subscription limits.";
711 }
712 if (limits.unavailable?.reason === "probeFailed") {
713 return limits.unavailable.message ?? "Could not read limits.";
714 }
715 return limits.windows.length === 0 ? "No limits reported." : null;
716}
717
718export function remainingPercent(window: ServerProviderUsageWindow): number {
719 return Math.round(100 - Math.max(0, Math.min(100, window.usedPercent)));
720}
721
722function resetMillis(window: ServerProviderUsageWindow): number | null {
723 if (window.resetsAt === undefined) return null;
724 const at = Date.parse(window.resetsAt);
725 return Number.isFinite(at) ? at : null;
726}
727
728export function elapsedShare(window: ServerProviderUsageWindow, now: number): number | null {
729 const resetsAt = resetMillis(window);
730 if (resetsAt === null || window.windowDurationMins === undefined) return null;
731 const length = window.windowDurationMins * MINUTE;
732 if (length <= 0) return null;
733 return Math.max(0, Math.min(1, (length - (resetsAt - now)) / length));
734}
735
736// Bars show quota remaining, so even pace tracks time remaining.
737export function paceRemainingPercent(
738 window: ServerProviderUsageWindow,
739 now: number,
740): number | null {
741 const elapsed = elapsedShare(window, now);
742 return elapsed === null ? null : Math.round((1 - elapsed) * 100);
743}
744
745export type LimitPace = "ahead" | "on" | "under";
746
747export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null {
748 const elapsed = elapsedShare(window, now);
749 return elapsed === null ? null : paceOfShares(window.usedPercent, elapsed);
750}
751
752function paceOfShares(usedPercent: number, elapsed: number): LimitPace {
753 const gap = usedPercent - elapsed * 100;
754 if (gap > 5) return "ahead";
755 if (gap < -5) return "under";
756 return "on";
757}
758
759export function formatDuration(ms: number): string {
760 const remaining = Math.max(0, ms);
761 const days = Math.floor(remaining / DAY);
762 const hours = Math.floor((remaining % DAY) / HOUR);
763 const minutes = Math.floor((remaining % HOUR) / MINUTE);
764 if (days > 0) return `${days}d ${hours}h`;
765 if (hours > 0) return `${hours}h ${minutes}m`;
766 return `${minutes}m`;
767}
768
769/** One redeemable reset as clients offer it; `input` already names the chosen credit. */
770export interface ResetCreditChoice {
771 readonly key: string;
772 readonly summary: string;
773 readonly actionLabel: string;
774 readonly confirmDescription: string;
775 readonly availableCount: number;
776 readonly input: ProviderConsumeResetCreditInput;
777}
778
779export function resetCreditChoices(
780 credits: ServerProviderResetCredits,
781 input: ProviderConsumeResetCreditInput,
782 now: number,
783 compact = false,
784): ReadonlyArray<ResetCreditChoice> {
785 const expiresIn = (at: string | undefined) => (at ? formatDuration(Date.parse(at) - now) : null);
786 if (credits.options && "instanceId" in input) {
787 return credits.options.map((option) => {
788 const expires = expiresIn(option.expiresAt);
789 return {
790 key: option.creditId,
791 summary: `${option.label} · ${option.availableCount} left${expires ? ` · expires in ${expires}` : ""}`,
792 actionLabel: "Use",
793 confirmDescription: `${option.label}: ${option.description} This cannot be undone.`,
794 availableCount: option.availableCount,
795 input: { ...input, creditId: option.creditId },
796 };
797 });
798 }
799 const count = credits.availableCount;
800 const expires = expiresIn(credits.nextExpiresAt);
801 return [
802 {
803 key: "next",
804 summary:
805 count === 0
806 ? "No reset credits banked"
807 : compact
808 ? `${count} banked${expires ? ` · expires in ${expires}` : ""}`
809 : `${count} ${count === 1 ? "reset credit" : "reset credits"} banked${expires ? ` · next expires in ${expires}` : ""}`,
810 actionLabel: "Use reset",
811 confirmDescription:
812 "This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.",
813 availableCount: count,
814 input,
815 },
816 ];
817}
818
819export function formatResetsIn(window: ServerProviderUsageWindow, now: number): string | null {
820 const resetsAt = resetMillis(window);
821 if (resetsAt === null) return null;
822 if (resetsAt <= now) return "resets now";
823 if (resetsAt - now < MINUTE) return "Resets momentarily";
824 return `resets in ${formatDuration(resetsAt - now)}`;
825}
826
827/** Under this share of quota left, a window reads as low: amber, and worth a notice. */
828const LOW_LIMIT_REMAINING_PERCENT = 25;
829
830/** Whether the window has less than a quarter of its quota left. */
831export function isLowLimit(window: ServerProviderUsageWindow): boolean {
832 return remainingPercent(window) < LOW_LIMIT_REMAINING_PERCENT;
833}
834
835/**
836 * Milliseconds until the window's quota runs out at the rate spent so far,
837 * or null when that rate is unknown or the window resets first.
838 */
839export function projectRunOut(window: ServerProviderUsageWindow, now: number): number | null {
840 const resetsAt = resetMillis(window);
841 const elapsed = elapsedShare(window, now);
842 if (resetsAt === null || elapsed === null || window.windowDurationMins === undefined) {
843 return null;
844 }
845 const used = Math.max(0, Math.min(100, window.usedPercent));
846 const elapsedMs = elapsed * window.windowDurationMins * MINUTE;
847 if (used <= 0 || elapsedMs <= 0) return null;
848 if (used >= 100) return 0;
849 const runOut = ((100 - used) / used) * elapsedMs;
850 return runOut < resetsAt - now ? runOut : null;
851}
852
853/** `runs out in about 40m`, or null when the window resets before it runs dry. */
854export function formatRunsOutIn(window: ServerProviderUsageWindow, now: number): string | null {
855 const runOut = projectRunOut(window, now);
856 if (runOut === null) return null;
857 return runOut < MINUTE ? "runs out now" : `runs out in about ${formatDuration(runOut)}`;
858}
859
860export interface TightestLimit {
861 readonly account: LimitAccount;
862 readonly window: ServerProviderUsageWindow;
863 readonly remainingPercent: number;
864}
865
866/**
867 * The session window with the least quota left across the accounts, optionally
868 * for one driver. Accounts with no session window fall back to their shortest
869 * window kind, so a monthly-only plan still has something to report.
870 */
871export function tightestSessionLimit(
872 accounts: readonly LimitAccount[],
873 driver?: ServerProvider["driver"],
874): TightestLimit | null {
875 let tightest: TightestLimit | null = null;
876 for (const account of accounts) {
877 if (driver !== undefined && account.driver !== driver) continue;
878 const window = [...account.limits.windows].sort(
879 (left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind],
880 )[0];
881 if (!window) continue;
882 const remaining = remainingPercent(window);
883 if (tightest === null || remaining < tightest.remainingPercent) {
884 tightest = { account, window, remainingPercent: remaining };
885 }
886 }
887 return tightest;
888}
889
890export const USAGE_LIMITS_COMMAND = {
891 name: "usage-limits",
892 description: "Show this provider's usage limits",
893} satisfies ServerProviderSlashCommand;
894
895export function isUsageLimitsCommand(prompt: string): boolean {
896 return prompt.trim().toLowerCase() === "/usage-limits";
897}
898
899export function hasProviderUsageLimits(
900 driver: ServerProvider["driver"],
901 providers: readonly ServerProvider[],
902 sources: UsageLimitSourceSnapshots,
903): boolean {
904 return (
905 providersWithLimits(providers).some((provider) => provider.driver === driver) ||
906 sources.some(
907 (source) =>
908 source.accounts.some((account) => account.driver === driver) ||
909 (source.error !== undefined && source.accounts.length === 0),
910 )
911 );
912}
913
914export function sameUsageLimitCommandCoverage(
915 previous: UsageLimitSourceSnapshots,
916 next: UsageLimitSourceSnapshots,
917): boolean {
918 const coverage = (sources: UsageLimitSourceSnapshots) =>
919 new Set(
920 sources.flatMap((source) =>
921 source.error !== undefined && source.accounts.length === 0
922 ? ["*"]
923 : source.accounts.map((account) => String(account.driver)),
924 ),
925 );
926 const before = coverage(previous);
927 const after = coverage(next);
928 return before.size === after.size && [...before].every((driver) => after.has(driver));
929}
930
931/** Advertise on workspace catalogs too, which replace the global command list. */
932export function withUsageLimitsCommands(
933 providers: readonly ServerProvider[],
934 sources: UsageLimitSourceSnapshots,
935): ServerProvider[] {
936 const supportedDrivers = new Set([
937 ...providersWithLimits(providers).map((provider) => provider.driver),
938 ...sources.flatMap((source) => source.accounts.map((account) => account.driver)),
939 ]);
940 const unreadableSource = sources.some(
941 (source) => source.error !== undefined && source.accounts.length === 0,
942 );
943 return providers.map((provider) => {
944 if (!unreadableSource && !supportedDrivers.has(provider.driver)) return provider;
945 const commands = (items: readonly ServerProviderSlashCommand[]) => [
946 ...items.filter((command) => command.name !== USAGE_LIMITS_COMMAND.name),
947 USAGE_LIMITS_COMMAND,
948 ];
949 return {
950 ...provider,
951 slashCommands: commands(provider.slashCommands),
952 ...(provider.workspaceSnapshots
953 ? {
954 workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({
955 ...snapshot,
956 slashCommands: commands(snapshot.slashCommands),
957 })),
958 }
959 : {}),
960 };
961 });
962}
963
964export function collectProviderUsageLimits(
965 instanceId: ProviderInstanceId,
966 providers: readonly ServerProvider[],
967 sources: UsageLimitSourceSnapshots,
968 now: number,
969): UsageLimitsReport | null {
970 const selected = providers.find((provider) => provider.instanceId === instanceId);
971 if (!selected || !hasProviderUsageLimits(selected.driver, providers, sources)) return null;
972 const resolveKey = accountKeyResolver([
973 ...providers.filter((provider) => provider.enabled).map(nativeReport),
974 ...sources.flatMap((source) => source.accounts),
975 ]);
976 type NativeSnapshot = { provider: ServerProvider; limits: ServerProviderUsageLimits };
977 const nativeByAccount = new Map<
978 string,
979 { usage: NativeSnapshot; credits: NativeSnapshot | null }
980 >();
981 for (const provider of providersWithLimits(providers)) {
982 if (provider.driver !== selected.driver || !provider.usageLimits) continue;
983 const snapshot = { provider, limits: provider.usageLimits };
984 const key = resolveKey(nativeReport(provider)) ?? `instance:${provider.instanceId}`;
985 const previous = nativeByAccount.get(key);
986 const previousUsable = previous ? limitsNotice(previous.usage.limits) === null : false;
987 const usable = limitsNotice(snapshot.limits) === null;
988 const providerWins =
989 !previous ||
990 (usable && !previousUsable) ||
991 (usable === previousUsable &&
992 Date.parse(snapshot.limits.checkedAt) > Date.parse(previous.usage.limits.checkedAt));
993 const previousCredit = previous?.credits;
994 const credits =
995 snapshot.limits.resetCredits &&
996 (!previousCredit ||
997 Date.parse(snapshot.limits.checkedAt) > Date.parse(previousCredit.limits.checkedAt))
998 ? snapshot
999 : (previousCredit ?? null);
1000 nativeByAccount.set(key, {
1001 usage: providerWins ? snapshot : previous.usage,
1002 credits,
1003 });
1004 }
1005 const native = [...nativeByAccount.values()];
1006 const nativeAccountKeys = new Set(
1007 native.flatMap(({ usage: { provider, limits } }) => {
1008 const key = resolveKey(nativeReport(provider));
1009 return key && limits.windows.length && !limits.unavailable ? [key] : [];
1010 }),
1011 );
1012 const accounts: Array<UsageLimitsReport["accounts"][number]> = [];
1013 const notices: string[] = [];
1014 for (const {
1015 usage: { provider, limits },
1016 credits,
1017 } of native) {
1018 const key = resolveKey(nativeReport(provider));
1019 const hubCredits = sources
1020 .flatMap((source) => source.accounts.map((account) => ({ source, account })))
1021 .filter(
1022 ({ account }) =>
1023 key !== null &&
1024 resolveKey(account) === key &&
1025 account.usageLimits.resetCredits &&
1026 !limitsNotice(account.usageLimits),
1027 )
1028 .sort(
1029 (a, b) =>
1030 Date.parse(b.account.usageLimits.checkedAt) - Date.parse(a.account.usageLimits.checkedAt),
1031 )[0];
1032 const hubCreditId = hubCredits?.account.usageLimits.resetCredits?.nextCreditId;
1033 const showHubCredits =
1034 hubCredits &&
1035 (!credits ||
1036 Date.parse(hubCredits.account.usageLimits.checkedAt) >
1037 Date.parse(credits.limits.checkedAt));
1038 const shownCredits = showHubCredits
1039 ? hubCredits.account.usageLimits.resetCredits
1040 : credits?.limits.resetCredits;
1041 accounts.push({
1042 id: provider.instanceId,
1043 driver: provider.driver,
1044 label: `${provider.displayName?.trim() || String(provider.driver)} [${provider.instanceId}]`,
1045 ...(provider.auth.label ? { plan: provider.auth.label } : {}),
1046 instanceId: provider.instanceId,
1047 resetCreditInput:
1048 hubCreditId && hubCredits
1049 ? {
1050 sourceId: hubCredits.source.id,
1051 accountId: hubCredits.account.id,
1052 creditId: hubCreditId,
1053 }
1054 : { instanceId: credits?.provider.instanceId ?? provider.instanceId },
1055 ...(provider.displayName ? { displayName: provider.displayName } : {}),
1056 ...(provider.accentColor ? { accentColor: provider.accentColor } : {}),
1057 ...(provider.auth.email ? { email: provider.auth.email } : {}),
1058 limits: shownCredits ? { ...limits, resetCredits: shownCredits } : limits,
1059 });
1060 }
1061 let proxyAccountNumber = 0;
1062 for (const source of sources) {
1063 const matching = source.accounts.filter((account) => account.driver === selected.driver);
1064 for (const account of matching) {
1065 const key = resolveKey(account);
1066 if (key && nativeAccountKeys.has(key)) continue;
1067 proxyAccountNumber += 1;
1068 const driverLabel =
1069 account.driver === "claudeAgent"
1070 ? "Claude"
1071 : String(account.driver).charAt(0).toUpperCase() + String(account.driver).slice(1);
1072 accounts.push({
1073 id: `${source.id}:${account.id}`,
1074 driver: account.driver,
1075 label:
1076 account.email?.trim() ||
1077 hubAccountName(account) ||
1078 `${driverLabel} account ${proxyAccountNumber}`,
1079 sourceLabel: source.label,
1080 ...(account.usageLimits.resetCredits?.nextCreditId
1081 ? {
1082 resetCreditInput: {
1083 sourceId: source.id,
1084 accountId: account.id,
1085 creditId: account.usageLimits.resetCredits.nextCreditId,
1086 },
1087 }
1088 : {}),
1089 ...(account.plan ? { plan: account.plan } : {}),
1090 ...(account.email ? { email: account.email } : {}),
1091 limits: account.usageLimits,
1092 });
1093 }
1094 if (source.error && (matching.length > 0 || source.accounts.length === 0)) {
1095 notices.push(`${source.label}: ${source.error}`);
1096 }
1097 }
1098 return { createdAt: DateTime.formatIso(DateTime.makeUnsafe(now)), accounts, notices };
1099}