diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts new file mode 100644 index 0000000000000..3ccfb8d8af062 --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -0,0 +1,244 @@ +import { + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type ServerProviderUsageLimits, + type UsageLimitSourceSnapshot, + UsageLimitSourceId, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { nextPoolReset, poolSources, resolveThreadDetailsLimits } from "./ThreadDetailsUsage.logic"; + +const now = Date.parse("2026-10-30T12:00:00.000Z"); +const checkedAt = "2026-10-30T11:59:00.000Z"; +const claude = ProviderDriverKind.make("claudeAgent"); +const codex = ProviderDriverKind.make("codex"); +const instanceId = ProviderInstanceId.make("claudeAgent"); + +function limitsAt(usedPercent: number, at = checkedAt): ServerProviderUsageLimits { + return { + checkedAt: at, + windows: [ + { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent, + windowDurationMins: 300, + resetsAt: "2026-10-30T13:17:00.000Z", + }, + ], + }; +} + +function limitsResettingAt(usedPercent: number, resetsAt: string): ServerProviderUsageLimits { + const [window] = limitsAt(usedPercent).windows; + return { checkedAt, windows: [{ ...window!, resetsAt }] }; +} + +const unsupported: ServerProviderUsageLimits = { + checkedAt, + windows: [], + unavailable: { reason: "unsupported" }, +}; + +function claudeInstance(usageLimits: ServerProviderUsageLimits): ServerProvider { + return { + instanceId, + driver: claude, + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated", type: "customEndpoint", label: "Custom API endpoint" }, + checkedAt, + models: [], + slashCommands: [], + skills: [], + usageLimits, + }; +} + +function signedIn(id: string, email: string, usageLimits: ServerProviderUsageLimits) { + const base = claudeInstance(usageLimits); + return { + ...base, + instanceId: ProviderInstanceId.make(id), + auth: { ...base.auth, email }, + } satisfies ServerProvider; +} + +interface HubAccountInput { + readonly id: string; + readonly email?: string; + readonly driver?: ProviderDriverKind; + readonly limits: ServerProviderUsageLimits; +} + +function hub( + label: string, + accounts: readonly HubAccountInput[], + error?: string, +): UsageLimitSourceSnapshot { + return { + id: UsageLimitSourceId.make(label), + kind: "cliproxy", + label, + checkedAt, + accounts: accounts.map((account) => ({ + id: account.id, + driver: account.driver ?? claude, + ...(account.email ? { email: account.email } : {}), + usageLimits: account.limits, + })), + ...(error ? { error } : {}), + }; +} + +/** What the card shows, reduced to the rows a user reads. */ +function shown( + own: ServerProviderUsageLimits, + sources: readonly UsageLimitSourceSnapshot[], + natives: readonly ServerProvider[] = [], +) { + const resolved = resolveThreadDetailsLimits( + instanceId, + [claudeInstance(own), ...natives], + sources, + now, + ); + if (resolved?.kind !== "pool") return resolved; + return { + kind: resolved.kind, + rows: resolved.windows.map((window) => ({ + label: window.label, + left: window.remainingPercent, + accounts: window.members.length, + from: poolSources(window), + })), + }; +} + +describe("resolveThreadDetailsLimits", () => { + it("shows nothing without a selected, known instance, or before its first reading", () => { + expect(resolveThreadDetailsLimits(null, [claudeInstance(unsupported)], [], now)).toBeNull(); + expect( + resolveThreadDetailsLimits(ProviderInstanceId.make("gone"), [], [hub("Hub", [])], now), + ).toBeNull(); + const { usageLimits: _unread, ...unread } = claudeInstance(unsupported); + const readableHub = hub("Hub", [{ id: "a.json", limits: limitsAt(20) }]); + expect(resolveThreadDetailsLimits(instanceId, [unread], [readableHub], now)).toBeNull(); + }); + + it("keeps an instance's own readable limits over any hub", () => { + const own = limitsAt(40); + expect(shown(own, [hub("Hub", [{ id: "a.json", limits: limitsAt(90) }])])).toEqual({ + kind: "instance", + limits: own, + }); + }); + + it.each([ + { + name: "one hub account", + sources: [hub("Hub", [{ id: "a.json", email: "a@example.com", limits: limitsAt(19) }])], + rows: [{ label: "Session", left: 81, accounts: 1, from: ["Hub"] }], + }, + { + name: "several accounts, pooled like Usage → Limits", + sources: [ + hub("Hub", [ + { id: "a.json", email: "a@example.com", limits: limitsAt(20) }, + { id: "b.json", email: "b@example.com", limits: limitsAt(60) }, + ]), + ], + rows: [{ label: "Session", left: 60, accounts: 2, from: ["Hub"] }], + }, + { + name: "one login reported by two hubs, counted once at its freshest reading", + sources: [ + hub("Old", [{ id: "a.json", email: "a@example.com", limits: limitsAt(90) }]), + hub("New", [ + { + id: "a.json", + email: "a@example.com", + limits: limitsAt(30, "2026-10-30T11:59:30.000Z"), + }, + ]), + ], + rows: [{ label: "Session", left: 70, accounts: 1, from: ["Old"] }], + }, + { + name: "accounts across hubs, skipping a hub that failed to read", + sources: [ + hub("Home", [{ id: "a.json", email: "a@example.com", limits: limitsAt(20) }]), + hub("Work", [{ id: "b.json", email: "b@example.com", limits: limitsAt(40) }]), + hub("Down", [], "The hub answered with HTTP 404."), + ], + rows: [{ label: "Session", left: 70, accounts: 2, from: ["Home", "Work"] }], + }, + { + name: "only this driver's readable accounts", + sources: [ + hub("Hub", [ + { id: "a.json", email: "a@example.com", limits: limitsAt(50) }, + { id: "c.json", email: "c@example.com", driver: codex, limits: limitsAt(0) }, + { id: "d.json", email: "d@example.com", limits: unsupported }, + ]), + ], + rows: [{ label: "Session", left: 50, accounts: 1, from: ["Hub"] }], + }, + { + name: "a login signed in natively and on a hub, counted once at its freshest reading", + sources: [hub("Hub", [{ id: "a.json", email: "a@example.com", limits: limitsAt(90) }])], + natives: [signedIn("claude-a", "a@example.com", limitsAt(30, "2026-10-30T11:59:30.000Z"))], + rows: [{ label: "Session", left: 70, accounts: 1, from: ["Signed-in accounts"] }], + }, + { + name: "a signed-in account no hub holds, alongside the hub's", + sources: [hub("Hub", [{ id: "a.json", email: "a@example.com", limits: limitsAt(20) }])], + natives: [signedIn("claude-b", "b@example.com", limitsAt(60))], + rows: [{ label: "Session", left: 60, accounts: 2, from: ["Hub", "Signed-in accounts"] }], + }, + ])("pools $name when the instance reads no limits", ({ sources, natives, rows }) => { + expect(shown(unsupported, sources, natives)).toEqual({ kind: "pool", rows }); + }); + + it("names the next reset that refills quota, skipping an unused account's 0% reset", () => { + const soon = "2026-10-30T12:10:00.000Z"; + const later = "2026-10-30T14:00:00.000Z"; + const resolved = resolveThreadDetailsLimits( + instanceId, + [claudeInstance(unsupported)], + [ + hub("Hub", [ + { id: "a.json", email: "a@example.com", limits: limitsResettingAt(0, soon) }, + { id: "b.json", email: "b@example.com", limits: limitsResettingAt(80, later) }, + ]), + ], + now, + ); + if (resolved?.kind !== "pool") throw new Error("expected a pool"); + const [window] = resolved.windows; + expect(window!.resets.map((reset) => reset.restoresPercent)).toEqual([0, 40]); + expect(nextPoolReset(window!, now)).toMatchObject({ + at: Date.parse(later), + restoresPercent: 40, + }); + }); + + it("keeps the instance's notice when no hub has a readable account for its driver", () => { + const sources = [ + hub("Hub", [ + { id: "c.json", driver: codex, limits: limitsAt(10) }, + { id: "d.json", limits: unsupported }, + ]), + ]; + const natives = [signedIn("claude-b", "b@example.com", limitsAt(60))]; + expect(shown(unsupported, sources, natives)).toEqual({ + kind: "instance", + limits: unsupported, + }); + }); +}); diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts new file mode 100644 index 0000000000000..67ad40711201e --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -0,0 +1,91 @@ +import { + EnvironmentId, + type ProviderInstanceId, + type ServerProvider, + type ServerProviderUsageLimits, + type UsageLimitSourceSnapshots, +} from "@t3tools/contracts"; +import { + collectLimitAccounts, + collectLimitPools, + collectProviderUsageLimits, + displayLimitWindows, + type LimitPoolWindow, + limitsNotice, +} from "@t3tools/shared/usageLimits"; + +export type ThreadDetailsLimits = + | { readonly kind: "instance"; readonly limits: ServerProviderUsageLimits } + | { readonly kind: "pool"; readonly windows: readonly LimitPoolWindow[] }; + +// collectLimitAccounts keys its input by environment; the card reads one, so any id serves. +const CARD_ENVIRONMENT = EnvironmentId.make("thread-details"); + +/** + * The limits the thread details card shows for one provider instance: its own, or, when it reads + * none (as when it sends requests through a CLIProxyAPI hub), the pool of its driver's accounts. + */ +export function resolveThreadDetailsLimits( + providerInstanceId: ProviderInstanceId | null, + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, + now: number, +): ThreadDetailsLimits | null { + const provider = providers.find((entry) => entry.instanceId === providerInstanceId); + if (!provider) return null; + const report = collectProviderUsageLimits(provider.instanceId, providers, sources, now); + // Only this instance's data: the report keeps one instance per login, and any other entry may be + // a different login or workspace, so fall back to the instance's own snapshot. + const own = + report?.accounts.find((entry) => entry.instanceId === provider.instanceId)?.limits ?? + provider.usageLimits; + if (!own) return null; + if (limitsNotice(own) === null) return { kind: "instance", limits: own }; + return driverPool(provider.driver, providers, sources, now) ?? { kind: "instance", limits: own }; +} + +/** The reset the card names: the soonest for one account, the soonest that refills quota for a pool. */ +export function nextPoolReset(window: LimitPoolWindow, now: number) { + const pooled = window.members.length > 1; + return ( + window.resets.find((reset) => reset.at > now && (!pooled || reset.restoresPercent > 0)) ?? null + ); +} + +function driverPool( + driver: ServerProvider["driver"], + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, + now: number, +): ThreadDetailsLimits | null { + const hubReadsDriver = sources.some((source) => + source.accounts.some( + (account) => account.driver === driver && limitsNotice(account.usageLimits) === null, + ), + ); + if (!hubReadsDriver) return null; + const presentations = new Map([ + [ + CARD_ENVIRONMENT, + { + entry: { target: { label: "" } }, + serverConfig: { providers, usageLimitSources: sources }, + }, + ], + ]); + const accounts = collectLimitAccounts(presentations).filter( + (account) => account.driver === driver, + ); + const [pool] = collectLimitPools(accounts, now); + if (!pool) return null; + return { kind: "pool", windows: displayLimitWindows(pool) }; +} + +/** Where a pooled window's readings come from: the hubs holding its hub-only accounts, then signed-in accounts. */ +export function poolSources(window: LimitPoolWindow): string[] { + const hubs = window.members.flatMap(({ account }) => + account.environments.length === 0 && account.sourceLabel ? [account.sourceLabel] : [], + ); + const signedIn = window.members.some(({ account }) => account.environments.length > 0); + return [...new Set(hubs), ...(signedIn ? ["Signed-in accounts"] : [])]; +} diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.tsx b/apps/web/src/components/chat/ThreadDetailsUsage.tsx index f3ee87e1b7f7b..f376870afa29a 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.tsx +++ b/apps/web/src/components/chat/ThreadDetailsUsage.tsx @@ -1,13 +1,14 @@ import type { ProviderInstanceId, ServerProvider, + ServerProviderUsageLimits, ServerProviderUsageWindow, UsageLimitSourceSnapshots, } from "@t3tools/contracts"; import { - collectProviderUsageLimits, formatDuration, type LimitPace, + type LimitPoolWindow, limitsNotice, paceOf, remainingPercent, @@ -23,6 +24,9 @@ import { formatUpcomingTimestamp } from "../../timestampFormat"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; +import { nextPoolReset, poolSources, resolveThreadDetailsLimits } from "./ThreadDetailsUsage.logic"; + +const MINUTE_MS = 60_000; const PACE_LABEL: Record = { ahead: "Ahead of pace", @@ -39,7 +43,16 @@ export interface ThreadDetailsUsageInput { type UsageTone = "error" | "warning" | null; -/** The selected provider's limit windows. Each row reads as room left. */ +function limitTone(left: number): UsageTone { + if (left <= 10) return "error"; + if (left <= 20) return "warning"; + return null; +} + +/** + * The selected provider's limit windows, or its driver's pooled accounts when it reads none. + * Each row reads as room left. + */ export function ThreadDetailsUsage({ providerInstanceId, providers, @@ -47,17 +60,8 @@ export function ThreadDetailsUsage({ }: Omit) { const nowMinute = useNowMinute(); const now = Date.parse(`${nowMinute}:00Z`); - const report = - providerInstanceId !== null - ? collectProviderUsageLimits(providerInstanceId, providers, sources, now) - : null; - // Only this instance's data: the report keeps one instance per login, and any other entry may be - // a different login or workspace, so fall back to the instance's own snapshot. - const limits = - report?.accounts.find((entry) => entry.instanceId === providerInstanceId)?.limits ?? - providers.find((provider) => provider.instanceId === providerInstanceId)?.usageLimits; - if (!limits) return null; - const notice = limitsNotice(limits); + const resolved = resolveThreadDetailsLimits(providerInstanceId, providers, sources, now); + if (!resolved) return null; return (
- {notice ? ( - {notice}} - /> + {resolved.kind === "pool" ? ( + resolved.windows.map((window) => ) ) : ( - limits.windows.map((window) => ) + )}
); } +function InstanceRows({ + limits, + now, +}: { + readonly limits: ServerProviderUsageLimits; + readonly now: number; +}) { + const notice = limitsNotice(limits); + if (notice) { + return ( + {notice}} + /> + ); + } + return limits.windows.map((window) => ); +} + /** The thread's context window. The bar fills as context is used; the tick marks auto-compaction. */ export function ThreadDetailsContext({ usage }: { readonly usage: ContextWindowSnapshot }) { const maxTokens = usage.maxTokens != null && usage.maxTokens > 0 ? usage.maxTokens : null; @@ -153,7 +173,7 @@ function LimitRow({ + + {window.label}: {left}% left + {accounts > 1 ? ` across ${accounts} accounts` : ""} + + + {window.pace ? ( + {PACE_LABEL[window.pace]} + ) : null} + {from.length > 0 ? ( + From {from.join(", ")} + ) : null} + + } + /> + ); +} + +function PoolResetLine({ + window, + now, +}: { + readonly window: LimitPoolWindow; + readonly now: number; +}) { + const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); + const next = nextPoolReset(window, now); + if (!next) return null; + const resetsAt = next.member.window.resetsAt; + if (!resetsAt) return null; + const remaining = next.at - now; + const when = remaining < MINUTE_MS ? "momentarily" : `in ${formatDuration(remaining)}`; + const restores = window.members.length > 1 ? next.restoresPercent : null; + const label = restores === null ? `Resets ${when}` : `+${restores}% ${when}`; + return ( + + {label} · {formatUpcomingTimestamp(resetsAt, timestampFormat, now)} + + ); +} + /** One label, hairline, and value row; the whole row opens the details tooltip. */ function UsageRow({ label, diff --git a/docs/user/usage.md b/docs/user/usage.md index d25bdba81f00a..5dadf3fde08c5 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -150,9 +150,12 @@ anything. The command is offered only for providers that appear under **Usage To keep them in view on web and desktop, turn on **Usage limits** in **Settings → Appearance → Thread details**, or right-click the thread details card. The bottom of the thread details card then shows how much is left in each limit window of the current provider account. Some windows, such as a weekly Opus limit, count only one model. Hover a -row to see when it resets and your pace. Turn on **Context window** to also show the thread's -context use against the model's limit. A tick on the bar marks where the provider compacts -automatically, when it reports that point. +row to see when it resets and your pace. A provider that sends requests through a CLIProxyAPI hub +reports no limits of its own, so the card shows that provider's accounts pooled, the hub's and +any signed-in ones, from this environment only. **Usage → Limits** pools every connected +environment. Turn on **Context window** to also show the thread's context use against the +model's limit. A tick on the bar marks where the provider compacts automatically, when it reports +that point. OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in the environment. T3 cannot report limits for external OpenCode servers because their credentials