From 3a14e41156726cc0678f5c5db67b293bd1392155 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Wed, 26 Aug 2026 14:57:32 -0600 Subject: [PATCH 01/18] feat(usage): show subscription limits --- .../src/features/usage/UsageRouteScreen.tsx | 151 +++++++++++++--- .../src/provider/Layers/ClaudeProvider.ts | 53 ++++++ .../src/provider/Layers/CodexProvider.ts | 56 +++++- apps/server/src/usage/UsageService.ts | 61 +++++++ .../src/usage/usageSubscriptionLimits.test.ts | 105 +++++++++++ .../src/usage/usageSubscriptionLimits.ts | 121 +++++++++++++ .../src/components/usage/UsagePage.test.tsx | 55 ++++++ apps/web/src/components/usage/UsagePage.tsx | 169 ++++++++++++++---- .../components/usage/UsageProviderChart.tsx | 8 +- docs/user/usage.md | 8 + packages/contracts/src/usage.ts | 28 ++- packages/shared/src/usageFormat.test.ts | 20 +++ packages/shared/src/usageFormat.ts | 19 ++ packages/shared/src/usageMerge.test.ts | 41 +++++ packages/shared/src/usageMerge.ts | 17 ++ 15 files changed, 846 insertions(+), 66 deletions(-) create mode 100644 apps/server/src/usage/usageSubscriptionLimits.test.ts create mode 100644 apps/server/src/usage/usageSubscriptionLimits.ts diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 817e6d7f9543..b2a975f70ccf 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,4 +1,5 @@ import { useNavigation } from "@react-navigation/native"; +import type { UsageLimitWindow, UsageProviderLimits } from "@t3tools/contracts"; import type { DailyTotals, MergedUsage } from "@t3tools/shared/usageMerge"; import { enumerateDays, @@ -8,10 +9,11 @@ import { formatHourShort, formatPercent, formatTokens, + formatUsageResetCountdown, formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -41,10 +43,16 @@ export function UsageRouteScreen() { window: makeWindow(30), })); const [metric, setMetric] = useState("cost"); + const [nowMs, setNowMs] = useState(Date.now()); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; const { merged, environments, isPending, isPartial, refresh } = useUsage(window); + useEffect(() => { + const interval = globalThis.setInterval(() => setNowMs(Date.now()), 60_000); + return () => globalThis.clearInterval(interval); + }, []); + const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), [window.sinceDay, window.untilDay], @@ -138,7 +146,12 @@ export function UsageRouteScreen() { isPast24Hours={isPast24Hours} timeZone={window.timeZone} /> - + @@ -295,52 +308,66 @@ function MetricToggle(props: { function ProviderSection(props: { readonly merged: MergedUsage; readonly metric: UsageChartMetric; + readonly nowMs: number; + readonly timeZone: string; }) { const { merged, metric } = props; const colors = useProviderColors(); - if (merged.providers.length === 0) return null; + if (merged.providers.length === 0 && merged.subscriptionLimits.length === 0) return null; // Ranked by whatever the toggle is showing, so the rows always descend. // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 method. const ordered = [...merged.providers].sort((a, b) => metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, ); + const orderedProviders = [ + ...ordered.map((provider) => provider.provider), + ...merged.subscriptionLimits + .map((limits) => limits.provider) + .filter((provider) => !ordered.some((entry) => entry.provider === provider)), + ]; return ( - {ordered.map((provider, index) => { - const share = metric === "cost" ? provider.costShare : provider.tokenShare; + {orderedProviders.map((providerKind, index) => { + const provider = merged.providers.find((entry) => entry.provider === providerKind); + const limits = merged.subscriptionLimits.find((entry) => entry.provider === providerKind); + const share = metric === "cost" ? (provider?.costShare ?? 0) : (provider?.tokenShare ?? 0); return ( - - - - {PROVIDER_LABEL[provider.provider]} + + + {PROVIDER_LABEL[providerKind]} + + {metric === "cost" + ? formatUsd(provider?.costUsd ?? 0) + : formatTokens(provider?.totalTokens ?? 0)} + - + {metric === "cost" - ? formatUsd(provider.costUsd) - : formatTokens(provider.totalTokens)} + ? `${formatPercent(share)} of cost · ${formatTokens(provider?.totalTokens ?? 0)} tokens` + : `${formatPercent(share)} of tokens · ${formatUsd(provider?.costUsd ?? 0)}`} - - {metric === "cost" - ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` - : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} - + {limits ? ( + + ) : null} ); })} @@ -348,6 +375,86 @@ function ProviderSection(props: { ); } +const LIMIT_WINDOW_LABEL: Record = { + fiveHour: "5h", + weekly: "Week", +}; + +function UsageLimitMeters(props: { + readonly limits: UsageProviderLimits; + readonly color: string; + readonly nowMs: number; + readonly timeZone: string; +}) { + return ( + + {props.limits.windows.map((window) => { + const percent = Math.min(100, Math.max(0, window.usedPercent)); + const reset = window.resetsAt + ? new Intl.DateTimeFormat("en-US", { + timeZone: props.timeZone, + month: "short", + day: "numeric", + hour: "numeric", + }).format(new Date(window.resetsAt)) + : null; + const countdown = window.resetsAt + ? formatUsageResetCountdown(window.resetsAt, props.nowMs) + : null; + const label = LIMIT_WINDOW_LABEL[window.kind]; + return ( + + + {label} + + + + + {window.unlimited ? "∞" : `${Math.round(percent)}%`} + + + + {window.unlimited + ? "No limit" + : countdown === "now" + ? "Reset due" + : countdown + ? `Resets in ${countdown}` + : "Reset time unavailable"} + + + ); + })} + + ); +} + function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24Hours: boolean }) { const { merged } = props; const activePeriods = (props.isPast24Hours ? merged.hourly : merged.daily).filter( diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index f815ac75be34..30ef8826987a 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -23,6 +23,7 @@ import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, type Options as ClaudeQueryOptions, + type SDKControlGetUsageResponse, type SlashCommand as ClaudeSlashCommand, type SDKUserMessage, type SettingSource, @@ -782,6 +783,58 @@ const probeClaudeCapabilities = ( ); }; +/** + * Reads the structured data behind Claude Code's `/usage` screen without + * sending a prompt or starting an Anthropic API request. + */ +export const probeClaudeUsage = ( + claudeSettings: ClaudeSettings, + environment?: NodeJS.ProcessEnv, + cwd?: string, +): Effect.Effect< + SDKControlGetUsageResponse | undefined, + never, + FileSystem.FileSystem | Path.Path +> => { + const abort = new AbortController(); + return Effect.gen(function* () { + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + const executablePath = yield* resolveClaudeSdkExecutablePath( + claudeSettings.binaryPath, + claudeEnvironment, + ); + return yield* Effect.tryPromise(async () => { + const q = claudeQuery({ + // Never yield. Initialization is enough to use the local control API. + // oxlint-disable-next-line require-yield + prompt: (async function* (): AsyncGenerator { + await waitForAbortSignal(abort.signal); + })(), + options: buildClaudeCapabilitiesProbeQueryOptions({ + executablePath, + abortController: abort, + environment: claudeEnvironment, + cwd, + }), + }); + await q.initializationResult(); + return q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(); + }); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (!abort.signal.aborted) abort.abort(); + }), + ), + Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS), + Effect.result, + Effect.map((result) => { + if (Result.isFailure(result)) return undefined; + return Option.isSome(result.success) ? result.success.value : undefined; + }), + ); +}; + const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( claudeSettings: ClaudeSettings, args: ReadonlyArray, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 52a8fdd25dc7..6dd7a6dd7de2 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -50,6 +50,14 @@ export interface CodexAppServerProviderSnapshot { readonly skills: ReadonlyArray; } +interface CodexAppServerProbeInput { + readonly binaryPath: string; + readonly homePath?: string; + readonly launchArgs?: string; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv; +} + const REASONING_EFFORT_LABELS: Readonly> = { none: "None", minimal: "Minimal", @@ -313,14 +321,9 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { }; } -const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { - readonly binaryPath: string; - readonly homePath?: string; - readonly launchArgs?: string; - readonly cwd: string; - readonly customModels?: ReadonlyArray; - readonly environment?: NodeJS.ProcessEnv; -}) { +const startCodexAppServerProbe = Effect.fn("startCodexAppServerProbe")(function* ( + input: CodexAppServerProbeInput, +) { // `~` is not shell-expanded when env vars are set via `child_process.spawn`, // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip // "CODEX_HOME points to '~/.codex_work', but that path does not exist". @@ -379,6 +382,16 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun const versionMatch = initialize.userAgent.match(/\/([^\s]+)/); const version = versionMatch ? versionMatch[1] : undefined; + return { client, version } as const; +}); + +const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* ( + input: CodexAppServerProbeInput & { + readonly customModels?: ReadonlyArray; + }, +) { + const { client, version } = yield* startCodexAppServerProbe(input); + const accountResponse = yield* client.request("account/read", {}); if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { return { @@ -409,6 +422,33 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; }); +/** Reads Codex's current ChatGPT subscription quota windows. */ +export const probeCodexRateLimits = Effect.fn("probeCodexRateLimits")(function* ( + codexSettings: CodexSettings, + environment: NodeJS.ProcessEnv = process.env, + cwd: string = process.cwd(), +) { + if (!codexSettings.enabled) return undefined; + + const response = yield* Effect.gen(function* () { + const { client } = yield* startCodexAppServerProbe({ + binaryPath: codexSettings.binaryPath, + homePath: codexSettings.homePath, + launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, environment), + cwd, + environment, + }); + return yield* client.request("account/rateLimits/read", undefined); + }).pipe( + Effect.scoped, + Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)), + Effect.result, + ); + + if (Result.isFailure(response) || Option.isNone(response.success)) return undefined; + return response.success.value; +}); + const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => { const models = new Set(); for (const model of codexSettings.customModels) { diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 0bf131ac973b..61b03ca32253 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -16,6 +16,7 @@ import * as NodeOS from "node:os"; import { USAGE_CONTRACT_VERSION, type UsageProviderKind, + type UsageProviderLimits, type UsageSource, type UsageSummary, type UsageSummaryInput, @@ -27,16 +28,20 @@ import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { ServerConfig } from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { probeClaudeUsage } from "../provider/Layers/ClaudeProvider.ts"; +import { probeCodexRateLimits } from "../provider/Layers/CodexProvider.ts"; import { UsageAggregator } from "./usageAggregation.ts"; import { parseRateTable, type RateTable } from "./usagePricing.ts"; import { @@ -52,12 +57,18 @@ import { type ScanCache, } from "./usageScanCache.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; +import { + normalizeClaudeSubscriptionLimits, + normalizeCodexSubscriptionLimits, +} from "./usageSubscriptionLimits.ts"; const LITELLM_RATES_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; /** Rates move rarely; a day-old table keeps the page working offline. */ const RATES_TTL_MS = 24 * 60 * 60 * 1000; +const SUBSCRIPTION_LIMITS_TTL_MS = 60 * 1000; +const SUBSCRIPTION_LIMITS_PROBE_TIMEOUT_MS = 5_000; /** * Files are filtered by mtime before opening. The slack covers a session whose @@ -106,6 +117,7 @@ export const layerTest = Layer.succeed( untilDay: input.untilDay, buckets: [], sources: [], + subscriptionLimits: [], pricing: { status: "unavailable", source: LITELLM_RATES_URL, @@ -123,6 +135,7 @@ export const make = Effect.gen(function* () { const config = yield* ServerConfig; const settingsService = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileCache: ScanCache = new Map(); let cacheDirty = false; @@ -132,6 +145,51 @@ export const make = Effect.gen(function* () { let rates: RateTable = new Map(); let ratesFetchedAtMs: number | null = null; let ratesStatus: UsageSummary["pricing"]["status"] = "unavailable"; + let subscriptionLimitsCache: { + readonly fetchedAtMs: number; + readonly limits: readonly UsageProviderLimits[]; + } | null = null; + + const readSubscriptionLimits = Effect.fn("UsageService.readSubscriptionLimits")(function* () { + const now = yield* Clock.currentTimeMillis; + if ( + subscriptionLimitsCache !== null && + now - subscriptionLimitsCache.fetchedAtMs < SUBSCRIPTION_LIMITS_TTL_MS + ) { + return subscriptionLimitsCache.limits; + } + + const settings = yield* settingsService.getSettings.pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + if (settings === null) return []; + + const [claudeResponse, codexResponse] = yield* Effect.all( + [ + settings.providers.claudeAgent.enabled + ? probeClaudeUsage(settings.providers.claudeAgent, process.env, config.cwd).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.timeoutOption(SUBSCRIPTION_LIMITS_PROBE_TIMEOUT_MS), + Effect.map(Option.getOrUndefined), + ) + : Effect.succeed(undefined), + probeCodexRateLimits(settings.providers.codex, process.env, config.cwd).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.timeoutOption(SUBSCRIPTION_LIMITS_PROBE_TIMEOUT_MS), + Effect.map(Option.getOrUndefined), + ), + ], + { concurrency: "unbounded" }, + ); + + const limits = [ + normalizeCodexSubscriptionLimits(codexResponse), + normalizeClaudeSubscriptionLimits(claudeResponse), + ].filter((entry): entry is UsageProviderLimits => entry !== null); + subscriptionLimitsCache = { fetchedAtMs: now, limits }; + return limits; + }); /** * Loads the LiteLLM rate table, preferring a fresh copy and falling back to @@ -323,6 +381,7 @@ export const make = Effect.gen(function* () { } const startedAtMs = yield* Clock.currentTimeMillis; + const subscriptionLimitsFiber = yield* readSubscriptionLimits().pipe(Effect.forkChild); yield* ensureRates(); yield* ensureScanCacheLoaded; @@ -420,6 +479,7 @@ export const make = Effect.gen(function* () { const aggregated = aggregator.finish(); const readAt = yield* DateTime.now; const finishedAtMs = yield* Clock.currentTimeMillis; + const subscriptionLimits = yield* Fiber.join(subscriptionLimitsFiber); return { contractVersion: USAGE_CONTRACT_VERSION, @@ -429,6 +489,7 @@ export const make = Effect.gen(function* () { untilDay: input.untilDay, buckets: aggregated.buckets, sources, + subscriptionLimits, pricing: { status: ratesStatus, source: LITELLM_RATES_URL, diff --git a/apps/server/src/usage/usageSubscriptionLimits.test.ts b/apps/server/src/usage/usageSubscriptionLimits.test.ts new file mode 100644 index 000000000000..bd186f2187b9 --- /dev/null +++ b/apps/server/src/usage/usageSubscriptionLimits.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + normalizeClaudeSubscriptionLimits, + normalizeCodexSubscriptionLimits, +} from "./usageSubscriptionLimits.ts"; + +describe("subscription usage limits", () => { + it("normalizes Claude's five-hour and weekly windows", () => { + const limits = normalizeClaudeSubscriptionLimits({ + subscription_type: "max", + rate_limits_available: true, + rate_limits: { + five_hour: { utilization: 10.4, resets_at: "2026-08-26T19:00:00.000Z" }, + seven_day: { utilization: 3, resets_at: "2026-09-01T23:00:00.000Z" }, + }, + }); + + expect(limits).toEqual({ + provider: "claude", + plan: "max", + windows: [ + { + kind: "fiveHour", + usedPercent: 10.4, + resetsAt: "2026-08-26T19:00:00.000Z", + unlimited: false, + }, + { + kind: "weekly", + usedPercent: 3, + resetsAt: "2026-09-01T23:00:00.000Z", + unlimited: false, + }, + ], + }); + }); + + it("omits Claude limits when plan rate limits are unavailable", () => { + expect( + normalizeClaudeSubscriptionLimits({ + subscription_type: null, + rate_limits_available: false, + rate_limits: null, + }), + ).toBeNull(); + }); + + it("normalizes Codex windows and Unix reset timestamps", () => { + const limits = normalizeCodexSubscriptionLimits({ + rateLimits: { + planType: "plus", + primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: 1_788_000_000 }, + secondary: { usedPercent: 8, windowDurationMins: 10_080, resetsAt: null }, + }, + }); + + expect(limits).toEqual({ + provider: "codex", + plan: "plus", + windows: [ + { + kind: "fiveHour", + usedPercent: 42, + resetsAt: "2026-08-29T10:40:00.000Z", + unlimited: false, + }, + { kind: "weekly", usedPercent: 8, resetsAt: null, unlimited: false }, + ], + }); + }); + + it.each(["pro", "prolite"] as const)( + "marks a missing five-hour window as unlimited for the %s plan", + (planType) => { + const limits = normalizeCodexSubscriptionLimits({ + rateLimits: { + planType, + secondary: { usedPercent: 44, windowDurationMins: 10_080, resetsAt: null }, + }, + }); + + expect(limits?.windows).toEqual([ + { + kind: "fiveHour", + usedPercent: 0, + resetsAt: null, + unlimited: true, + }, + { kind: "weekly", usedPercent: 44, resetsAt: null, unlimited: false }, + ]); + }, + ); + + it("clamps provider percentages to the progress bar range", () => { + const limits = normalizeCodexSubscriptionLimits({ + rateLimits: { + primary: { usedPercent: 140 }, + secondary: { usedPercent: -5 }, + }, + }); + + expect(limits?.windows.map((window) => window.usedPercent)).toEqual([100, 0]); + }); +}); diff --git a/apps/server/src/usage/usageSubscriptionLimits.ts b/apps/server/src/usage/usageSubscriptionLimits.ts new file mode 100644 index 000000000000..a870a2123251 --- /dev/null +++ b/apps/server/src/usage/usageSubscriptionLimits.ts @@ -0,0 +1,121 @@ +import type { SDKControlGetUsageResponse } from "@anthropic-ai/claude-agent-sdk"; +import type { + UsageLimitWindow, + UsageLimitWindowKind, + UsageProviderLimits, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import type * as CodexSchema from "effect-codex-app-server/schema"; + +const FIVE_HOURS_MINUTES = 5 * 60; +const WEEK_MINUTES = 7 * 24 * 60; +const UNLIMITED_CODEX_FIVE_HOUR_PLANS = new Set(["pro", "prolite"]); + +type ClaudeUsageLimitsResponse = Pick< + SDKControlGetUsageResponse, + "subscription_type" | "rate_limits_available" | "rate_limits" +>; + +type CodexUsageLimitsResponse = Pick; + +function usedPercent(value: number | null): number | null { + if (value === null || !Number.isFinite(value)) return null; + return Math.min(100, Math.max(0, value)); +} + +function claudeWindow( + kind: UsageLimitWindowKind, + window: + | { + readonly utilization: number | null; + readonly resets_at: string | null; + } + | null + | undefined, +): UsageLimitWindow | null { + const percent = usedPercent(window?.utilization ?? null); + if (percent === null) return null; + return { kind, usedPercent: percent, resetsAt: window?.resets_at ?? null, unlimited: false }; +} + +export function normalizeClaudeSubscriptionLimits( + response: ClaudeUsageLimitsResponse | undefined, +): UsageProviderLimits | null { + if (!response?.rate_limits_available || response.rate_limits === null) return null; + + const windows = [ + claudeWindow("fiveHour", response.rate_limits.five_hour), + claudeWindow("weekly", response.rate_limits.seven_day), + ].filter((window): window is UsageLimitWindow => window !== null); + if (windows.length === 0) return null; + + const plan = response.subscription_type?.trim() ?? ""; + return { + provider: "claude", + plan: plan.length > 0 ? plan : null, + windows, + }; +} + +function codexWindowKind( + window: CodexSchema.V2GetAccountRateLimitsResponse__RateLimitWindow, + fallback: UsageLimitWindowKind, +): UsageLimitWindowKind { + if (window.windowDurationMins === FIVE_HOURS_MINUTES) return "fiveHour"; + if (window.windowDurationMins === WEEK_MINUTES) return "weekly"; + return fallback; +} + +function codexWindow( + window: CodexSchema.V2GetAccountRateLimitsResponse__RateLimitWindow | null | undefined, + fallback: UsageLimitWindowKind, +): UsageLimitWindow | null { + if (!window) return null; + const percent = usedPercent(window.usedPercent); + if (percent === null) return null; + + const resetsAt = + window.resetsAt === null || window.resetsAt === undefined + ? null + : DateTime.formatIso(DateTime.makeUnsafe(window.resetsAt * 1_000)); + return { + kind: codexWindowKind(window, fallback), + usedPercent: percent, + resetsAt, + unlimited: false, + }; +} + +export function normalizeCodexSubscriptionLimits( + response: CodexUsageLimitsResponse | undefined, +): UsageProviderLimits | null { + if (!response) return null; + + const meteredWindows = [ + codexWindow(response.rateLimits.primary, "fiveHour"), + codexWindow(response.rateLimits.secondary, "weekly"), + ].filter((window): window is UsageLimitWindow => window !== null); + const unlimitedFiveHour = + response.rateLimits.planType !== null && + response.rateLimits.planType !== undefined && + UNLIMITED_CODEX_FIVE_HOUR_PLANS.has(response.rateLimits.planType) && + !meteredWindows.some((window) => window.kind === "fiveHour"); + const windows = unlimitedFiveHour + ? [ + { + kind: "fiveHour", + usedPercent: 0, + resetsAt: null, + unlimited: true, + } satisfies UsageLimitWindow, + ...meteredWindows, + ] + : meteredWindows; + if (windows.length === 0) return null; + + return { + provider: "codex", + plan: response.rateLimits.planType ?? null, + windows, + }; +} diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 8e86b521e890..bba3aaff6e1b 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -49,6 +49,11 @@ vi.mock("../ui/select", () => ({ })); vi.mock("../ui/sidebar", () => ({ SidebarInset: "div" })); vi.mock("../ui/toggle-group", () => ({ Toggle: "button", ToggleGroup: "div" })); +vi.mock("../ui/tooltip", () => ({ + Tooltip: "div", + TooltipPopup: "div", + TooltipTrigger: "div", +})); vi.mock("../WorkspaceBreadcrumb", () => ({ WorkspaceBreadcrumb: "div", WorkspaceBreadcrumbItem: "div", @@ -180,3 +185,53 @@ describe("UsagePage model breakdown", () => { ]); }); }); + +describe("UsagePage subscription limits", () => { + it("keeps provider share copy and adds compact quota meters", () => { + testState.useUsage.mockReturnValue({ + merged: { + ...mergeUsage([], USAGE_CONTRACT_VERSION), + providers: [ + { + provider: "codex", + costUsd: 12, + totalTokens: 2_000, + records: 2, + sessions: 1, + costShare: 1, + tokenShare: 1, + }, + ], + subscriptionLimits: [ + { + provider: "codex", + plan: "pro", + windows: [ + { kind: "fiveHour", usedPercent: 0, resetsAt: null, unlimited: true }, + { + kind: "weekly", + usedPercent: 8, + resetsAt: "2030-08-29T21:00:00.000Z", + unlimited: false, + }, + ], + }, + ], + }, + environments: [], + isPending: false, + isPartial: false, + refresh: vi.fn(), + }); + + const markup = renderToStaticMarkup(); + + expect(markup).toContain('aria-label="Codex 5h limit"'); + expect(markup).toContain('aria-label="Codex Week limit"'); + expect(markup).toContain("∞"); + expect(markup).toContain("No limit"); + expect(markup).toContain("Resets in"); + expect(markup).toContain("of cost"); + expect(markup.indexOf("of cost")).toBeLessThan(markup.indexOf('aria-label="Codex 5h limit"')); + }); +}); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 7474bb9d6120..12dc6b88bcc8 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -1,6 +1,6 @@ -import type { UsageProviderKind } from "@t3tools/contracts"; +import type { UsageLimitWindow, UsageProviderKind, UsageProviderLimits } from "@t3tools/contracts"; import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; @@ -16,6 +16,7 @@ import { formatHourShort, formatPercent, formatTokens, + formatUsageResetCountdown, formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; @@ -24,6 +25,7 @@ import { ScrollArea } from "../ui/scroll-area"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { SidebarInset } from "../ui/sidebar"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem, @@ -48,10 +50,16 @@ export function UsagePage() { })); const [metric, setMetric] = useState("cost"); const [breakdown, setBreakdown] = useState<"model" | "time">("model"); + const [nowMs, setNowMs] = useState(Date.now()); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; const { merged, environments, isPending, isPartial, refresh } = useUsage(window); + useEffect(() => { + const interval = globalThis.setInterval(() => setNowMs(Date.now()), 60_000); + return () => globalThis.clearInterval(interval); + }, []); + // Hold the content until every environment is terminal. Rendering merged // totals while devices are still answering makes every number on the page // jump as each one lands. @@ -84,6 +92,11 @@ export function UsagePage() { [breakdown, merged.models, metric], ); const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]); + const summaryProviders = useMemo(() => { + const visible = new Set(activeProviders); + for (const limits of merged.subscriptionLimits) visible.add(limits.provider); + return PROVIDER_ORDER.filter((provider) => visible.has(provider)); + }, [activeProviders, merged.subscriptionLimits]); const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`; const selectWindow = (days: number) => { @@ -222,7 +235,7 @@ export function UsagePage() { />
-
+
{metric === "cost" @@ -236,8 +249,11 @@ export function UsagePage() {
- {activeProviders.map((provider) => { + {summaryProviders.map((provider) => { const totals = merged.providers.find((entry) => entry.provider === provider); + const limits = merged.subscriptionLimits.find( + (entry) => entry.provider === provider, + ); const share = metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0); const providerSessions = totals?.sessions ?? 0; @@ -245,43 +261,45 @@ export function UsagePage() { providerSessions === 1 ? "session" : "sessions" }`; return ( -
-
- - - - - - {PROVIDER_PRESENTATION[provider].label} - - - {sessionLabel} +
+
+
+ + + + + {PROVIDER_PRESENTATION[provider].label} + + + {sessionLabel} + - - + + {metric === "cost" + ? formatUsd(totals?.costUsd ?? 0) + : formatTokens(totals?.totalTokens ?? 0)} + +
+ {metric === "cost" - ? formatUsd(totals?.costUsd ?? 0) - : formatTokens(totals?.totalTokens ?? 0)} + ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens` + : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`}
- - {metric === "cost" - ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens` - : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`} - + {limits ? ( + + ) : null}
); })}
-
+

{isPast24Hours ? "Hourly" : "Daily"}{" "} {metric === "tokens" ? "processed tokens" : "cost"} @@ -476,6 +494,95 @@ function ProviderMark({ return ; } +const LIMIT_WINDOW_LABEL: Record = { + fiveHour: "5h", + weekly: "Week", +}; + +function UsageLimitMeters({ + limits, + nowMs, + timeZone, +}: { + readonly limits: UsageProviderLimits; + readonly nowMs: number; + readonly timeZone: string; +}) { + const providerLabel = PROVIDER_PRESENTATION[limits.provider].label; + return ( +
+ {limits.windows.map((window) => { + const percent = Math.min(100, Math.max(0, window.usedPercent)); + const reset = window.resetsAt ? formatDateTimeShort(window.resetsAt, timeZone) : null; + const countdown = window.resetsAt + ? formatUsageResetCountdown(window.resetsAt, nowMs) + : null; + const label = LIMIT_WINDOW_LABEL[window.kind]; + const resetText = reset ? ` Resets ${reset}.` : ""; + const usageText = window.unlimited + ? "Unlimited. No five-hour limit on this plan." + : `${Math.round(percent)}% used.${resetText}`; + return ( + + + } + > + + {label} + + + + + + {window.unlimited ? "∞" : `${Math.round(percent)}%`} + + + {window.unlimited + ? "No limit" + : countdown === "now" + ? "Reset due" + : countdown + ? `Resets in ${countdown}` + : "Reset time unavailable"} + + + + {providerLabel} {label}:{" "} + {window.unlimited ? "Unlimited" : `${Math.round(percent)}% used`} + {!window.unlimited && reset ? ` · Resets ${reset}` : null} + + + ); + })} +
+ ); +} + function Metric({ label, value }: { readonly label: string; readonly value: string }) { return (
diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 26d49e664804..b3ccebe034ca 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -333,10 +333,10 @@ export function UsageProviderChart({ : formatPeriod(period); return ( -
-
+
+
{/* Axis labels sit outside the plot so they stay aligned to gridlines. */} -
+
{ticks.map((tick) => ( { hoverPositionRef.current = null; diff --git a/docs/user/usage.md b/docs/user/usage.md index 72d19ba77f37..3397262bce70 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -5,6 +5,14 @@ the providers' local session history and shows API-equivalent token cost, proces savings, provider shares, and model breakdowns. Subscription billing is separate from the raw token cost shown here. +When a signed-in provider exposes subscription quotas, its summary row keeps the usual cost and +token summary and adds the current five-hour and weekly usage meters. Each meter shows the time +remaining until it resets. On web and desktop, hover a meter to see the exact reset time. Providers +that do not expose quota data omit the meters. + +Codex Pro 5x and Pro 20x plans show `∞` for the uncapped five-hour window. Plus plans show the +five-hour percentage reported by Codex. + Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**, **30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the headline and chart, and refreshing rescans every connected environment. diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index cde888a6153e..40ca5cd91fd0 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -13,6 +13,7 @@ * @module usage */ import * as Schema from "effect/Schema"; +import * as Effect from "effect/Effect"; import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; @@ -21,7 +22,7 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; * client renders partial coverage when an environment reports an older version * rather than failing the whole page. */ -export const USAGE_CONTRACT_VERSION = 4 as const; +export const USAGE_CONTRACT_VERSION = 6 as const; export const UsageProviderKind = Schema.Literals(["claude", "codex"]); export type UsageProviderKind = typeof UsageProviderKind.Type; @@ -160,6 +161,27 @@ export const UsagePricing = Schema.Struct({ }); export type UsagePricing = typeof UsagePricing.Type; +export const UsageLimitWindowKind = Schema.Literals(["fiveHour", "weekly"]); +export type UsageLimitWindowKind = typeof UsageLimitWindowKind.Type; + +/** One subscription quota window reported by the provider CLI. */ +export const UsageLimitWindow = Schema.Struct({ + kind: UsageLimitWindowKind, + usedPercent: Schema.Number, + resetsAt: Schema.NullOr(Schema.String), + /** True when this plan has no cap for the window. */ + unlimited: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), +}); +export type UsageLimitWindow = typeof UsageLimitWindow.Type; + +/** Best-effort subscription limits for one provider account. */ +export const UsageProviderLimits = Schema.Struct({ + provider: UsageProviderKind, + plan: Schema.NullOr(TrimmedNonEmptyString), + windows: Schema.Array(UsageLimitWindow), +}); +export type UsageProviderLimits = typeof UsageProviderLimits.Type; + export const UsageSummaryInput = Schema.Struct({ /** Inclusive first day of the window, in `timeZone`. */ sinceDay: UsageDay, @@ -187,6 +209,10 @@ export const UsageSummary = Schema.Struct({ untilDay: UsageDay, buckets: Schema.Array(UsageBucket), sources: Schema.Array(UsageSource), + /** Current provider subscription quotas, when the local CLI exposes them. */ + subscriptionLimits: Schema.Array(UsageProviderLimits).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), pricing: UsagePricing, /** Wall-clock cost of the scan, surfaced in diagnostics. */ scanDurationMs: NonNegativeInt, diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index fb231fbacb20..ba07197b7cf5 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -6,6 +6,7 @@ import { formatDateTimeShort, formatHourShort, formatRelativeHourShort, + formatUsageResetCountdown, makeWindow, } from "./usageFormat.ts"; @@ -71,3 +72,22 @@ describe("hourly usage formatting", () => { } }); }); + +describe("subscription reset formatting", () => { + const now = Date.parse("2026-08-26T17:00:00.000Z"); + + it("keeps short windows precise to the minute", () => { + expect(formatUsageResetCountdown("2026-08-26T19:14:00.000Z", now)).toBe("2h 14m"); + expect(formatUsageResetCountdown("2026-08-26T17:00:01.000Z", now)).toBe("1m"); + }); + + it("keeps weekly windows compact", () => { + expect(formatUsageResetCountdown("2026-08-29T21:00:00.000Z", now)).toBe("3d 4h"); + expect(formatUsageResetCountdown("2026-08-29T17:45:00.000Z", now)).toBe("3d"); + }); + + it("handles reached and invalid reset times", () => { + expect(formatUsageResetCountdown("2026-08-26T17:00:00.000Z", now)).toBe("now"); + expect(formatUsageResetCountdown("not-a-date", now)).toBeNull(); + }); +}); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index bd751829dd87..bd80500c7438 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -14,6 +14,7 @@ const CURRENCY = new Intl.NumberFormat("en-US", { }); const INTEGER = new Intl.NumberFormat("en-US"); +const MINUTE_MS = 60 * 1000; export function formatUsd(value: number): string { return CURRENCY.format(value); @@ -46,6 +47,24 @@ export function formatPercent(share: number, digits = 1): string { return `${(share * 100).toFixed(digits)}%`; } +/** Compact time remaining for subscription-limit reset labels. */ +export function formatUsageResetCountdown(resetsAt: string, nowMs: number): string | null { + const resetMs = Date.parse(resetsAt); + if (Number.isNaN(resetMs)) return null; + + const remainingMs = resetMs - nowMs; + if (remainingMs <= 0) return "now"; + + const totalMinutes = Math.ceil(remainingMs / MINUTE_MS); + const days = Math.floor(totalMinutes / (24 * 60)); + const hours = Math.floor((totalMinutes % (24 * 60)) / 60); + const minutes = totalMinutes % 60; + + if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`; + if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + return `${minutes}m`; +} + /** `2026-08-07` to `Aug 7`. */ export function formatDayShort(day: string): string { const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part)); diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 3bee4a9bdc02..f1fd52edd1eb 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -50,6 +50,7 @@ function summary( sinceDay: "2026-08-01" as UsageDay, untilDay: "2026-08-31" as UsageDay, buckets, + subscriptionLimits: [], sources: sources.map((source) => ({ fingerprint: { hostId: source.hostId, @@ -264,6 +265,46 @@ describe("mergeUsage", () => { expect(merged.hourly).toHaveLength(0); }); + it("uses the newest subscription limits reported for each provider", () => { + const older = summary([], []); + const newer = summary([], []); + const merged = mergeUsage( + [ + environment("env-a", { + ...older, + readAt: "2026-08-07T10:00:00.000Z", + subscriptionLimits: [ + { + provider: "codex", + plan: "plus", + windows: [{ kind: "fiveHour", usedPercent: 20, resetsAt: null, unlimited: false }], + }, + ], + }), + environment("env-b", { + ...newer, + readAt: "2026-08-07T11:00:00.000Z", + subscriptionLimits: [ + { + provider: "codex", + plan: "pro", + windows: [{ kind: "fiveHour", usedPercent: 35, resetsAt: null, unlimited: false }], + }, + ], + }), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.subscriptionLimits).toEqual([ + { + provider: "codex", + plan: "pro", + windows: [{ kind: "fiveHour", usedPercent: 35, resetsAt: null, unlimited: false }], + }, + ]); + }); + it("omits providers with no sessions or usage", () => { const merged = mergeUsage( [ diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 954139b4e10f..ac98c5d97e30 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -10,6 +10,7 @@ import type { EnvironmentId, UsageBucket, UsageProviderKind, + UsageProviderLimits, UsageSourceFingerprint, UsageSummary, } from "@t3tools/contracts"; @@ -72,6 +73,7 @@ export interface MergedUsage { readonly records: number; readonly sessions: number; readonly providers: readonly ProviderTotals[]; + readonly subscriptionLimits: readonly UsageProviderLimits[]; readonly models: readonly ModelTotals[]; readonly daily: readonly DailyTotals[]; readonly hourly: readonly HourlyTotals[]; @@ -183,6 +185,7 @@ const EMPTY_MERGED: MergedUsage = { records: 0, sessions: 0, providers: [], + subscriptionLimits: [], models: [], daily: [], hourly: [], @@ -221,6 +224,19 @@ export function mergeUsage( } const { ownerByFingerprint, duplicates } = claimSources(current); + const subscriptionLimitsByProvider = new Map(); + const newestFirst = [...current].sort( + (left, right) => + right.summary.readAt.localeCompare(left.summary.readAt) || + left.environmentId.localeCompare(right.environmentId), + ); + for (const environment of newestFirst) { + for (const limits of environment.summary.subscriptionLimits) { + if (!subscriptionLimitsByProvider.has(limits.provider)) { + subscriptionLimitsByProvider.set(limits.provider, limits); + } + } + } let costUsd = 0; let uncachedInputTokens = 0; @@ -400,6 +416,7 @@ export function mergeUsage( records, sessions, providers, + subscriptionLimits: [...subscriptionLimitsByProvider.values()], models, daily, hourly, From d55071e27a7d37d1ac7423dc293d1a8a74a53396 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Wed, 26 Aug 2026 16:47:12 -0600 Subject: [PATCH 02/18] fix(usage): harden subscription limit reporting --- .../src/features/usage/UsageRouteScreen.tsx | 8 +- apps/server/src/usage/UsageService.ts | 122 +++++++++++------- .../src/usage/usageSubscriptionLimits.test.ts | 9 ++ .../src/usage/usageSubscriptionLimits.ts | 13 +- .../src/components/usage/UsagePage.test.tsx | 12 ++ apps/web/src/components/usage/UsagePage.tsx | 49 +++++-- packages/contracts/src/usage.ts | 2 +- packages/shared/src/usageFormat.test.ts | 7 + packages/shared/src/usageFormat.ts | 17 +++ packages/shared/src/usageMerge.test.ts | 17 ++- 10 files changed, 183 insertions(+), 73 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index b2a975f70ccf..ff5aea6c9ea8 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -10,6 +10,7 @@ import { formatPercent, formatTokens, formatUsageResetCountdown, + formatUsageResetDateTime, formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; @@ -391,12 +392,7 @@ function UsageLimitMeters(props: { {props.limits.windows.map((window) => { const percent = Math.min(100, Math.max(0, window.usedPercent)); const reset = window.resetsAt - ? new Intl.DateTimeFormat("en-US", { - timeZone: props.timeZone, - month: "short", - day: "numeric", - hour: "numeric", - }).format(new Date(window.resetsAt)) + ? formatUsageResetDateTime(window.resetsAt, props.timeZone) : null; const countdown = window.resetsAt ? formatUsageResetCountdown(window.resetsAt, props.nowMs) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 61b03ca32253..1b96c7e6f6dc 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -33,6 +33,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -68,7 +69,6 @@ const LITELLM_RATES_URL = /** Rates move rarely; a day-old table keeps the page working offline. */ const RATES_TTL_MS = 24 * 60 * 60 * 1000; const SUBSCRIPTION_LIMITS_TTL_MS = 60 * 1000; -const SUBSCRIPTION_LIMITS_PROBE_TIMEOUT_MS = 5_000; /** * Files are filtered by mtime before opening. The slack covers a session whose @@ -136,6 +136,7 @@ export const make = Effect.gen(function* () { const settingsService = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const subscriptionLimitsSemaphore = yield* Semaphore.make(1); const fileCache: ScanCache = new Map(); let cacheDirty = false; @@ -145,51 +146,77 @@ export const make = Effect.gen(function* () { let rates: RateTable = new Map(); let ratesFetchedAtMs: number | null = null; let ratesStatus: UsageSummary["pricing"]["status"] = "unavailable"; - let subscriptionLimitsCache: { - readonly fetchedAtMs: number; - readonly limits: readonly UsageProviderLimits[]; - } | null = null; - - const readSubscriptionLimits = Effect.fn("UsageService.readSubscriptionLimits")(function* () { - const now = yield* Clock.currentTimeMillis; - if ( - subscriptionLimitsCache !== null && - now - subscriptionLimitsCache.fetchedAtMs < SUBSCRIPTION_LIMITS_TTL_MS - ) { - return subscriptionLimitsCache.limits; + const subscriptionLimitsCache = new Map< + UsageProviderKind, + { + readonly fetchedAtMs: number; + readonly limits: UsageProviderLimits; } + >(); + + const readSubscriptionLimits = Effect.fn("UsageService.readSubscriptionLimits")(() => + subscriptionLimitsSemaphore.withPermits(1)( + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const cachedLimits = new Map(); + for (const [provider, cached] of subscriptionLimitsCache) { + if (now - cached.fetchedAtMs < SUBSCRIPTION_LIMITS_TTL_MS) { + cachedLimits.set(provider, cached.limits); + } + } - const settings = yield* settingsService.getSettings.pipe( - Effect.catchCause(() => Effect.succeed(null)), - ); - if (settings === null) return []; - - const [claudeResponse, codexResponse] = yield* Effect.all( - [ - settings.providers.claudeAgent.enabled - ? probeClaudeUsage(settings.providers.claudeAgent, process.env, config.cwd).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.timeoutOption(SUBSCRIPTION_LIMITS_PROBE_TIMEOUT_MS), - Effect.map(Option.getOrUndefined), - ) - : Effect.succeed(undefined), - probeCodexRateLimits(settings.providers.codex, process.env, config.cwd).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - Effect.timeoutOption(SUBSCRIPTION_LIMITS_PROBE_TIMEOUT_MS), - Effect.map(Option.getOrUndefined), - ), - ], - { concurrency: "unbounded" }, - ); - - const limits = [ - normalizeCodexSubscriptionLimits(codexResponse), - normalizeClaudeSubscriptionLimits(claudeResponse), - ].filter((entry): entry is UsageProviderLimits => entry !== null); - subscriptionLimitsCache = { fetchedAtMs: now, limits }; - return limits; - }); + const settings = yield* settingsService.getSettings.pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + if (settings === null) return [...cachedLimits.values()]; + + const cachedClaude = settings.providers.claudeAgent.enabled + ? cachedLimits.get("claude") + : undefined; + const cachedCodex = settings.providers.codex.enabled + ? cachedLimits.get("codex") + : undefined; + + const [claudeResponse, codexResponse] = yield* Effect.all( + [ + settings.providers.claudeAgent.enabled && cachedClaude === undefined + ? probeClaudeUsage(settings.providers.claudeAgent, process.env, config.cwd).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.map(Option.fromUndefinedOr), + ) + : Effect.succeed(Option.none()), + settings.providers.codex.enabled && cachedCodex === undefined + ? probeCodexRateLimits(settings.providers.codex, process.env, config.cwd).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + childProcessSpawner, + ), + Effect.map(Option.fromUndefinedOr), + ) + : Effect.succeed(Option.none()), + ], + { concurrency: "unbounded" }, + ); + + const claudeLimits = + cachedClaude ?? normalizeClaudeSubscriptionLimits(Option.getOrUndefined(claudeResponse)); + const codexLimits = + cachedCodex ?? normalizeCodexSubscriptionLimits(Option.getOrUndefined(codexResponse)); + const fetchedAtMs = yield* Clock.currentTimeMillis; + for (const limits of [codexLimits, claudeLimits]) { + // Do not memoize a transient probe failure as a valid empty result. + // The next refresh should be able to recover that provider immediately. + if (limits !== null && !cachedLimits.has(limits.provider)) { + subscriptionLimitsCache.set(limits.provider, { fetchedAtMs, limits }); + } + } + return [codexLimits, claudeLimits].filter( + (limits): limits is UsageProviderLimits => limits !== null, + ); + }), + ), + ); /** * Loads the LiteLLM rate table, preferring a fresh copy and falling back to @@ -381,7 +408,12 @@ export const make = Effect.gen(function* () { } const startedAtMs = yield* Clock.currentTimeMillis; - const subscriptionLimitsFiber = yield* readSubscriptionLimits().pipe(Effect.forkChild); + const subscriptionLimitsFiber = yield* readSubscriptionLimits().pipe( + // Subscription meters are optional. Provider payload drift must not make + // transcript usage unavailable. + Effect.catchCause(() => Effect.succeed([])), + Effect.forkChild, + ); yield* ensureRates(); yield* ensureScanCacheLoaded; diff --git a/apps/server/src/usage/usageSubscriptionLimits.test.ts b/apps/server/src/usage/usageSubscriptionLimits.test.ts index bd186f2187b9..62ba6cc92dee 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.test.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.test.ts @@ -46,6 +46,15 @@ describe("subscription usage limits", () => { ).toBeNull(); }); + it("omits Claude limits when the experimental response has no rate-limit payload", () => { + expect( + normalizeClaudeSubscriptionLimits({ + subscription_type: "max", + rate_limits_available: true, + }), + ).toBeNull(); + }); + it("normalizes Codex windows and Unix reset timestamps", () => { const limits = normalizeCodexSubscriptionLimits({ rateLimits: { diff --git a/apps/server/src/usage/usageSubscriptionLimits.ts b/apps/server/src/usage/usageSubscriptionLimits.ts index a870a2123251..c9b25514428e 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.ts @@ -11,9 +11,8 @@ const FIVE_HOURS_MINUTES = 5 * 60; const WEEK_MINUTES = 7 * 24 * 60; const UNLIMITED_CODEX_FIVE_HOUR_PLANS = new Set(["pro", "prolite"]); -type ClaudeUsageLimitsResponse = Pick< - SDKControlGetUsageResponse, - "subscription_type" | "rate_limits_available" | "rate_limits" +type ClaudeUsageLimitsResponse = Partial< + Pick >; type CodexUsageLimitsResponse = Pick; @@ -41,11 +40,13 @@ function claudeWindow( export function normalizeClaudeSubscriptionLimits( response: ClaudeUsageLimitsResponse | undefined, ): UsageProviderLimits | null { - if (!response?.rate_limits_available || response.rate_limits === null) return null; + const rateLimits = response?.rate_limits; + if (!response?.rate_limits_available || rateLimits === null || rateLimits === undefined) + return null; const windows = [ - claudeWindow("fiveHour", response.rate_limits.five_hour), - claudeWindow("weekly", response.rate_limits.seven_day), + claudeWindow("fiveHour", rateLimits.five_hour), + claudeWindow("weekly", rateLimits.seven_day), ].filter((window): window is UsageLimitWindow => window !== null); if (windows.length === 0) return null; diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index bba3aaff6e1b..660936b93d53 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -231,7 +231,19 @@ describe("UsagePage subscription limits", () => { expect(markup).toContain("∞"); expect(markup).toContain("No limit"); expect(markup).toContain("Resets in"); + expect(markup).toContain("Aug 29, 9:00 PM"); expect(markup).toContain("of cost"); expect(markup.indexOf("of cost")).toBeLessThan(markup.indexOf('aria-label="Codex 5h limit"')); }); + + it("keeps the loading skeleton shaped like the quota-enabled provider rows", () => { + const current = testState.useUsage(); + testState.useUsage.mockReturnValue({ ...current, isPending: true }); + + const markup = renderToStaticMarkup(); + + expect(markup.match(/grid-rows-\[auto_auto\]/g)).toHaveLength(4); + expect(markup).toContain("min-h-60 flex-1"); + expect(markup).not.toContain("size-2 shrink-0 rounded-full bg-muted"); + }); }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 12dc6b88bcc8..006d7bdc92d7 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -17,6 +17,7 @@ import { formatPercent, formatTokens, formatUsageResetCountdown, + formatUsageResetDateTime, formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; @@ -513,7 +514,7 @@ function UsageLimitMeters({
{limits.windows.map((window) => { const percent = Math.min(100, Math.max(0, window.usedPercent)); - const reset = window.resetsAt ? formatDateTimeShort(window.resetsAt, timeZone) : null; + const reset = window.resetsAt ? formatUsageResetDateTime(window.resetsAt, timeZone) : null; const countdown = window.resetsAt ? formatUsageResetCountdown(window.resetsAt, nowMs) : null; @@ -699,30 +700,50 @@ function UsageSkeleton() { return ( <>
-
+
{PROVIDER_ORDER.map((provider) => ( -
-
- - - -
- -
+
+
+
+ + + + + + + + +
+
+
+
+ {["fiveHour", "weekly"].map((window) => ( +
+ + + + +
+ ))}
-
))}
-
+
-
-
+
+
+
+
+
diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 40ca5cd91fd0..ef487cb73c7e 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -22,7 +22,7 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; * client renders partial coverage when an environment reports an older version * rather than failing the whole page. */ -export const USAGE_CONTRACT_VERSION = 6 as const; +export const USAGE_CONTRACT_VERSION = 4 as const; export const UsageProviderKind = Schema.Literals(["claude", "codex"]); export type UsageProviderKind = typeof UsageProviderKind.Type; diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index ba07197b7cf5..9d092e774e92 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -7,6 +7,7 @@ import { formatHourShort, formatRelativeHourShort, formatUsageResetCountdown, + formatUsageResetDateTime, makeWindow, } from "./usageFormat.ts"; @@ -90,4 +91,10 @@ describe("subscription reset formatting", () => { expect(formatUsageResetCountdown("2026-08-26T17:00:00.000Z", now)).toBe("now"); expect(formatUsageResetCountdown("not-a-date", now)).toBeNull(); }); + + it("formats an exact reset time without throwing on malformed provider data", () => { + expect(formatUsageResetDateTime("2026-08-26T19:14:00.000Z", "UTC")).toBe("Aug 26, 7:14 PM"); + expect(formatUsageResetDateTime("not-a-date", "UTC")).toBeNull(); + expect(formatUsageResetDateTime("2026-08-26T19:14:00.000Z", "Etc/Unknown")).toBeNull(); + }); }); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index bd80500c7438..5dff7c452c0d 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -65,6 +65,23 @@ export function formatUsageResetCountdown(resetsAt: string, nowMs: number): stri return `${minutes}m`; } +/** Exact reset instant for subscription-limit details, including minutes. */ +export function formatUsageResetDateTime(instant: string, timeZone?: string): string | null { + const date = new Date(instant); + if (Number.isNaN(date.getTime())) return null; + try { + return new Intl.DateTimeFormat("en-US", { + ...(timeZone === undefined ? {} : { timeZone }), + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(date); + } catch { + return null; + } +} + /** `2026-08-07` to `Aug 7`. */ export function formatDayShort(day: string): string { const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part)); diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index f1fd52edd1eb..38f338f56c6a 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -4,12 +4,15 @@ import { type UsageBucket, type UsageDay, type UsageProviderKind, - type UsageSummary, + UsageSummary, } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; import { mergeUsage, type EnvironmentUsage } from "./usageMerge.ts"; +const decodeUsageSummary = Schema.decodeUnknownSync(UsageSummary); + function bucket(overrides: Partial = {}): UsageBucket { return { day: "2026-08-07" as UsageDay, @@ -170,6 +173,18 @@ describe("mergeUsage", () => { expect(merged.staleEnvironments).toEqual(["env-b"]); }); + it("keeps v4 environment totals when subscription limits are absent", () => { + const current = summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a" }], 4); + const { subscriptionLimits: _subscriptionLimits, ...legacyPayload } = current; + const decoded = decodeUsageSummary(legacyPayload); + + const merged = mergeUsage([environment("env-a", decoded)], USAGE_CONTRACT_VERSION); + + expect(decoded.subscriptionLimits).toEqual([]); + expect(merged.costUsd).toBe(10); + expect(merged.staleEnvironments).toEqual([]); + }); + it("derives provider shares and cost quality", () => { const merged = mergeUsage( [ From 55ee35857ab307803e14f2978aed18d0ea4c06d1 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Wed, 26 Aug 2026 17:21:27 -0600 Subject: [PATCH 03/18] fix(server): bound subscription usage probes --- apps/server/src/usage/UsageService.ts | 89 ++++++++++--------- .../src/usage/usageSubscriptionLimits.test.ts | 47 +++++++++- .../src/usage/usageSubscriptionLimits.ts | 61 +++++++++++++ 3 files changed, 156 insertions(+), 41 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 1b96c7e6f6dc..0bdf9b68b74e 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -16,7 +16,6 @@ import * as NodeOS from "node:os"; import { USAGE_CONTRACT_VERSION, type UsageProviderKind, - type UsageProviderLimits, type UsageSource, type UsageSummary, type UsageSummaryInput, @@ -59,8 +58,13 @@ import { } from "./usageScanCache.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; import { + makeSubscriptionLimitsCacheEntry, normalizeClaudeSubscriptionLimits, normalizeCodexSubscriptionLimits, + readSubscriptionLimitsCacheEntry, + runSubscriptionLimitsProbe, + type SubscriptionLimitsCacheEntry, + type SubscriptionLimitsProbeOutcome, } from "./usageSubscriptionLimits.ts"; const LITELLM_RATES_URL = @@ -68,7 +72,6 @@ const LITELLM_RATES_URL = /** Rates move rarely; a day-old table keeps the page working offline. */ const RATES_TTL_MS = 24 * 60 * 60 * 1000; -const SUBSCRIPTION_LIMITS_TTL_MS = 60 * 1000; /** * Files are filtered by mtime before opening. The slack covers a session whose @@ -146,73 +149,79 @@ export const make = Effect.gen(function* () { let rates: RateTable = new Map(); let ratesFetchedAtMs: number | null = null; let ratesStatus: UsageSummary["pricing"]["status"] = "unavailable"; - const subscriptionLimitsCache = new Map< - UsageProviderKind, - { - readonly fetchedAtMs: number; - readonly limits: UsageProviderLimits; - } - >(); + const subscriptionLimitsCache = new Map(); const readSubscriptionLimits = Effect.fn("UsageService.readSubscriptionLimits")(() => subscriptionLimitsSemaphore.withPermits(1)( Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; - const cachedLimits = new Map(); + const cachedOutcomes = new Map(); for (const [provider, cached] of subscriptionLimitsCache) { - if (now - cached.fetchedAtMs < SUBSCRIPTION_LIMITS_TTL_MS) { - cachedLimits.set(provider, cached.limits); - } + const outcome = readSubscriptionLimitsCacheEntry(cached, now); + if (outcome !== undefined) cachedOutcomes.set(provider, outcome); + else subscriptionLimitsCache.delete(provider); } const settings = yield* settingsService.getSettings.pipe( Effect.catchCause(() => Effect.succeed(null)), ); - if (settings === null) return [...cachedLimits.values()]; + if (settings === null) { + return [...cachedOutcomes.values()].flatMap((outcome) => + outcome._tag === "Success" && outcome.limits !== null ? [outcome.limits] : [], + ); + } const cachedClaude = settings.providers.claudeAgent.enabled - ? cachedLimits.get("claude") + ? cachedOutcomes.get("claude") : undefined; const cachedCodex = settings.providers.codex.enabled - ? cachedLimits.get("codex") + ? cachedOutcomes.get("codex") : undefined; - const [claudeResponse, codexResponse] = yield* Effect.all( + const [claudeProbeOutcome, codexProbeOutcome] = yield* Effect.all( [ settings.providers.claudeAgent.enabled && cachedClaude === undefined - ? probeClaudeUsage(settings.providers.claudeAgent, process.env, config.cwd).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.map(Option.fromUndefinedOr), - ) + ? runSubscriptionLimitsProbe( + probeClaudeUsage(settings.providers.claudeAgent, process.env, config.cwd).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + normalizeClaudeSubscriptionLimits, + ).pipe(Effect.map(Option.some)) : Effect.succeed(Option.none()), settings.providers.codex.enabled && cachedCodex === undefined - ? probeCodexRateLimits(settings.providers.codex, process.env, config.cwd).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - childProcessSpawner, + ? runSubscriptionLimitsProbe( + probeCodexRateLimits(settings.providers.codex, process.env, config.cwd).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + childProcessSpawner, + ), ), - Effect.map(Option.fromUndefinedOr), - ) + normalizeCodexSubscriptionLimits, + ).pipe(Effect.map(Option.some)) : Effect.succeed(Option.none()), ], { concurrency: "unbounded" }, ); - const claudeLimits = - cachedClaude ?? normalizeClaudeSubscriptionLimits(Option.getOrUndefined(claudeResponse)); - const codexLimits = - cachedCodex ?? normalizeCodexSubscriptionLimits(Option.getOrUndefined(codexResponse)); + const claudeOutcome = cachedClaude ?? Option.getOrUndefined(claudeProbeOutcome); + const codexOutcome = cachedCodex ?? Option.getOrUndefined(codexProbeOutcome); const fetchedAtMs = yield* Clock.currentTimeMillis; - for (const limits of [codexLimits, claudeLimits]) { - // Do not memoize a transient probe failure as a valid empty result. - // The next refresh should be able to recover that provider immediately. - if (limits !== null && !cachedLimits.has(limits.provider)) { - subscriptionLimitsCache.set(limits.provider, { fetchedAtMs, limits }); - } + if (Option.isSome(claudeProbeOutcome)) { + subscriptionLimitsCache.set( + "claude", + makeSubscriptionLimitsCacheEntry(claudeProbeOutcome.value, fetchedAtMs), + ); + } + if (Option.isSome(codexProbeOutcome)) { + subscriptionLimitsCache.set( + "codex", + makeSubscriptionLimitsCacheEntry(codexProbeOutcome.value, fetchedAtMs), + ); } - return [codexLimits, claudeLimits].filter( - (limits): limits is UsageProviderLimits => limits !== null, + + return [codexOutcome, claudeOutcome].flatMap((outcome) => + outcome?._tag === "Success" && outcome.limits !== null ? [outcome.limits] : [], ); }), ), diff --git a/apps/server/src/usage/usageSubscriptionLimits.test.ts b/apps/server/src/usage/usageSubscriptionLimits.test.ts index 62ba6cc92dee..d3e62031e127 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.test.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.test.ts @@ -1,8 +1,15 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; import { + makeSubscriptionLimitsCacheEntry, normalizeClaudeSubscriptionLimits, normalizeCodexSubscriptionLimits, + readSubscriptionLimitsCacheEntry, + runSubscriptionLimitsProbe, } from "./usageSubscriptionLimits.ts"; describe("subscription usage limits", () => { @@ -111,4 +118,42 @@ describe("subscription usage limits", () => { expect(limits?.windows.map((window) => window.usedPercent)).toEqual([100, 0]); }); + + it.effect("bounds a hanging provider probe at five seconds", () => + Effect.gen(function* () { + const fiber = yield* runSubscriptionLimitsProbe(Effect.never, () => null).pipe( + Effect.forkScoped, + ); + + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.seconds(5)); + + expect(yield* Fiber.join(fiber)).toEqual({ _tag: "Failure" }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("distinguishes a successful empty response from a failed probe", () => + Effect.gen(function* () { + const outcome = yield* runSubscriptionLimitsProbe(Effect.succeed({}), () => null); + + expect(outcome).toEqual({ _tag: "Success", limits: null }); + }), + ); + + it("caches a successful empty response for the normal refresh interval", () => { + const entry = makeSubscriptionLimitsCacheEntry({ _tag: "Success", limits: null }, 1_000); + + expect(readSubscriptionLimitsCacheEntry(entry, 60_999)).toEqual({ + _tag: "Success", + limits: null, + }); + expect(readSubscriptionLimitsCacheEntry(entry, 61_000)).toBeUndefined(); + }); + + it("retries failed probes after a short backoff", () => { + const entry = makeSubscriptionLimitsCacheEntry({ _tag: "Failure" }, 1_000); + + expect(readSubscriptionLimitsCacheEntry(entry, 5_999)).toEqual({ _tag: "Failure" }); + expect(readSubscriptionLimitsCacheEntry(entry, 6_000)).toBeUndefined(); + }); }); diff --git a/apps/server/src/usage/usageSubscriptionLimits.ts b/apps/server/src/usage/usageSubscriptionLimits.ts index c9b25514428e..a429649442c7 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.ts @@ -5,12 +5,73 @@ import type { UsageProviderLimits, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import type * as CodexSchema from "effect-codex-app-server/schema"; const FIVE_HOURS_MINUTES = 5 * 60; const WEEK_MINUTES = 7 * 24 * 60; const UNLIMITED_CODEX_FIVE_HOUR_PLANS = new Set(["pro", "prolite"]); +export const SUBSCRIPTION_LIMITS_PROBE_TIMEOUT_MS = 5_000; +export const SUBSCRIPTION_LIMITS_SUCCESS_TTL_MS = 60_000; +export const SUBSCRIPTION_LIMITS_FAILURE_TTL_MS = 5_000; + +export type SubscriptionLimitsProbeOutcome = + | { + readonly _tag: "Success"; + readonly limits: UsageProviderLimits | null; + } + | { readonly _tag: "Failure" }; + +export interface SubscriptionLimitsCacheEntry { + readonly expiresAtMs: number; + readonly outcome: SubscriptionLimitsProbeOutcome; +} + +const subscriptionLimitsProbeFailure = { _tag: "Failure" } as const; + +/** Caps optional provider probes and preserves successful empty responses. */ +export const runSubscriptionLimitsProbe = Effect.fn("runSubscriptionLimitsProbe")( + ( + probe: Effect.Effect, + normalize: (response: Response) => UsageProviderLimits | null, + ) => + probe.pipe( + Effect.map( + (response): SubscriptionLimitsProbeOutcome => + response === undefined + ? subscriptionLimitsProbeFailure + : { _tag: "Success", limits: normalize(response) }, + ), + Effect.timeoutOption(SUBSCRIPTION_LIMITS_PROBE_TIMEOUT_MS), + Effect.map( + Option.match({ + onNone: () => subscriptionLimitsProbeFailure, + onSome: (outcome) => outcome, + }), + ), + ), +); + +export function makeSubscriptionLimitsCacheEntry( + outcome: SubscriptionLimitsProbeOutcome, + nowMs: number, +): SubscriptionLimitsCacheEntry { + const ttlMs = + outcome._tag === "Success" + ? SUBSCRIPTION_LIMITS_SUCCESS_TTL_MS + : SUBSCRIPTION_LIMITS_FAILURE_TTL_MS; + return { expiresAtMs: nowMs + ttlMs, outcome }; +} + +export function readSubscriptionLimitsCacheEntry( + entry: SubscriptionLimitsCacheEntry | undefined, + nowMs: number, +): SubscriptionLimitsProbeOutcome | undefined { + return entry !== undefined && nowMs < entry.expiresAtMs ? entry.outcome : undefined; +} + type ClaudeUsageLimitsResponse = Partial< Pick >; From 6dc14075a6ab00075274160198ebde0d322220cf Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Wed, 26 Aug 2026 17:32:52 -0600 Subject: [PATCH 04/18] fix(server): finish usage probes in background --- apps/server/src/usage/UsageService.ts | 10 ++++-- .../src/usage/usageSubscriptionLimits.test.ts | 33 ++++++++++++++++--- .../src/usage/usageSubscriptionLimits.ts | 31 ++++++++++------- 3 files changed, 54 insertions(+), 20 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 0bdf9b68b74e..56929fb4dcea 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -26,13 +26,14 @@ import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; -import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import * as Scope from "effect/Scope"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -58,6 +59,7 @@ import { } from "./usageScanCache.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; import { + awaitSubscriptionLimits, makeSubscriptionLimitsCacheEntry, normalizeClaudeSubscriptionLimits, normalizeCodexSubscriptionLimits, @@ -140,6 +142,8 @@ export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const subscriptionLimitsSemaphore = yield* Semaphore.make(1); + const subscriptionLimitsScope = yield* Scope.make("sequential"); + yield* Effect.addFinalizer(() => Scope.close(subscriptionLimitsScope, Exit.void)); const fileCache: ScanCache = new Map(); let cacheDirty = false; @@ -421,7 +425,7 @@ export const make = Effect.gen(function* () { // Subscription meters are optional. Provider payload drift must not make // transcript usage unavailable. Effect.catchCause(() => Effect.succeed([])), - Effect.forkChild, + Effect.forkIn(subscriptionLimitsScope), ); yield* ensureRates(); yield* ensureScanCacheLoaded; @@ -520,7 +524,7 @@ export const make = Effect.gen(function* () { const aggregated = aggregator.finish(); const readAt = yield* DateTime.now; const finishedAtMs = yield* Clock.currentTimeMillis; - const subscriptionLimits = yield* Fiber.join(subscriptionLimitsFiber); + const subscriptionLimits = yield* awaitSubscriptionLimits(subscriptionLimitsFiber); return { contractVersion: USAGE_CONTRACT_VERSION, diff --git a/apps/server/src/usage/usageSubscriptionLimits.test.ts b/apps/server/src/usage/usageSubscriptionLimits.test.ts index d3e62031e127..669907a7dc17 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.test.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.test.ts @@ -1,3 +1,4 @@ +import type { UsageProviderLimits } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -5,6 +6,7 @@ import * as Fiber from "effect/Fiber"; import * as TestClock from "effect/testing/TestClock"; import { + awaitSubscriptionLimits, makeSubscriptionLimitsCacheEntry, normalizeClaudeSubscriptionLimits, normalizeCodexSubscriptionLimits, @@ -119,24 +121,45 @@ describe("subscription usage limits", () => { expect(limits?.windows.map((window) => window.usedPercent)).toEqual([100, 0]); }); - it.effect("bounds a hanging provider probe at five seconds", () => + it.effect("returns after five seconds while a slow provider probe keeps running", () => Effect.gen(function* () { - const fiber = yield* runSubscriptionLimitsProbe(Effect.never, () => null).pipe( + const limits = { + provider: "codex", + plan: "plus", + windows: [ + { + kind: "weekly", + usedPercent: 42, + resetsAt: null, + unlimited: false, + }, + ], + } satisfies UsageProviderLimits; + const providerFiber = yield* Effect.sleep(Duration.seconds(10)).pipe( + Effect.as([limits] as readonly UsageProviderLimits[]), Effect.forkScoped, ); + const waitFiber = yield* awaitSubscriptionLimits(providerFiber).pipe(Effect.forkChild); yield* Effect.yieldNow; yield* TestClock.adjust(Duration.seconds(5)); - expect(yield* Fiber.join(fiber)).toEqual({ _tag: "Failure" }); + expect(yield* Fiber.join(waitFiber)).toEqual([]); + + yield* TestClock.adjust(Duration.seconds(5)); + expect(yield* Fiber.join(providerFiber)).toEqual([limits]); }).pipe(Effect.provide(TestClock.layer())), ); it.effect("distinguishes a successful empty response from a failed probe", () => Effect.gen(function* () { - const outcome = yield* runSubscriptionLimitsProbe(Effect.succeed({}), () => null); + const [emptyOutcome, failedOutcome] = yield* Effect.all([ + runSubscriptionLimitsProbe(Effect.succeed({}), () => null), + runSubscriptionLimitsProbe(Effect.void, () => null), + ]); - expect(outcome).toEqual({ _tag: "Success", limits: null }); + expect(emptyOutcome).toEqual({ _tag: "Success", limits: null }); + expect(failedOutcome).toEqual({ _tag: "Failure" }); }), ); diff --git a/apps/server/src/usage/usageSubscriptionLimits.ts b/apps/server/src/usage/usageSubscriptionLimits.ts index a429649442c7..04e9fbc81863 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.ts @@ -6,6 +6,7 @@ import type { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import type * as CodexSchema from "effect-codex-app-server/schema"; @@ -13,7 +14,7 @@ const FIVE_HOURS_MINUTES = 5 * 60; const WEEK_MINUTES = 7 * 24 * 60; const UNLIMITED_CODEX_FIVE_HOUR_PLANS = new Set(["pro", "prolite"]); -export const SUBSCRIPTION_LIMITS_PROBE_TIMEOUT_MS = 5_000; +export const SUBSCRIPTION_LIMITS_READ_BUDGET_MS = 5_000; export const SUBSCRIPTION_LIMITS_SUCCESS_TTL_MS = 60_000; export const SUBSCRIPTION_LIMITS_FAILURE_TTL_MS = 5_000; @@ -31,24 +32,30 @@ export interface SubscriptionLimitsCacheEntry { const subscriptionLimitsProbeFailure = { _tag: "Failure" } as const; -/** Caps optional provider probes and preserves successful empty responses. */ +/** Tags provider responses so an empty response is distinct from a failed probe. */ export const runSubscriptionLimitsProbe = Effect.fn("runSubscriptionLimitsProbe")( ( probe: Effect.Effect, normalize: (response: Response) => UsageProviderLimits | null, ) => - probe.pipe( - Effect.map( - (response): SubscriptionLimitsProbeOutcome => - response === undefined - ? subscriptionLimitsProbeFailure - : { _tag: "Success", limits: normalize(response) }, - ), - Effect.timeoutOption(SUBSCRIPTION_LIMITS_PROBE_TIMEOUT_MS), + Effect.map( + probe, + (response): SubscriptionLimitsProbeOutcome => + response === undefined + ? subscriptionLimitsProbeFailure + : { _tag: "Success", limits: normalize(response) }, + ), +); + +/** Bounds the page response without interrupting the service-owned probe fiber. */ +export const awaitSubscriptionLimits = Effect.fn("awaitSubscriptionLimits")( + (fiber: Fiber.Fiber) => + Fiber.join(fiber).pipe( + Effect.timeoutOption(SUBSCRIPTION_LIMITS_READ_BUDGET_MS), Effect.map( Option.match({ - onNone: () => subscriptionLimitsProbeFailure, - onSome: (outcome) => outcome, + onNone: (): readonly UsageProviderLimits[] => [], + onSome: (limits) => limits, }), ), ), From f5256b9a68a711ed9819b36f63510a524aa7232d Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Wed, 26 Aug 2026 17:40:45 -0600 Subject: [PATCH 05/18] fix(server): probe configured codex account --- apps/server/src/usage/UsageService.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 56929fb4dcea..325c5b1054f8 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -181,6 +181,13 @@ export const make = Effect.gen(function* () { const cachedCodex = settings.providers.codex.enabled ? cachedOutcomes.get("codex") : undefined; + const codexHomeLayout = yield* resolveCodexHomeLayout(settings.providers.codex).pipe( + Effect.provideService(Path.Path, path), + ); + const codexProbeSettings = { + ...settings.providers.codex, + homePath: codexHomeLayout.effectiveHomePath ?? "", + }; const [claudeProbeOutcome, codexProbeOutcome] = yield* Effect.all( [ @@ -195,7 +202,7 @@ export const make = Effect.gen(function* () { : Effect.succeed(Option.none()), settings.providers.codex.enabled && cachedCodex === undefined ? runSubscriptionLimitsProbe( - probeCodexRateLimits(settings.providers.codex, process.env, config.cwd).pipe( + probeCodexRateLimits(codexProbeSettings, process.env, config.cwd).pipe( Effect.provideService( ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner, From d8ff47d7c662e7e8d703ba30f47d00d9e3d5a10a Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Wed, 26 Aug 2026 17:47:42 -0600 Subject: [PATCH 06/18] refactor(web): share usage meter layout --- apps/web/src/components/usage/UsagePage.tsx | 22 ++++++++------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 006d7bdc92d7..4dd09e22db2d 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -44,6 +44,10 @@ const WINDOW_OPTIONS = [ { days: 90, label: "90 days" }, ] as const; +const USAGE_LIMITS_CLASS_NAME = "flex flex-col gap-2.5 pt-3 pb-1 pl-6"; +const USAGE_LIMIT_ROW_CLASS_NAME = + "grid min-w-0 grid-cols-[2.25rem_minmax(0,1fr)_2.25rem] grid-rows-[auto_auto] items-center gap-x-2 gap-y-1"; + export function UsagePage() { const [windowSelection, setWindowSelection] = useState(() => ({ days: 30, @@ -511,7 +515,7 @@ function UsageLimitMeters({ }) { const providerLabel = PROVIDER_PRESENTATION[limits.provider].label; return ( -
+
{limits.windows.map((window) => { const percent = Math.min(100, Math.max(0, window.usedPercent)); const reset = window.resetsAt ? formatUsageResetDateTime(window.resetsAt, timeZone) : null; @@ -525,11 +529,7 @@ function UsageLimitMeters({ : `${Math.round(percent)}% used.${resetText}`; return ( - - } - > + }> {label} @@ -548,9 +548,6 @@ function UsageLimitMeters({ width: window.unlimited ? "100%" : `${percent}%`, backgroundColor: PROVIDER_PRESENTATION[limits.provider].color, opacity: window.unlimited ? 0.45 : 1, - boxShadow: window.unlimited - ? `0 0 6px color-mix(in srgb, ${PROVIDER_PRESENTATION[limits.provider].color} 45%, transparent)` - : undefined, }} /> @@ -720,12 +717,9 @@ function UsageSkeleton() {
-
+
{["fiveHour", "weekly"].map((window) => ( -
+
From 46c1603c5550615a23cd8e44e97b955aa484968e Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Wed, 26 Aug 2026 19:43:56 -0600 Subject: [PATCH 07/18] perf(server): reduce subscription limit probes --- apps/server/src/usage/usageSubscriptionLimits.test.ts | 6 +++--- apps/server/src/usage/usageSubscriptionLimits.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/src/usage/usageSubscriptionLimits.test.ts b/apps/server/src/usage/usageSubscriptionLimits.test.ts index 669907a7dc17..415156f8ea60 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.test.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.test.ts @@ -163,14 +163,14 @@ describe("subscription usage limits", () => { }), ); - it("caches a successful empty response for the normal refresh interval", () => { + it("caches a successful empty response for three minutes", () => { const entry = makeSubscriptionLimitsCacheEntry({ _tag: "Success", limits: null }, 1_000); - expect(readSubscriptionLimitsCacheEntry(entry, 60_999)).toEqual({ + expect(readSubscriptionLimitsCacheEntry(entry, 180_999)).toEqual({ _tag: "Success", limits: null, }); - expect(readSubscriptionLimitsCacheEntry(entry, 61_000)).toBeUndefined(); + expect(readSubscriptionLimitsCacheEntry(entry, 181_000)).toBeUndefined(); }); it("retries failed probes after a short backoff", () => { diff --git a/apps/server/src/usage/usageSubscriptionLimits.ts b/apps/server/src/usage/usageSubscriptionLimits.ts index 04e9fbc81863..b0c67b28cc0b 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.ts @@ -15,7 +15,7 @@ const WEEK_MINUTES = 7 * 24 * 60; const UNLIMITED_CODEX_FIVE_HOUR_PLANS = new Set(["pro", "prolite"]); export const SUBSCRIPTION_LIMITS_READ_BUDGET_MS = 5_000; -export const SUBSCRIPTION_LIMITS_SUCCESS_TTL_MS = 60_000; +export const SUBSCRIPTION_LIMITS_SUCCESS_TTL_MS = 3 * 60_000; export const SUBSCRIPTION_LIMITS_FAILURE_TTL_MS = 5_000; export type SubscriptionLimitsProbeOutcome = From 8903c183350b0375c05f9d1a09b55db26c18fea9 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Wed, 26 Aug 2026 20:10:21 -0600 Subject: [PATCH 08/18] fix(usage): harden subscription limit reporting --- .../src/features/usage/UsageRouteScreen.tsx | 14 +- apps/server/src/usage/UsageService.ts | 8 + .../src/usage/usageSubscriptionLimits.test.ts | 180 +++++++++++++++--- .../src/usage/usageSubscriptionLimits.ts | 143 +++++++++++++- apps/web/src/components/usage/UsagePage.tsx | 14 +- docs/user/usage.md | 7 +- packages/contracts/src/usage.ts | 5 +- 7 files changed, 322 insertions(+), 49 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index ff5aea6c9ea8..23b42a83586c 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -376,10 +376,12 @@ function ProviderSection(props: { ); } -const LIMIT_WINDOW_LABEL: Record = { - fiveHour: "5h", - weekly: "Week", -}; +function usageLimitWindowLabel(window: UsageLimitWindow): string { + if (window.label) return window.label; + if (window.kind === "fiveHour") return "5h"; + if (window.kind === "weekly") return "Week"; + return window.kind; +} function UsageLimitMeters(props: { readonly limits: UsageProviderLimits; @@ -397,9 +399,9 @@ function UsageLimitMeters(props: { const countdown = window.resetsAt ? formatUsageResetCountdown(window.resetsAt, props.nowMs) : null; - const label = LIMIT_WINDOW_LABEL[window.kind]; + const label = usageLimitWindowLabel(window); return ( - + {label} (); for (const [provider, cached] of subscriptionLimitsCache) { const outcome = readSubscriptionLimitsCacheEntry(cached, now); diff --git a/apps/server/src/usage/usageSubscriptionLimits.test.ts b/apps/server/src/usage/usageSubscriptionLimits.test.ts index 415156f8ea60..5d45f0d4d493 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.test.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.test.ts @@ -8,6 +8,7 @@ import * as TestClock from "effect/testing/TestClock"; import { awaitSubscriptionLimits, makeSubscriptionLimitsCacheEntry, + makeSubscriptionLimitsDevFixture, normalizeClaudeSubscriptionLimits, normalizeCodexSubscriptionLimits, readSubscriptionLimitsCacheEntry, @@ -31,12 +32,14 @@ describe("subscription usage limits", () => { windows: [ { kind: "fiveHour", + label: "5h", usedPercent: 10.4, resetsAt: "2026-08-26T19:00:00.000Z", unlimited: false, }, { kind: "weekly", + label: "Week", usedPercent: 3, resetsAt: "2026-09-01T23:00:00.000Z", unlimited: false, @@ -45,6 +48,67 @@ describe("subscription usage limits", () => { }); }); + it("normalizes Claude's live model-scoped weekly window shape", () => { + const limits = normalizeClaudeSubscriptionLimits({ + subscription_type: "max", + rate_limits_available: true, + rate_limits: { + five_hour: null, + seven_day: { utilization: 3, resets_at: "2026-09-01T23:00:00.000Z" }, + }, + limits: [ + { + kind: "weekly_scoped", + percent: 95, + resets_at: "2026-09-01T23:00:00.000Z", + scope: { model: { display_name: "Fable" } }, + }, + ], + }); + + expect(limits?.windows).toEqual([ + { + kind: "weekly", + label: "Week", + usedPercent: 3, + resetsAt: "2026-09-01T23:00:00.000Z", + unlimited: false, + }, + { + kind: "weekly:fable", + label: "Fable", + usedPercent: 95, + resetsAt: "2026-09-01T23:00:00.000Z", + unlimited: false, + }, + ]); + }); + + it("loosely normalizes future seven-day Claude windows", () => { + const response = { + subscription_type: "max", + rate_limits_available: true, + rate_limits: { + five_hour: null, + seven_day: null, + seven_day_future_model: { + utilization: 72, + resets_at: "2026-09-01T23:00:00.000Z", + }, + }, + } as Parameters[0]; + + expect(normalizeClaudeSubscriptionLimits(response)?.windows).toEqual([ + { + kind: "weekly:future_model", + label: "Future Model", + usedPercent: 72, + resetsAt: "2026-09-01T23:00:00.000Z", + unlimited: false, + }, + ]); + }); + it("omits Claude limits when plan rate limits are unavailable", () => { expect( normalizeClaudeSubscriptionLimits({ @@ -79,36 +143,49 @@ describe("subscription usage limits", () => { windows: [ { kind: "fiveHour", + label: "5h", usedPercent: 42, resetsAt: "2026-08-29T10:40:00.000Z", unlimited: false, }, - { kind: "weekly", usedPercent: 8, resetsAt: null, unlimited: false }, + { kind: "weekly", label: "Week", usedPercent: 8, resetsAt: null, unlimited: false }, ], }); }); - it.each(["pro", "prolite"] as const)( - "marks a missing five-hour window as unlimited for the %s plan", - (planType) => { - const limits = normalizeCodexSubscriptionLimits({ - rateLimits: { - planType, - secondary: { usedPercent: 44, windowDurationMins: 10_080, resetsAt: null }, - }, - }); + it("does not invent an unlimited window from the Codex plan name", () => { + const limits = normalizeCodexSubscriptionLimits({ + rateLimits: { + planType: "pro", + secondary: { usedPercent: 44, windowDurationMins: 10_080, resetsAt: null }, + }, + }); - expect(limits?.windows).toEqual([ - { - kind: "fiveHour", - usedPercent: 0, - resetsAt: null, - unlimited: true, - }, - { kind: "weekly", usedPercent: 44, resetsAt: null, unlimited: false }, - ]); - }, - ); + expect(limits?.windows).toEqual([ + { kind: "weekly", label: "Week", usedPercent: 44, resetsAt: null, unlimited: false }, + ]); + }); + + it("shows an unlimited Codex five-hour window only when the provider reports it", () => { + const limits = normalizeCodexSubscriptionLimits({ + rateLimits: { + planType: "pro", + credits: { balance: null, hasCredits: false, unlimited: true }, + secondary: { usedPercent: 44, windowDurationMins: 10_080, resetsAt: null }, + }, + }); + + expect(limits?.windows).toEqual([ + { + kind: "fiveHour", + label: "5h", + usedPercent: 0, + resetsAt: null, + unlimited: true, + }, + { kind: "weekly", label: "Week", usedPercent: 44, resetsAt: null, unlimited: false }, + ]); + }); it("clamps provider percentages to the progress bar range", () => { const limits = normalizeCodexSubscriptionLimits({ @@ -173,10 +250,65 @@ describe("subscription usage limits", () => { expect(readSubscriptionLimitsCacheEntry(entry, 181_000)).toBeUndefined(); }); - it("retries failed probes after a short backoff", () => { + it("backs off failed probes for ten minutes", () => { const entry = makeSubscriptionLimitsCacheEntry({ _tag: "Failure" }, 1_000); - expect(readSubscriptionLimitsCacheEntry(entry, 5_999)).toEqual({ _tag: "Failure" }); - expect(readSubscriptionLimitsCacheEntry(entry, 6_000)).toBeUndefined(); + expect(readSubscriptionLimitsCacheEntry(entry, 600_999)).toEqual({ _tag: "Failure" }); + expect(readSubscriptionLimitsCacheEntry(entry, 601_000)).toBeUndefined(); + }); + + it("provides representative limits only for the explicit dev fixture", () => { + expect(makeSubscriptionLimitsDevFixture(false, "review", 1_788_000_000_000)).toBeNull(); + expect(makeSubscriptionLimitsDevFixture(true, undefined, 1_788_000_000_000)).toBeNull(); + + expect(makeSubscriptionLimitsDevFixture(true, "review", 1_788_000_000_000)).toEqual([ + { + provider: "codex", + plan: "pro", + windows: [ + { + kind: "fiveHour", + label: "5h", + usedPercent: 0, + resetsAt: null, + unlimited: true, + }, + { + kind: "weekly", + label: "Week", + usedPercent: 47, + resetsAt: "2026-09-04T10:40:00.000Z", + unlimited: false, + }, + ], + }, + { + provider: "claude", + plan: "max", + windows: [ + { + kind: "fiveHour", + label: "5h", + usedPercent: 68, + resetsAt: "2026-08-29T12:40:00.000Z", + unlimited: false, + }, + { + kind: "weekly", + label: "Week", + usedPercent: 32, + resetsAt: "2026-09-03T10:40:00.000Z", + unlimited: false, + }, + { + kind: "weekly:fable", + label: "Fable", + usedPercent: 91, + resetsAt: "2026-09-02T10:40:00.000Z", + unlimited: false, + }, + ], + }, + ]); }); }); diff --git a/apps/server/src/usage/usageSubscriptionLimits.ts b/apps/server/src/usage/usageSubscriptionLimits.ts index b0c67b28cc0b..69889d7b355a 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.ts @@ -8,15 +8,17 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; import type * as CodexSchema from "effect-codex-app-server/schema"; const FIVE_HOURS_MINUTES = 5 * 60; const WEEK_MINUTES = 7 * 24 * 60; -const UNLIMITED_CODEX_FIVE_HOUR_PLANS = new Set(["pro", "prolite"]); +const HOUR_MS = 60 * 60_000; +const DAY_MS = 24 * HOUR_MS; export const SUBSCRIPTION_LIMITS_READ_BUDGET_MS = 5_000; export const SUBSCRIPTION_LIMITS_SUCCESS_TTL_MS = 3 * 60_000; -export const SUBSCRIPTION_LIMITS_FAILURE_TTL_MS = 5_000; +export const SUBSCRIPTION_LIMITS_FAILURE_TTL_MS = 10 * 60_000; export type SubscriptionLimitsProbeOutcome = | { @@ -81,7 +83,10 @@ export function readSubscriptionLimitsCacheEntry( type ClaudeUsageLimitsResponse = Partial< Pick ->; +> & { + /** Newer Claude responses expose model-scoped weekly windows here. */ + readonly limits?: unknown; +}; type CodexUsageLimitsResponse = Pick; @@ -92,6 +97,7 @@ function usedPercent(value: number | null): number | null { function claudeWindow( kind: UsageLimitWindowKind, + label: string, window: | { readonly utilization: number | null; @@ -102,7 +108,60 @@ function claudeWindow( ): UsageLimitWindow | null { const percent = usedPercent(window?.utilization ?? null); if (percent === null) return null; - return { kind, usedPercent: percent, resetsAt: window?.resets_at ?? null, unlimited: false }; + return { + kind, + label, + usedPercent: percent, + resetsAt: window?.resets_at ?? null, + unlimited: false, + }; +} + +function readClaudeWindow(value: unknown): { + readonly utilization: number | null; + readonly resets_at: string | null; +} | null { + if (!Predicate.isObject(value)) return null; + const utilization = value.utilization; + const resetsAt = value.resets_at; + if (typeof utilization !== "number" && utilization !== null) return null; + if (typeof resetsAt !== "string" && resetsAt !== null) return null; + return { utilization, resets_at: resetsAt }; +} + +function formatClaudeWindowLabel(value: string): string { + return value + .split("_") + .filter((part) => part.length > 0) + .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`) + .join(" "); +} + +function readClaudeScopedWindows(value: unknown): readonly UsageLimitWindow[] { + if (!Array.isArray(value)) return []; + + return value.flatMap((limit): readonly UsageLimitWindow[] => { + if (!Predicate.isObject(limit) || limit.kind !== "weekly_scoped") return []; + const scope = limit.scope; + if (!Predicate.isObject(scope) || !Predicate.isObject(scope.model)) return []; + const label = scope.model.display_name; + if (typeof label !== "string" || label.trim().length === 0) return []; + const resetsAt = limit.resets_at; + if (typeof resetsAt !== "string" && resetsAt !== null) return []; + const percent = typeof limit.percent === "number" ? usedPercent(limit.percent) : null; + if (percent === null) return []; + + const normalizedLabel = label.trim(); + return [ + { + kind: `weekly:${normalizedLabel.toLowerCase()}`, + label: normalizedLabel, + usedPercent: percent, + resetsAt, + unlimited: false, + }, + ]; + }); } export function normalizeClaudeSubscriptionLimits( @@ -113,9 +172,30 @@ export function normalizeClaudeSubscriptionLimits( return null; const windows = [ - claudeWindow("fiveHour", rateLimits.five_hour), - claudeWindow("weekly", rateLimits.seven_day), + claudeWindow("fiveHour", "5h", rateLimits.five_hour), + claudeWindow("weekly", "Week", rateLimits.seven_day), ].filter((window): window is UsageLimitWindow => window !== null); + + const scopedWindows = new Map(); + if (Predicate.isObject(rateLimits)) { + for (const [key, value] of Object.entries(rateLimits)) { + if (!key.startsWith("seven_day_") || key === "seven_day") continue; + const window = readClaudeWindow(value); + if (window === null) continue; + const suffix = key.slice("seven_day_".length); + const label = formatClaudeWindowLabel(suffix); + if (label.length === 0) continue; + const normalized = claudeWindow(`weekly:${suffix}`, label, window); + if (normalized !== null) scopedWindows.set(label.toLowerCase(), normalized); + } + } + const limits = + response.limits ?? + (Predicate.hasProperty(rateLimits, "limits") ? rateLimits.limits : undefined); + for (const window of readClaudeScopedWindows(limits)) { + scopedWindows.set(window.label?.toLowerCase() ?? window.kind, window); + } + windows.push(...scopedWindows.values()); if (windows.length === 0) return null; const plan = response.subscription_type?.trim() ?? ""; @@ -149,6 +229,7 @@ function codexWindow( : DateTime.formatIso(DateTime.makeUnsafe(window.resetsAt * 1_000)); return { kind: codexWindowKind(window, fallback), + label: codexWindowKind(window, fallback) === "fiveHour" ? "5h" : "Week", usedPercent: percent, resetsAt, unlimited: false, @@ -165,14 +246,13 @@ export function normalizeCodexSubscriptionLimits( codexWindow(response.rateLimits.secondary, "weekly"), ].filter((window): window is UsageLimitWindow => window !== null); const unlimitedFiveHour = - response.rateLimits.planType !== null && - response.rateLimits.planType !== undefined && - UNLIMITED_CODEX_FIVE_HOUR_PLANS.has(response.rateLimits.planType) && + response.rateLimits.credits?.unlimited === true && !meteredWindows.some((window) => window.kind === "fiveHour"); const windows = unlimitedFiveHour ? [ { kind: "fiveHour", + label: "5h", usedPercent: 0, resetsAt: null, unlimited: true, @@ -188,3 +268,48 @@ export function normalizeCodexSubscriptionLimits( windows, }; } + +/** Provides representative quota data for visual review without provider authentication. */ +export function makeSubscriptionLimitsDevFixture( + enabled: boolean, + fixture: string | undefined, + nowMs: number, +): readonly UsageProviderLimits[] | null { + if (!enabled || fixture !== "review") return null; + + const claude = normalizeClaudeSubscriptionLimits({ + subscription_type: "max", + rate_limits_available: true, + rate_limits: { + five_hour: { + utilization: 68, + resets_at: DateTime.formatIso(DateTime.makeUnsafe(nowMs + 2 * HOUR_MS)), + }, + seven_day: { + utilization: 32, + resets_at: DateTime.formatIso(DateTime.makeUnsafe(nowMs + 5 * DAY_MS)), + }, + }, + limits: [ + { + kind: "weekly_scoped", + percent: 91, + resets_at: DateTime.formatIso(DateTime.makeUnsafe(nowMs + 4 * DAY_MS)), + scope: { model: { display_name: "Fable" } }, + }, + ], + }); + const codex = normalizeCodexSubscriptionLimits({ + rateLimits: { + planType: "pro", + credits: { balance: null, hasCredits: false, unlimited: true }, + secondary: { + usedPercent: 47, + windowDurationMins: WEEK_MINUTES, + resetsAt: Math.floor((nowMs + 6 * DAY_MS) / 1_000), + }, + }, + }); + + return [codex, claude].filter((limits): limits is UsageProviderLimits => limits !== null); +} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 4dd09e22db2d..8061d7d1ee77 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -499,10 +499,12 @@ function ProviderMark({ return ; } -const LIMIT_WINDOW_LABEL: Record = { - fiveHour: "5h", - weekly: "Week", -}; +function usageLimitWindowLabel(window: UsageLimitWindow): string { + if (window.label) return window.label; + if (window.kind === "fiveHour") return "5h"; + if (window.kind === "weekly") return "Week"; + return window.kind; +} function UsageLimitMeters({ limits, @@ -522,13 +524,13 @@ function UsageLimitMeters({ const countdown = window.resetsAt ? formatUsageResetCountdown(window.resetsAt, nowMs) : null; - const label = LIMIT_WINDOW_LABEL[window.kind]; + const label = usageLimitWindowLabel(window); const resetText = reset ? ` Resets ${reset}.` : ""; const usageText = window.unlimited ? "Unlimited. No five-hour limit on this plan." : `${Math.round(percent)}% used.${resetText}`; return ( - + }> {label} diff --git a/docs/user/usage.md b/docs/user/usage.md index 3397262bce70..58f4be88bea3 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -8,10 +8,11 @@ cost shown here. When a signed-in provider exposes subscription quotas, its summary row keeps the usual cost and token summary and adds the current five-hour and weekly usage meters. Each meter shows the time remaining until it resets. On web and desktop, hover a meter to see the exact reset time. Providers -that do not expose quota data omit the meters. +that do not expose quota data omit the meters. Claude also shows model-scoped weekly windows, such +as Fable, when the subscription reports them. -Codex Pro 5x and Pro 20x plans show `∞` for the uncapped five-hour window. Plus plans show the -five-hour percentage reported by Codex. +Codex shows `∞` only when the provider explicitly reports unlimited credits. Otherwise it shows +only the quota windows returned for the signed-in Codex plan. Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**, **30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index ef487cb73c7e..d671ba5b16a0 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -161,12 +161,15 @@ export const UsagePricing = Schema.Struct({ }); export type UsagePricing = typeof UsagePricing.Type; -export const UsageLimitWindowKind = Schema.Literals(["fiveHour", "weekly"]); +/** Provider-stable identifier for a subscription quota window. */ +export const UsageLimitWindowKind = TrimmedNonEmptyString; export type UsageLimitWindowKind = typeof UsageLimitWindowKind.Type; /** One subscription quota window reported by the provider CLI. */ export const UsageLimitWindow = Schema.Struct({ kind: UsageLimitWindowKind, + /** Provider-facing label. Optional so contract-v4 clients can decode older servers. */ + label: Schema.optionalKey(TrimmedNonEmptyString), usedPercent: Schema.Number, resetsAt: Schema.NullOr(Schema.String), /** True when this plan has no cap for the window. */ From 3cc3032a3c742ea1b0079abdac1144b8e1257921 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Wed, 26 Aug 2026 20:55:36 -0600 Subject: [PATCH 09/18] fix(usage): reuse fresh subscription limits --- .../src/features/usage/UsageRouteScreen.tsx | 10 + apps/server/src/usage/UsageService.ts | 233 +++++++++++------- .../src/usage/usageSubscriptionLimits.test.ts | 179 ++++++++++++-- .../src/usage/usageSubscriptionLimits.ts | 197 ++++++++++++--- .../src/usage/usageTranscriptReader.test.ts | 58 +++++ .../server/src/usage/usageTranscriptReader.ts | 51 ++++ .../src/components/usage/UsagePage.test.tsx | 3 + apps/web/src/components/usage/UsagePage.tsx | 10 + docs/user/usage.md | 6 +- packages/contracts/src/usage.ts | 4 + packages/shared/src/usageFormat.test.ts | 7 + packages/shared/src/usageFormat.ts | 13 + 12 files changed, 614 insertions(+), 157 deletions(-) create mode 100644 apps/server/src/usage/usageTranscriptReader.test.ts diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 23b42a83586c..6833e08a799f 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -9,6 +9,7 @@ import { formatHourShort, formatPercent, formatTokens, + formatUsageObservationAge, formatUsageResetCountdown, formatUsageResetDateTime, formatUsd, @@ -389,8 +390,17 @@ function UsageLimitMeters(props: { readonly nowMs: number; readonly timeZone: string; }) { + const observationAge = + props.limits.stale === true && props.limits.observedAt + ? formatUsageObservationAge(props.limits.observedAt, props.nowMs) + : null; return ( + {observationAge ? ( + + Limits last updated {observationAge} ago + + ) : null} {props.limits.windows.map((window) => { const percent = Math.min(100, Math.max(0, window.usedPercent)); const reset = window.resetsAt diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index c8bc9769e37f..3e07fb32951a 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -48,6 +48,7 @@ import { parseRateTable, type RateTable } from "./usagePricing.ts"; import { listTranscriptFiles, readDirectoryVolumeId, + readFreshCodexRateLimitsSnapshot, readTranscriptRecords, } from "./usageTranscriptReader.ts"; import { @@ -66,6 +67,8 @@ import { normalizeCodexSubscriptionLimits, readSubscriptionLimitsCacheEntry, runSubscriptionLimitsProbe, + SUBSCRIPTION_LIMITS_SUCCESS_TTL_MS, + type CodexTranscriptRateLimitsSnapshot, type SubscriptionLimitsCacheEntry, type SubscriptionLimitsProbeOutcome, } from "./usageSubscriptionLimits.ts"; @@ -156,94 +159,123 @@ export const make = Effect.gen(function* () { let ratesStatus: UsageSummary["pricing"]["status"] = "unavailable"; const subscriptionLimitsCache = new Map(); - const readSubscriptionLimits = Effect.fn("UsageService.readSubscriptionLimits")(() => - subscriptionLimitsSemaphore.withPermits(1)( - Effect.gen(function* () { - const now = yield* Clock.currentTimeMillis; - const fixture = makeSubscriptionLimitsDevFixture( - config.devUrl !== undefined, - process.env.T3CODE_DEV_USAGE_LIMITS_FIXTURE, - now, - ); - if (fixture !== null) return fixture; - - const cachedOutcomes = new Map(); - for (const [provider, cached] of subscriptionLimitsCache) { - const outcome = readSubscriptionLimitsCacheEntry(cached, now); - if (outcome !== undefined) cachedOutcomes.set(provider, outcome); - else subscriptionLimitsCache.delete(provider); - } + const readSubscriptionLimits = Effect.fn("UsageService.readSubscriptionLimits")( + (codexSnapshot: CodexTranscriptRateLimitsSnapshot | null) => + subscriptionLimitsSemaphore.withPermits(1)( + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const fixture = makeSubscriptionLimitsDevFixture( + config.devUrl !== undefined, + process.env.T3CODE_DEV_USAGE_LIMITS_FIXTURE, + now, + ); + if (fixture !== null) return fixture; - const settings = yield* settingsService.getSettings.pipe( - Effect.catchCause(() => Effect.succeed(null)), - ); - if (settings === null) { - return [...cachedOutcomes.values()].flatMap((outcome) => - outcome._tag === "Success" && outcome.limits !== null ? [outcome.limits] : [], + const cachedOutcomes = new Map(); + for (const [provider, cached] of subscriptionLimitsCache) { + const outcome = readSubscriptionLimitsCacheEntry(cached, now); + if (outcome !== undefined) cachedOutcomes.set(provider, outcome); + } + + const settings = yield* settingsService.getSettings.pipe( + Effect.catchCause(() => Effect.succeed(null)), ); - } + if (settings === null) { + return [...cachedOutcomes.values()].flatMap((outcome) => + outcome._tag === "Success" && outcome.limits !== null ? [outcome.limits] : [], + ); + } - const cachedClaude = settings.providers.claudeAgent.enabled - ? cachedOutcomes.get("claude") - : undefined; - const cachedCodex = settings.providers.codex.enabled - ? cachedOutcomes.get("codex") - : undefined; - const codexHomeLayout = yield* resolveCodexHomeLayout(settings.providers.codex).pipe( - Effect.provideService(Path.Path, path), - ); - const codexProbeSettings = { - ...settings.providers.codex, - homePath: codexHomeLayout.effectiveHomePath ?? "", - }; - - const [claudeProbeOutcome, codexProbeOutcome] = yield* Effect.all( - [ - settings.providers.claudeAgent.enabled && cachedClaude === undefined - ? runSubscriptionLimitsProbe( - probeClaudeUsage(settings.providers.claudeAgent, process.env, config.cwd).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ), - normalizeClaudeSubscriptionLimits, - ).pipe(Effect.map(Option.some)) - : Effect.succeed(Option.none()), - settings.providers.codex.enabled && cachedCodex === undefined - ? runSubscriptionLimitsProbe( - probeCodexRateLimits(codexProbeSettings, process.env, config.cwd).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - childProcessSpawner, - ), - ), - normalizeCodexSubscriptionLimits, - ).pipe(Effect.map(Option.some)) - : Effect.succeed(Option.none()), - ], - { concurrency: "unbounded" }, - ); - - const claudeOutcome = cachedClaude ?? Option.getOrUndefined(claudeProbeOutcome); - const codexOutcome = cachedCodex ?? Option.getOrUndefined(codexProbeOutcome); - const fetchedAtMs = yield* Clock.currentTimeMillis; - if (Option.isSome(claudeProbeOutcome)) { - subscriptionLimitsCache.set( - "claude", - makeSubscriptionLimitsCacheEntry(claudeProbeOutcome.value, fetchedAtMs), + const cachedClaude = settings.providers.claudeAgent.enabled + ? cachedOutcomes.get("claude") + : undefined; + const cachedCodex = settings.providers.codex.enabled + ? cachedOutcomes.get("codex") + : undefined; + const codexHomeLayout = yield* resolveCodexHomeLayout(settings.providers.codex).pipe( + Effect.provideService(Path.Path, path), ); - } - if (Option.isSome(codexProbeOutcome)) { - subscriptionLimitsCache.set( - "codex", - makeSubscriptionLimitsCacheEntry(codexProbeOutcome.value, fetchedAtMs), + const codexProbeSettings = { + ...settings.providers.codex, + homePath: codexHomeLayout.effectiveHomePath ?? "", + }; + const cachedCodexEntry = subscriptionLimitsCache.get("codex"); + const codexSnapshotCanRefreshCache = + cachedCodex === undefined || cachedCodexEntry?.outcome._tag === "Failure"; + const codexSnapshotOutcome = + settings.providers.codex.enabled && + codexSnapshotCanRefreshCache && + codexSnapshot !== null + ? Option.some({ + outcome: { + _tag: "Success", + limits: normalizeCodexSubscriptionLimits(codexSnapshot.response), + } satisfies SubscriptionLimitsProbeOutcome, + observedAtMs: codexSnapshot.observedAtMs, + }) + : Option.none(); + + const [claudeProbeOutcome, codexProbeOutcome] = yield* Effect.all( + [ + settings.providers.claudeAgent.enabled && cachedClaude === undefined + ? runSubscriptionLimitsProbe( + probeClaudeUsage(settings.providers.claudeAgent, process.env, config.cwd).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + normalizeClaudeSubscriptionLimits, + ).pipe(Effect.map(Option.some)) + : Effect.succeed(Option.none()), + settings.providers.codex.enabled && + cachedCodex === undefined && + Option.isNone(codexSnapshotOutcome) + ? runSubscriptionLimitsProbe( + probeCodexRateLimits(codexProbeSettings, process.env, config.cwd).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + childProcessSpawner, + ), + ), + normalizeCodexSubscriptionLimits, + ).pipe(Effect.map(Option.some)) + : Effect.succeed(Option.none()), + ], + { concurrency: "unbounded" }, ); - } - return [codexOutcome, claudeOutcome].flatMap((outcome) => - outcome?._tag === "Success" && outcome.limits !== null ? [outcome.limits] : [], - ); - }), - ), + const fetchedAtMs = yield* Clock.currentTimeMillis; + let claudeOutcome = cachedClaude; + if (Option.isSome(claudeProbeOutcome)) { + const entry = makeSubscriptionLimitsCacheEntry( + claudeProbeOutcome.value, + fetchedAtMs, + subscriptionLimitsCache.get("claude"), + ); + subscriptionLimitsCache.set("claude", entry); + claudeOutcome = readSubscriptionLimitsCacheEntry(entry, fetchedAtMs); + } + let codexOutcome = cachedCodex; + const freshCodexOutcome = Option.isSome(codexSnapshotOutcome) + ? codexSnapshotOutcome.value + : Option.isSome(codexProbeOutcome) + ? { outcome: codexProbeOutcome.value, observedAtMs: fetchedAtMs } + : undefined; + if (freshCodexOutcome !== undefined) { + const entry = makeSubscriptionLimitsCacheEntry( + freshCodexOutcome.outcome, + fetchedAtMs, + subscriptionLimitsCache.get("codex"), + freshCodexOutcome.observedAtMs, + ); + subscriptionLimitsCache.set("codex", entry); + codexOutcome = readSubscriptionLimitsCacheEntry(entry, fetchedAtMs); + } + + return [codexOutcome, claudeOutcome].flatMap((outcome) => + outcome?._tag === "Success" && outcome.limits !== null ? [outcome.limits] : [], + ); + }), + ), ); /** @@ -436,16 +468,6 @@ export const make = Effect.gen(function* () { } const startedAtMs = yield* Clock.currentTimeMillis; - const subscriptionLimitsFiber = yield* readSubscriptionLimits().pipe( - // Subscription meters are optional. Provider payload drift must not make - // transcript usage unavailable. - Effect.catchCause(() => Effect.succeed([])), - Effect.forkIn(subscriptionLimitsScope), - ); - yield* ensureRates(); - yield* ensureScanCacheLoaded; - - const hostId = NodeOS.hostname(); // The home resolvers ask for `Path` themselves; satisfy them from the // instance we already hold so `readSummary` stays context-free. const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); @@ -458,6 +480,29 @@ export const make = Effect.gen(function* () { } const windowStartMs = (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; + const prefetchedFiles = new Map>>(); + const codexDir = dirs.find(({ provider }) => provider === "codex")?.dir; + const codexFiles = + codexDir === undefined + ? [] + : yield* Effect.promise(() => listTranscriptFiles(codexDir, windowStartMs)); + if (codexDir !== undefined) prefetchedFiles.set(codexDir, codexFiles); + const codexSnapshot = yield* Effect.promise(() => + readFreshCodexRateLimitsSnapshot( + codexFiles, + startedAtMs - SUBSCRIPTION_LIMITS_SUCCESS_TTL_MS, + ), + ); + const subscriptionLimitsFiber = yield* readSubscriptionLimits(codexSnapshot).pipe( + // Subscription meters are optional. Provider payload drift must not make + // transcript usage unavailable. + Effect.catchCause(() => Effect.succeed([])), + Effect.forkIn(subscriptionLimitsScope), + ); + yield* ensureRates(); + yield* ensureScanCacheLoaded; + + const hostId = NodeOS.hostname(); const aggregator = new UsageAggregator({ timeZone: input.timeZone, @@ -492,7 +537,9 @@ export const make = Effect.gen(function* () { } walkedRoots.push(dir); - const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs)); + const prefetched = prefetchedFiles.get(dir); + const files = + prefetched ?? (yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs))); let scannedFiles = 0; let skippedFiles = 0; // Distinct per directory. Buckets carry per-cell session counts, but a diff --git a/apps/server/src/usage/usageSubscriptionLimits.test.ts b/apps/server/src/usage/usageSubscriptionLimits.test.ts index 5d45f0d4d493..976f2c0faf6c 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.test.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.test.ts @@ -11,6 +11,7 @@ import { makeSubscriptionLimitsDevFixture, normalizeClaudeSubscriptionLimits, normalizeCodexSubscriptionLimits, + parseCodexTranscriptRateLimitsSnapshot, readSubscriptionLimitsCacheEntry, runSubscriptionLimitsProbe, } from "./usageSubscriptionLimits.ts"; @@ -49,22 +50,23 @@ describe("subscription usage limits", () => { }); it("normalizes Claude's live model-scoped weekly window shape", () => { - const limits = normalizeClaudeSubscriptionLimits({ + const response = { subscription_type: "max", rate_limits_available: true, rate_limits: { five_hour: null, seven_day: { utilization: 3, resets_at: "2026-09-01T23:00:00.000Z" }, + limits: [ + { + kind: "weekly_scoped", + percent: 95, + resets_at: "2026-09-01T23:00:00.000Z", + scope: { model: { display_name: "Fable" } }, + }, + ], }, - limits: [ - { - kind: "weekly_scoped", - percent: 95, - resets_at: "2026-09-01T23:00:00.000Z", - scope: { model: { display_name: "Fable" } }, - }, - ], - }); + } as Parameters[0]; + const limits = normalizeClaudeSubscriptionLimits(response); expect(limits?.windows).toEqual([ { @@ -166,27 +168,102 @@ describe("subscription usage limits", () => { ]); }); - it("shows an unlimited Codex five-hour window only when the provider reports it", () => { + it("classifies Codex windows within the upstream five-percent tolerance", () => { const limits = normalizeCodexSubscriptionLimits({ rateLimits: { - planType: "pro", - credits: { balance: null, hasCredits: false, unlimited: true }, - secondary: { usedPercent: 44, windowDurationMins: 10_080, resetsAt: null }, + primary: { usedPercent: 44, windowDurationMins: 10_079, resetsAt: null }, + secondary: { usedPercent: 12, windowDurationMins: 43_201, resetsAt: null }, }, }); expect(limits?.windows).toEqual([ { - kind: "fiveHour", - label: "5h", - usedPercent: 0, + kind: "weekly", + label: "Week", + usedPercent: 44, resetsAt: null, - unlimited: true, + unlimited: false, }, - { kind: "weekly", label: "Week", usedPercent: 44, resetsAt: null, unlimited: false }, + { kind: "monthly", label: "Month", usedPercent: 12, resetsAt: null, unlimited: false }, + ]); + + expect( + normalizeCodexSubscriptionLimits({ + rateLimits: { + primary: { usedPercent: 7, windowDurationMins: 1_439, resetsAt: null }, + secondary: { usedPercent: 2, windowDurationMins: 525_599, resetsAt: null }, + }, + })?.windows.map(({ kind, label }) => ({ kind, label })), + ).toEqual([ + { kind: "daily", label: "Day" }, + { kind: "annual", label: "Year" }, ]); }); + it("uses neutral labels when Codex omits window durations", () => { + const limits = normalizeCodexSubscriptionLimits({ + rateLimits: { + primary: { usedPercent: 44, resetsAt: null }, + secondary: { usedPercent: 12, resetsAt: null }, + }, + }); + + expect(limits?.windows).toEqual([ + { + kind: "codex:primary", + label: "Usage", + usedPercent: 44, + resetsAt: null, + unlimited: false, + }, + { + kind: "codex:secondary", + label: "Secondary", + usedPercent: 12, + resetsAt: null, + unlimited: false, + }, + ]); + }); + + it("reads Codex rate limits from a persisted token-count event", () => { + expect( + parseCodexTranscriptRateLimitsSnapshot( + JSON.stringify({ + timestamp: "2026-08-27T02:10:00.000Z", + type: "event_msg", + payload: { + type: "token_count", + info: { + rate_limits: { + primary: null, + secondary: { + used_percent: 57, + window_minutes: 10_080, + resets_at: 1_788_000_000, + }, + plan_type: "prolite", + }, + }, + }, + }), + ), + ).toEqual({ + observedAtMs: Date.parse("2026-08-27T02:10:00.000Z"), + response: { + rateLimits: { + planType: "prolite", + primary: null, + secondary: { + usedPercent: 57, + windowDurationMins: 10_080, + resetsAt: 1_788_000_000, + }, + }, + }, + }); + }); + it("clamps provider percentages to the progress bar range", () => { const limits = normalizeCodexSubscriptionLimits({ rateLimits: { @@ -250,6 +327,21 @@ describe("subscription usage limits", () => { expect(readSubscriptionLimitsCacheEntry(entry, 181_000)).toBeUndefined(); }); + it("does not extend a transcript snapshot beyond three minutes from observation", () => { + const entry = makeSubscriptionLimitsCacheEntry( + { _tag: "Success", limits: null }, + 120_000, + undefined, + 1_000, + ); + + expect(readSubscriptionLimitsCacheEntry(entry, 180_999)).toEqual({ + _tag: "Success", + limits: null, + }); + expect(readSubscriptionLimitsCacheEntry(entry, 181_000)).toBeUndefined(); + }); + it("backs off failed probes for ten minutes", () => { const entry = makeSubscriptionLimitsCacheEntry({ _tag: "Failure" }, 1_000); @@ -257,6 +349,48 @@ describe("subscription usage limits", () => { expect(readSubscriptionLimitsCacheEntry(entry, 601_000)).toBeUndefined(); }); + it("retains the last known good limits when a refresh fails", () => { + const success = makeSubscriptionLimitsCacheEntry( + { + _tag: "Success", + limits: { + provider: "codex", + plan: "prolite", + windows: [ + { + kind: "weekly", + label: "Week", + usedPercent: 57, + resetsAt: null, + unlimited: false, + }, + ], + }, + }, + 1_000, + ); + const failed = makeSubscriptionLimitsCacheEntry({ _tag: "Failure" }, 181_000, success); + + expect(readSubscriptionLimitsCacheEntry(failed, 181_001)).toEqual({ + _tag: "Success", + limits: { + provider: "codex", + plan: "prolite", + windows: [ + { + kind: "weekly", + label: "Week", + usedPercent: 57, + resetsAt: null, + unlimited: false, + }, + ], + observedAt: "1970-01-01T00:00:01.000Z", + stale: true, + }, + }); + }); + it("provides representative limits only for the explicit dev fixture", () => { expect(makeSubscriptionLimitsDevFixture(false, "review", 1_788_000_000_000)).toBeNull(); expect(makeSubscriptionLimitsDevFixture(true, undefined, 1_788_000_000_000)).toBeNull(); @@ -266,13 +400,6 @@ describe("subscription usage limits", () => { provider: "codex", plan: "pro", windows: [ - { - kind: "fiveHour", - label: "5h", - usedPercent: 0, - resetsAt: null, - unlimited: true, - }, { kind: "weekly", label: "Week", diff --git a/apps/server/src/usage/usageSubscriptionLimits.ts b/apps/server/src/usage/usageSubscriptionLimits.ts index 69889d7b355a..c83de25b34c6 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.ts @@ -9,10 +9,13 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; -import type * as CodexSchema from "effect-codex-app-server/schema"; +import * as Schema from "effect/Schema"; const FIVE_HOURS_MINUTES = 5 * 60; +const DAY_MINUTES = 24 * 60; const WEEK_MINUTES = 7 * 24 * 60; +const MONTH_MINUTES = 30 * DAY_MINUTES; +const YEAR_MINUTES = 365 * DAY_MINUTES; const HOUR_MS = 60 * 60_000; const DAY_MS = 24 * HOUR_MS; @@ -30,6 +33,10 @@ export type SubscriptionLimitsProbeOutcome = export interface SubscriptionLimitsCacheEntry { readonly expiresAtMs: number; readonly outcome: SubscriptionLimitsProbeOutcome; + readonly lastSuccess?: { + readonly limits: UsageProviderLimits | null; + readonly observedAtMs: number; + }; } const subscriptionLimitsProbeFailure = { _tag: "Failure" } as const; @@ -66,29 +73,137 @@ export const awaitSubscriptionLimits = Effect.fn("awaitSubscriptionLimits")( export function makeSubscriptionLimitsCacheEntry( outcome: SubscriptionLimitsProbeOutcome, nowMs: number, + previous?: SubscriptionLimitsCacheEntry, + observedAtMs = nowMs, ): SubscriptionLimitsCacheEntry { const ttlMs = outcome._tag === "Success" ? SUBSCRIPTION_LIMITS_SUCCESS_TTL_MS : SUBSCRIPTION_LIMITS_FAILURE_TTL_MS; - return { expiresAtMs: nowMs + ttlMs, outcome }; + const expiresAtMs = + outcome._tag === "Success" ? Math.min(nowMs + ttlMs, observedAtMs + ttlMs) : nowMs + ttlMs; + const lastSuccess = + outcome._tag === "Success" ? { limits: outcome.limits, observedAtMs } : previous?.lastSuccess; + return { + expiresAtMs, + outcome, + ...(lastSuccess === undefined ? {} : { lastSuccess }), + }; } export function readSubscriptionLimitsCacheEntry( entry: SubscriptionLimitsCacheEntry | undefined, nowMs: number, ): SubscriptionLimitsProbeOutcome | undefined { - return entry !== undefined && nowMs < entry.expiresAtMs ? entry.outcome : undefined; + if (entry === undefined || nowMs >= entry.expiresAtMs) return undefined; + if (entry.outcome._tag === "Success") { + return { + _tag: "Success", + limits: stampSubscriptionLimits(entry.outcome.limits, entry.lastSuccess?.observedAtMs, false), + }; + } + if (entry.lastSuccess === undefined) return entry.outcome; + return { + _tag: "Success", + limits: stampSubscriptionLimits(entry.lastSuccess.limits, entry.lastSuccess.observedAtMs, true), + }; +} + +function stampSubscriptionLimits( + limits: UsageProviderLimits | null, + observedAtMs: number | undefined, + stale: boolean, +): UsageProviderLimits | null { + if (limits === null || observedAtMs === undefined) return limits; + return { + ...limits, + observedAt: DateTime.formatIso(DateTime.makeUnsafe(observedAtMs)), + stale, + }; } type ClaudeUsageLimitsResponse = Partial< Pick > & { - /** Newer Claude responses expose model-scoped weekly windows here. */ + /** Compatibility fallback for SDK builds that project model-scoped limits at the top level. */ readonly limits?: unknown; }; -type CodexUsageLimitsResponse = Pick; +interface CodexRateLimitWindowResponse { + readonly usedPercent: number; + readonly windowDurationMins?: number | null; + readonly resetsAt?: number | null; +} + +export interface CodexUsageLimitsResponse { + readonly rateLimits: { + readonly planType?: string | null; + readonly primary?: CodexRateLimitWindowResponse | null; + readonly secondary?: CodexRateLimitWindowResponse | null; + }; +} + +export interface CodexTranscriptRateLimitsSnapshot { + readonly observedAtMs: number; + readonly response: CodexUsageLimitsResponse; +} + +const NullableNumber = Schema.Union([Schema.Number, Schema.Null]); +const CodexTranscriptRateLimitWindow = Schema.Struct({ + used_percent: Schema.Number, + window_minutes: Schema.optionalKey(NullableNumber), + resets_at: Schema.optionalKey(NullableNumber), +}); +const CodexTranscriptRateLimitsEvent = Schema.Struct({ + timestamp: Schema.String, + payload: Schema.Struct({ + type: Schema.Literal("token_count"), + info: Schema.Struct({ + rate_limits: Schema.Struct({ + primary: Schema.optionalKey(Schema.Union([CodexTranscriptRateLimitWindow, Schema.Null])), + secondary: Schema.optionalKey(Schema.Union([CodexTranscriptRateLimitWindow, Schema.Null])), + plan_type: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + }), + }), + }), +}); +const decodeCodexTranscriptRateLimitsEvent = Schema.decodeUnknownOption( + Schema.fromJsonString(CodexTranscriptRateLimitsEvent), +); + +function codexTranscriptWindow( + window: typeof CodexTranscriptRateLimitWindow.Type | null | undefined, +): CodexRateLimitWindowResponse | null | undefined { + if (window === null || window === undefined) return window; + return { + usedPercent: window.used_percent, + ...(window.window_minutes === undefined ? {} : { windowDurationMins: window.window_minutes }), + ...(window.resets_at === undefined ? {} : { resetsAt: window.resets_at }), + }; +} + +/** Reads the account-accurate rate-limit snapshot Codex persists after a turn. */ +export function parseCodexTranscriptRateLimitsSnapshot( + line: string, +): CodexTranscriptRateLimitsSnapshot | null { + const decoded = decodeCodexTranscriptRateLimitsEvent(line); + if (Option.isNone(decoded)) return null; + const observedAtMs = Date.parse(decoded.value.timestamp); + if (!Number.isFinite(observedAtMs)) return null; + const limits = decoded.value.payload.info.rate_limits; + const primary = codexTranscriptWindow(limits.primary); + const secondary = codexTranscriptWindow(limits.secondary); + return { + observedAtMs, + response: { + rateLimits: { + ...(limits.plan_type === undefined ? {} : { planType: limits.plan_type }), + ...(primary === undefined ? {} : { primary }), + ...(secondary === undefined ? {} : { secondary }), + }, + }, + }; +} function usedPercent(value: number | null): number | null { if (value === null || !Number.isFinite(value)) return null; @@ -206,18 +321,43 @@ export function normalizeClaudeSubscriptionLimits( }; } -function codexWindowKind( - window: CodexSchema.V2GetAccountRateLimitsResponse__RateLimitWindow, - fallback: UsageLimitWindowKind, -): UsageLimitWindowKind { - if (window.windowDurationMins === FIVE_HOURS_MINUTES) return "fiveHour"; - if (window.windowDurationMins === WEEK_MINUTES) return "weekly"; - return fallback; +interface CodexWindowPresentation { + readonly kind: UsageLimitWindowKind; + readonly label: string; +} + +const CODEX_WINDOW_PRESENTATIONS = [ + { minutes: FIVE_HOURS_MINUTES, kind: "fiveHour", label: "5h" }, + { minutes: DAY_MINUTES, kind: "daily", label: "Day" }, + { minutes: WEEK_MINUTES, kind: "weekly", label: "Week" }, + { minutes: MONTH_MINUTES, kind: "monthly", label: "Month" }, + { minutes: YEAR_MINUTES, kind: "annual", label: "Year" }, +] as const; + +function isApproximateCodexWindow(actualMinutes: number, expectedMinutes: number): boolean { + return actualMinutes >= expectedMinutes * 0.95 && actualMinutes <= expectedMinutes * 1.05; +} + +function codexWindowPresentation( + window: CodexRateLimitWindowResponse, + position: "primary" | "secondary", +): CodexWindowPresentation { + const duration = window.windowDurationMins; + if (duration !== null && duration !== undefined && Number.isFinite(duration)) { + const known = CODEX_WINDOW_PRESENTATIONS.find((candidate) => + isApproximateCodexWindow(duration, candidate.minutes), + ); + if (known !== undefined) return known; + } + return { + kind: `codex:${position}`, + label: position === "primary" ? "Usage" : "Secondary", + }; } function codexWindow( - window: CodexSchema.V2GetAccountRateLimitsResponse__RateLimitWindow | null | undefined, - fallback: UsageLimitWindowKind, + window: CodexRateLimitWindowResponse | null | undefined, + position: "primary" | "secondary", ): UsageLimitWindow | null { if (!window) return null; const percent = usedPercent(window.usedPercent); @@ -227,9 +367,10 @@ function codexWindow( window.resetsAt === null || window.resetsAt === undefined ? null : DateTime.formatIso(DateTime.makeUnsafe(window.resetsAt * 1_000)); + const presentation = codexWindowPresentation(window, position); return { - kind: codexWindowKind(window, fallback), - label: codexWindowKind(window, fallback) === "fiveHour" ? "5h" : "Week", + kind: presentation.kind, + label: presentation.label, usedPercent: percent, resetsAt, unlimited: false, @@ -242,30 +383,15 @@ export function normalizeCodexSubscriptionLimits( if (!response) return null; const meteredWindows = [ - codexWindow(response.rateLimits.primary, "fiveHour"), - codexWindow(response.rateLimits.secondary, "weekly"), + codexWindow(response.rateLimits.primary, "primary"), + codexWindow(response.rateLimits.secondary, "secondary"), ].filter((window): window is UsageLimitWindow => window !== null); - const unlimitedFiveHour = - response.rateLimits.credits?.unlimited === true && - !meteredWindows.some((window) => window.kind === "fiveHour"); - const windows = unlimitedFiveHour - ? [ - { - kind: "fiveHour", - label: "5h", - usedPercent: 0, - resetsAt: null, - unlimited: true, - } satisfies UsageLimitWindow, - ...meteredWindows, - ] - : meteredWindows; - if (windows.length === 0) return null; + if (meteredWindows.length === 0) return null; return { provider: "codex", plan: response.rateLimits.planType ?? null, - windows, + windows: meteredWindows, }; } @@ -302,7 +428,6 @@ export function makeSubscriptionLimitsDevFixture( const codex = normalizeCodexSubscriptionLimits({ rateLimits: { planType: "pro", - credits: { balance: null, hasCredits: false, unlimited: true }, secondary: { usedPercent: 47, windowDurationMins: WEEK_MINUTES, diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts new file mode 100644 index 000000000000..657e852f7c67 --- /dev/null +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -0,0 +1,58 @@ +// @effect-diagnostics nodeBuiltinImport:off -- This focused test exercises the raw Node transcript tail reader. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { describe, expect, it } from "@effect/vitest"; + +import { readFreshCodexRateLimitsSnapshot } from "./usageTranscriptReader.ts"; + +function rateLimitLine(timestamp: string, usedPercent: number): string { + return JSON.stringify({ + timestamp, + type: "event_msg", + payload: { + type: "token_count", + info: { + rate_limits: { + primary: null, + secondary: { + used_percent: usedPercent, + window_minutes: 10_080, + resets_at: 1_788_000_000, + }, + plan_type: "prolite", + }, + }, + }, + }); +} + +describe("Codex transcript rate-limit snapshots", () => { + it("returns the newest fresh snapshot from a recently modified rollout", async () => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-codex-limits-")); + try { + const path = NodePath.join(directory, "rollout.jsonl"); + const olderAt = "2026-08-27T02:09:00.000Z"; + const newerAt = "2026-08-27T02:10:00.000Z"; + await NodeFSP.writeFile( + path, + `${rateLimitLine(olderAt, 40)}\n${rateLimitLine(newerAt, 57)}\n`, + ); + const stats = await NodeFSP.stat(path); + const files = [{ path, size: stats.size, mtimeMs: stats.mtimeMs }]; + + const snapshot = await readFreshCodexRateLimitsSnapshot( + files, + Date.parse("2026-08-27T02:08:00.000Z"), + ); + expect(snapshot?.observedAtMs).toBe(Date.parse(newerAt)); + expect(snapshot?.response.rateLimits.secondary?.usedPercent).toBe(57); + await expect( + readFreshCodexRateLimitsSnapshot(files, Date.parse("2026-08-27T02:11:00.000Z")), + ).resolves.toBeNull(); + } finally { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index c72f0c24db65..70de18624919 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -17,6 +17,10 @@ import * as NodeReadline from "node:readline"; import type { UsageProviderKind } from "@t3tools/contracts"; +import { + parseCodexTranscriptRateLimitsSnapshot, + type CodexTranscriptRateLimitsSnapshot, +} from "./usageSubscriptionLimits.ts"; import { initialCodexScanState, mightCarryUsage, @@ -25,6 +29,8 @@ import { type UsageRecord, } from "./usageTranscripts.ts"; +const CODEX_RATE_LIMIT_TAIL_BYTES = 1024 * 1024; + export interface TranscriptFile { readonly path: string; readonly size: number; @@ -89,6 +95,51 @@ export async function readDirectoryVolumeId(path: string): Promise { } } +/** + * Reads recent Codex rollout tails for the newest account-accurate rate-limit snapshot. + * + * Only the final MiB of each recently modified rollout is inspected. Missing a snapshot + * merely falls back to the native probe; a stale snapshot is never returned as current. + */ +export async function readFreshCodexRateLimitsSnapshot( + files: readonly TranscriptFile[], + sinceMs: number, +): Promise { + const candidates = files + .filter((file) => file.mtimeMs >= sinceMs) + .sort((a, b) => b.mtimeMs - a.mtimeMs); + let newest: CodexTranscriptRateLimitsSnapshot | null = null; + + for (const file of candidates) { + let handle: NodeFSP.FileHandle | undefined; + try { + handle = await NodeFSP.open(file.path, "r"); + const stats = await handle.stat(); + const length = Math.min(stats.size, CODEX_RATE_LIMIT_TAIL_BYTES); + const offset = stats.size - length; + const buffer = Buffer.alloc(length); + await handle.read(buffer, 0, length, offset); + const lines = buffer.toString("utf8").split(/\r?\n/); + if (offset > 0) lines.shift(); + + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]; + if (line === undefined || !line.includes('"rate_limits"')) continue; + const snapshot = parseCodexTranscriptRateLimitsSnapshot(line); + if (snapshot === null || snapshot.observedAtMs < sinceMs) continue; + if (newest === null || snapshot.observedAtMs > newest.observedAtMs) newest = snapshot; + break; + } + } catch { + // Rollouts can rotate while the Usage page scans. The probe remains the fallback. + } finally { + await handle?.close().catch(() => undefined); + } + } + + return newest; +} + /** * Streams one transcript and returns the usage records it contains, or `null` * when the file could not be read. diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 660936b93d53..6a3121b3e8a0 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -206,6 +206,8 @@ describe("UsagePage subscription limits", () => { { provider: "codex", plan: "pro", + observedAt: "2026-08-26T20:00:00.000Z", + stale: true, windows: [ { kind: "fiveHour", usedPercent: 0, resetsAt: null, unlimited: true }, { @@ -231,6 +233,7 @@ describe("UsagePage subscription limits", () => { expect(markup).toContain("∞"); expect(markup).toContain("No limit"); expect(markup).toContain("Resets in"); + expect(markup).toContain("Limits last updated"); expect(markup).toContain("Aug 29, 9:00 PM"); expect(markup).toContain("of cost"); expect(markup.indexOf("of cost")).toBeLessThan(markup.indexOf('aria-label="Codex 5h limit"')); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 8061d7d1ee77..f473e3421069 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -16,6 +16,7 @@ import { formatHourShort, formatPercent, formatTokens, + formatUsageObservationAge, formatUsageResetCountdown, formatUsageResetDateTime, formatUsd, @@ -516,8 +517,17 @@ function UsageLimitMeters({ readonly timeZone: string; }) { const providerLabel = PROVIDER_PRESENTATION[limits.provider].label; + const observationAge = + limits.stale === true && limits.observedAt + ? formatUsageObservationAge(limits.observedAt, nowMs) + : null; return (
+ {observationAge ? ( + + Limits last updated {observationAge} ago + + ) : null} {limits.windows.map((window) => { const percent = Math.min(100, Math.max(0, window.usedPercent)); const reset = window.resetsAt ? formatUsageResetDateTime(window.resetsAt, timeZone) : null; diff --git a/docs/user/usage.md b/docs/user/usage.md index 58f4be88bea3..5f0cffe94827 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -11,8 +11,10 @@ remaining until it resets. On web and desktop, hover a meter to see the exact re that do not expose quota data omit the meters. Claude also shows model-scoped weekly windows, such as Fable, when the subscription reports them. -Codex shows `∞` only when the provider explicitly reports unlimited credits. Otherwise it shows -only the quota windows returned for the signed-in Codex plan. +Codex shows only the quota windows returned for the signed-in plan. Recent Codex sessions can +supply the same account-accurate snapshot without another provider request; otherwise T3 Code asks +the local Codex CLI. If a refresh fails, the last successful meters remain visible with their age +until a later refresh succeeds. Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**, **30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index d671ba5b16a0..106f1c0b13a3 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -182,6 +182,10 @@ export const UsageProviderLimits = Schema.Struct({ provider: UsageProviderKind, plan: Schema.NullOr(TrimmedNonEmptyString), windows: Schema.Array(UsageLimitWindow), + /** When the provider or local transcript observed these values. */ + observedAt: Schema.optionalKey(TrimmedNonEmptyString), + /** True when a failed refresh is serving the last known good values. */ + stale: Schema.optionalKey(Schema.Boolean), }); export type UsageProviderLimits = typeof UsageProviderLimits.Type; diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index 9d092e774e92..aeec4dd1683e 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -6,6 +6,7 @@ import { formatDateTimeShort, formatHourShort, formatRelativeHourShort, + formatUsageObservationAge, formatUsageResetCountdown, formatUsageResetDateTime, makeWindow, @@ -97,4 +98,10 @@ describe("subscription reset formatting", () => { expect(formatUsageResetDateTime("not-a-date", "UTC")).toBeNull(); expect(formatUsageResetDateTime("2026-08-26T19:14:00.000Z", "Etc/Unknown")).toBeNull(); }); + + it("formats the age of a retained subscription-limit snapshot", () => { + expect(formatUsageObservationAge("2026-08-26T16:55:00.000Z", now)).toBe("5m"); + expect(formatUsageObservationAge("2026-08-26T14:00:00.000Z", now)).toBe("3h"); + expect(formatUsageObservationAge("not-a-date", now)).toBeNull(); + }); }); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index 5dff7c452c0d..972706c4599f 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -65,6 +65,19 @@ export function formatUsageResetCountdown(resetsAt: string, nowMs: number): stri return `${minutes}m`; } +/** Compact age for a last-known-good subscription-limit snapshot. */ +export function formatUsageObservationAge(observedAt: string, nowMs: number): string | null { + const observedAtMs = Date.parse(observedAt); + if (Number.isNaN(observedAtMs)) return null; + + const totalMinutes = Math.max(0, Math.floor((nowMs - observedAtMs) / MINUTE_MS)); + if (totalMinutes < 1) return "less than a minute"; + if (totalMinutes < 60) return `${totalMinutes}m`; + const totalHours = Math.floor(totalMinutes / 60); + if (totalHours < 24) return `${totalHours}h`; + return `${Math.floor(totalHours / 24)}d`; +} + /** Exact reset instant for subscription-limit details, including minutes. */ export function formatUsageResetDateTime(instant: string, timeZone?: string): string | null { const date = new Date(instant); From 6c43b31e07ee79444aca4e968c0bd430d0d9ab9a Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Wed, 26 Aug 2026 21:02:11 -0600 Subject: [PATCH 10/18] fix(usage): bound transcript snapshots --- apps/mobile/src/features/usage/UsageRouteScreen.tsx | 10 ++++------ apps/server/src/usage/UsageService.ts | 1 + apps/server/src/usage/usageTranscriptReader.test.ts | 10 ++++++++-- apps/server/src/usage/usageTranscriptReader.ts | 11 ++++++++++- apps/web/src/components/usage/UsagePage.test.tsx | 1 + apps/web/src/components/usage/UsagePage.tsx | 4 ++-- 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 6833e08a799f..cac9764f0be4 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -413,16 +413,14 @@ function UsageLimitMeters(props: { return ( - {label} + + {label} + { const path = NodePath.join(directory, "rollout.jsonl"); const olderAt = "2026-08-27T02:09:00.000Z"; const newerAt = "2026-08-27T02:10:00.000Z"; + const futureAt = "2036-08-27T02:10:00.000Z"; await NodeFSP.writeFile( path, - `${rateLimitLine(olderAt, 40)}\n${rateLimitLine(newerAt, 57)}\n`, + `${rateLimitLine(olderAt, 40)}\n${rateLimitLine(newerAt, 57)}\n${rateLimitLine(futureAt, 99)}\n`, ); const stats = await NodeFSP.stat(path); const files = [{ path, size: stats.size, mtimeMs: stats.mtimeMs }]; @@ -45,11 +46,16 @@ describe("Codex transcript rate-limit snapshots", () => { const snapshot = await readFreshCodexRateLimitsSnapshot( files, Date.parse("2026-08-27T02:08:00.000Z"), + Date.parse("2026-08-27T02:10:30.000Z"), ); expect(snapshot?.observedAtMs).toBe(Date.parse(newerAt)); expect(snapshot?.response.rateLimits.secondary?.usedPercent).toBe(57); await expect( - readFreshCodexRateLimitsSnapshot(files, Date.parse("2026-08-27T02:11:00.000Z")), + readFreshCodexRateLimitsSnapshot( + files, + Date.parse("2026-08-27T02:11:00.000Z"), + Date.parse("2026-08-27T02:11:30.000Z"), + ), ).resolves.toBeNull(); } finally { await NodeFSP.rm(directory, { recursive: true, force: true }); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 70de18624919..8289f6d779a6 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -30,6 +30,7 @@ import { } from "./usageTranscripts.ts"; const CODEX_RATE_LIMIT_TAIL_BYTES = 1024 * 1024; +const CODEX_RATE_LIMIT_CLOCK_SKEW_MS = 60_000; export interface TranscriptFile { readonly path: string; @@ -104,7 +105,9 @@ export async function readDirectoryVolumeId(path: string): Promise { export async function readFreshCodexRateLimitsSnapshot( files: readonly TranscriptFile[], sinceMs: number, + nowMs: number, ): Promise { + const latestAllowedMs = nowMs + CODEX_RATE_LIMIT_CLOCK_SKEW_MS; const candidates = files .filter((file) => file.mtimeMs >= sinceMs) .sort((a, b) => b.mtimeMs - a.mtimeMs); @@ -126,7 +129,13 @@ export async function readFreshCodexRateLimitsSnapshot( const line = lines[index]; if (line === undefined || !line.includes('"rate_limits"')) continue; const snapshot = parseCodexTranscriptRateLimitsSnapshot(line); - if (snapshot === null || snapshot.observedAtMs < sinceMs) continue; + if ( + snapshot === null || + snapshot.observedAtMs < sinceMs || + snapshot.observedAtMs > latestAllowedMs + ) { + continue; + } if (newest === null || snapshot.observedAtMs > newest.observedAtMs) newest = snapshot; break; } diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 6a3121b3e8a0..aa918ada0c3e 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -230,6 +230,7 @@ describe("UsagePage subscription limits", () => { expect(markup).toContain('aria-label="Codex 5h limit"'); expect(markup).toContain('aria-label="Codex Week limit"'); + expect(markup).toContain('aria-valuetext="Unlimited. No limit on this plan."'); expect(markup).toContain("∞"); expect(markup).toContain("No limit"); expect(markup).toContain("Resets in"); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index f473e3421069..a8be71721f08 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -537,12 +537,12 @@ function UsageLimitMeters({ const label = usageLimitWindowLabel(window); const resetText = reset ? ` Resets ${reset}.` : ""; const usageText = window.unlimited - ? "Unlimited. No five-hour limit on this plan." + ? "Unlimited. No limit on this plan." : `${Math.round(percent)}% used.${resetText}`; return ( }> - + {label} Date: Wed, 26 Aug 2026 21:06:57 -0600 Subject: [PATCH 11/18] fix(usage): bound Codex transcript reads --- apps/server/src/usage/UsageService.ts | 26 ++++++++--------- .../src/usage/usageTranscriptReader.test.ts | 29 +++++++++++++++++++ .../server/src/usage/usageTranscriptReader.ts | 4 ++- 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index e13666a54251..183d7631ad01 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -199,13 +199,8 @@ export const make = Effect.gen(function* () { ...settings.providers.codex, homePath: codexHomeLayout.effectiveHomePath ?? "", }; - const cachedCodexEntry = subscriptionLimitsCache.get("codex"); - const codexSnapshotCanRefreshCache = - cachedCodex === undefined || cachedCodexEntry?.outcome._tag === "Failure"; const codexSnapshotOutcome = - settings.providers.codex.enabled && - codexSnapshotCanRefreshCache && - codexSnapshot !== null + settings.providers.codex.enabled && cachedCodex === undefined && codexSnapshot !== null ? Option.some({ outcome: { _tag: "Success", @@ -487,13 +482,18 @@ export const make = Effect.gen(function* () { ? [] : yield* Effect.promise(() => listTranscriptFiles(codexDir, windowStartMs)); if (codexDir !== undefined) prefetchedFiles.set(codexDir, codexFiles); - const codexSnapshot = yield* Effect.promise(() => - readFreshCodexRateLimitsSnapshot( - codexFiles, - startedAtMs - SUBSCRIPTION_LIMITS_SUCCESS_TTL_MS, - startedAtMs, - ), - ); + const codexLimitsCacheActive = + readSubscriptionLimitsCacheEntry(subscriptionLimitsCache.get("codex"), startedAtMs) !== + undefined; + const codexSnapshot = codexLimitsCacheActive + ? null + : yield* Effect.promise(() => + readFreshCodexRateLimitsSnapshot( + codexFiles, + startedAtMs - SUBSCRIPTION_LIMITS_SUCCESS_TTL_MS, + startedAtMs, + ), + ); const subscriptionLimitsFiber = yield* readSubscriptionLimits(codexSnapshot).pipe( // Subscription meters are optional. Provider payload drift must not make // transcript usage unavailable. diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index cbc76c5784cc..30023e59a9ac 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -61,4 +61,33 @@ describe("Codex transcript rate-limit snapshots", () => { await NodeFSP.rm(directory, { recursive: true, force: true }); } }); + + it("bounds cold-cache tail reads to the newest rollout candidates", async () => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-codex-limits-")); + try { + const files = await Promise.all( + Array.from({ length: 9 }, async (_, index) => { + const path = NodePath.join(directory, `rollout-${index}.jsonl`); + await NodeFSP.writeFile( + path, + index === 8 ? `${rateLimitLine("2026-08-27T02:10:00.000Z", 99)}\n` : "{}\n", + ); + const mtimeMs = Date.parse(`2026-08-27T02:10:0${8 - index}.000Z`); + await NodeFSP.utimes(path, mtimeMs / 1_000, mtimeMs / 1_000); + const stats = await NodeFSP.stat(path); + return { path, size: stats.size, mtimeMs: stats.mtimeMs }; + }), + ); + + await expect( + readFreshCodexRateLimitsSnapshot( + files, + Date.parse("2026-08-27T02:08:00.000Z"), + Date.parse("2026-08-27T02:10:30.000Z"), + ), + ).resolves.toBeNull(); + } finally { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } + }); }); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 8289f6d779a6..0ca52b999af0 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -31,6 +31,7 @@ import { const CODEX_RATE_LIMIT_TAIL_BYTES = 1024 * 1024; const CODEX_RATE_LIMIT_CLOCK_SKEW_MS = 60_000; +const CODEX_RATE_LIMIT_MAX_CANDIDATES = 8; export interface TranscriptFile { readonly path: string; @@ -110,7 +111,8 @@ export async function readFreshCodexRateLimitsSnapshot( const latestAllowedMs = nowMs + CODEX_RATE_LIMIT_CLOCK_SKEW_MS; const candidates = files .filter((file) => file.mtimeMs >= sinceMs) - .sort((a, b) => b.mtimeMs - a.mtimeMs); + .sort((a, b) => b.mtimeMs - a.mtimeMs) + .slice(0, CODEX_RATE_LIMIT_MAX_CANDIDATES); let newest: CodexTranscriptRateLimitsSnapshot | null = null; for (const file of candidates) { From 49babe4fc1a4a623362f23bbe9563ed8ffe431af Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Wed, 26 Aug 2026 22:29:32 -0600 Subject: [PATCH 12/18] fix(usage): decouple subscription limit refreshes --- apps/server/src/usage/UsageService.ts | 249 ++++++++++-------- .../src/usage/usageSubscriptionLimits.test.ts | 53 +++- .../src/usage/usageSubscriptionLimits.ts | 27 +- 3 files changed, 196 insertions(+), 133 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 183d7631ad01..f1c95ac35a43 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -159,120 +159,140 @@ export const make = Effect.gen(function* () { let ratesStatus: UsageSummary["pricing"]["status"] = "unavailable"; const subscriptionLimitsCache = new Map(); - const readSubscriptionLimits = Effect.fn("UsageService.readSubscriptionLimits")( - (codexSnapshot: CodexTranscriptRateLimitsSnapshot | null) => - subscriptionLimitsSemaphore.withPermits(1)( - Effect.gen(function* () { - const now = yield* Clock.currentTimeMillis; - const fixture = makeSubscriptionLimitsDevFixture( - config.devUrl !== undefined, - process.env.T3CODE_DEV_USAGE_LIMITS_FIXTURE, - now, - ); - if (fixture !== null) return fixture; - - const cachedOutcomes = new Map(); - for (const [provider, cached] of subscriptionLimitsCache) { - const outcome = readSubscriptionLimitsCacheEntry(cached, now); - if (outcome !== undefined) cachedOutcomes.set(provider, outcome); - } - - const settings = yield* settingsService.getSettings.pipe( - Effect.catchCause(() => Effect.succeed(null)), - ); - if (settings === null) { - return [...cachedOutcomes.values()].flatMap((outcome) => - outcome._tag === "Success" && outcome.limits !== null ? [outcome.limits] : [], - ); - } + const readCurrentSubscriptionLimits = Effect.fn("UsageService.readCurrentSubscriptionLimits")( + function* () { + const now = yield* Clock.currentTimeMillis; + const fixture = makeSubscriptionLimitsDevFixture( + config.devUrl !== undefined, + process.env.T3CODE_DEV_USAGE_LIMITS_FIXTURE, + now, + ); + if (fixture !== null) return fixture; - const cachedClaude = settings.providers.claudeAgent.enabled - ? cachedOutcomes.get("claude") - : undefined; - const cachedCodex = settings.providers.codex.enabled - ? cachedOutcomes.get("codex") - : undefined; - const codexHomeLayout = yield* resolveCodexHomeLayout(settings.providers.codex).pipe( - Effect.provideService(Path.Path, path), - ); - const codexProbeSettings = { - ...settings.providers.codex, - homePath: codexHomeLayout.effectiveHomePath ?? "", - }; - const codexSnapshotOutcome = - settings.providers.codex.enabled && cachedCodex === undefined && codexSnapshot !== null - ? Option.some({ - outcome: { - _tag: "Success", - limits: normalizeCodexSubscriptionLimits(codexSnapshot.response), - } satisfies SubscriptionLimitsProbeOutcome, - observedAtMs: codexSnapshot.observedAtMs, - }) - : Option.none(); - - const [claudeProbeOutcome, codexProbeOutcome] = yield* Effect.all( - [ - settings.providers.claudeAgent.enabled && cachedClaude === undefined - ? runSubscriptionLimitsProbe( - probeClaudeUsage(settings.providers.claudeAgent, process.env, config.cwd).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ), - normalizeClaudeSubscriptionLimits, - ).pipe(Effect.map(Option.some)) - : Effect.succeed(Option.none()), - settings.providers.codex.enabled && - cachedCodex === undefined && - Option.isNone(codexSnapshotOutcome) - ? runSubscriptionLimitsProbe( - probeCodexRateLimits(codexProbeSettings, process.env, config.cwd).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - childProcessSpawner, - ), - ), - normalizeCodexSubscriptionLimits, - ).pipe(Effect.map(Option.some)) - : Effect.succeed(Option.none()), - ], - { concurrency: "unbounded" }, - ); - - const fetchedAtMs = yield* Clock.currentTimeMillis; - let claudeOutcome = cachedClaude; - if (Option.isSome(claudeProbeOutcome)) { - const entry = makeSubscriptionLimitsCacheEntry( - claudeProbeOutcome.value, - fetchedAtMs, - subscriptionLimitsCache.get("claude"), - ); - subscriptionLimitsCache.set("claude", entry); - claudeOutcome = readSubscriptionLimitsCacheEntry(entry, fetchedAtMs); - } - let codexOutcome = cachedCodex; - const freshCodexOutcome = Option.isSome(codexSnapshotOutcome) - ? codexSnapshotOutcome.value - : Option.isSome(codexProbeOutcome) - ? { outcome: codexProbeOutcome.value, observedAtMs: fetchedAtMs } - : undefined; - if (freshCodexOutcome !== undefined) { - const entry = makeSubscriptionLimitsCacheEntry( - freshCodexOutcome.outcome, - fetchedAtMs, - subscriptionLimitsCache.get("codex"), - freshCodexOutcome.observedAtMs, - ); - subscriptionLimitsCache.set("codex", entry); - codexOutcome = readSubscriptionLimitsCacheEntry(entry, fetchedAtMs); - } + const settings = yield* settingsService.getSettings.pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + const providers: readonly UsageProviderKind[] = + settings === null + ? ["codex", "claude"] + : [ + ...(settings.providers.codex.enabled ? (["codex"] as const) : []), + ...(settings.providers.claudeAgent.enabled ? (["claude"] as const) : []), + ]; + + return providers.flatMap((provider) => { + const outcome = readSubscriptionLimitsCacheEntry( + subscriptionLimitsCache.get(provider), + now, + ); + return outcome?._tag === "Success" && outcome.limits !== null ? [outcome.limits] : []; + }); + }, + ); - return [codexOutcome, claudeOutcome].flatMap((outcome) => - outcome?._tag === "Success" && outcome.limits !== null ? [outcome.limits] : [], - ); - }), - ), + const cacheSubscriptionLimitsProbe = Effect.fn("UsageService.cacheSubscriptionLimitsProbe")( + function* (provider: UsageProviderKind, outcome: SubscriptionLimitsProbeOutcome) { + const fetchedAtMs = yield* Clock.currentTimeMillis; + subscriptionLimitsCache.set( + provider, + makeSubscriptionLimitsCacheEntry( + outcome, + fetchedAtMs, + subscriptionLimitsCache.get(provider), + ), + ); + }, ); + const refreshSubscriptionLimits = Effect.fn("UsageService.refreshSubscriptionLimits")(function* ( + codexSnapshot: CodexTranscriptRateLimitsSnapshot | null, + ) { + const now = yield* Clock.currentTimeMillis; + const fixture = makeSubscriptionLimitsDevFixture( + config.devUrl !== undefined, + process.env.T3CODE_DEV_USAGE_LIMITS_FIXTURE, + now, + ); + if (fixture !== null) return; + + // The transcript value is already available and account-accurate. Cache + // it before taking the probe lock so a slow Claude refresh cannot hold it + // past the page response budget or its own freshness deadline. + if ( + codexSnapshot !== null && + readSubscriptionLimitsCacheEntry(subscriptionLimitsCache.get("codex"), now) === undefined + ) { + subscriptionLimitsCache.set( + "codex", + makeSubscriptionLimitsCacheEntry( + { + _tag: "Success", + limits: normalizeCodexSubscriptionLimits(codexSnapshot.response), + }, + now, + subscriptionLimitsCache.get("codex"), + codexSnapshot.observedAtMs, + ), + ); + } + + yield* subscriptionLimitsSemaphore.withPermits(1)( + Effect.gen(function* () { + const probeStartedAtMs = yield* Clock.currentTimeMillis; + + const cachedOutcomes = new Map(); + for (const [provider, cached] of subscriptionLimitsCache) { + const outcome = readSubscriptionLimitsCacheEntry(cached, probeStartedAtMs); + if (outcome !== undefined) cachedOutcomes.set(provider, outcome); + } + + const settings = yield* settingsService.getSettings.pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + if (settings === null) return; + + const cachedClaude = settings.providers.claudeAgent.enabled + ? cachedOutcomes.get("claude") + : undefined; + const cachedCodex = settings.providers.codex.enabled + ? cachedOutcomes.get("codex") + : undefined; + const codexHomeLayout = yield* resolveCodexHomeLayout(settings.providers.codex).pipe( + Effect.provideService(Path.Path, path), + ); + const codexProbeSettings = { + ...settings.providers.codex, + homePath: codexHomeLayout.effectiveHomePath ?? "", + }; + yield* Effect.all( + [ + settings.providers.claudeAgent.enabled && cachedClaude === undefined + ? runSubscriptionLimitsProbe( + probeClaudeUsage(settings.providers.claudeAgent, process.env, config.cwd).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + normalizeClaudeSubscriptionLimits, + ).pipe(Effect.flatMap((outcome) => cacheSubscriptionLimitsProbe("claude", outcome))) + : Effect.void, + settings.providers.codex.enabled && cachedCodex === undefined + ? runSubscriptionLimitsProbe( + probeCodexRateLimits(codexProbeSettings, process.env, config.cwd).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + childProcessSpawner, + ), + ), + normalizeCodexSubscriptionLimits, + ).pipe(Effect.flatMap((outcome) => cacheSubscriptionLimitsProbe("codex", outcome))) + : Effect.void, + ], + { concurrency: "unbounded" }, + ); + }), + ); + }); + /** * Loads the LiteLLM rate table, preferring a fresh copy and falling back to * the on-disk snapshot. With neither, every model reports as unpriced rather @@ -494,10 +514,10 @@ export const make = Effect.gen(function* () { startedAtMs, ), ); - const subscriptionLimitsFiber = yield* readSubscriptionLimits(codexSnapshot).pipe( + const subscriptionLimitsFiber = yield* refreshSubscriptionLimits(codexSnapshot).pipe( // Subscription meters are optional. Provider payload drift must not make // transcript usage unavailable. - Effect.catchCause(() => Effect.succeed([])), + Effect.catchCause(() => Effect.void), Effect.forkIn(subscriptionLimitsScope), ); yield* ensureRates(); @@ -587,7 +607,10 @@ export const make = Effect.gen(function* () { const aggregated = aggregator.finish(); const readAt = yield* DateTime.now; const finishedAtMs = yield* Clock.currentTimeMillis; - const subscriptionLimits = yield* awaitSubscriptionLimits(subscriptionLimitsFiber); + const subscriptionLimits = yield* awaitSubscriptionLimits( + subscriptionLimitsFiber, + readCurrentSubscriptionLimits(), + ); return { contractVersion: USAGE_CONTRACT_VERSION, diff --git a/apps/server/src/usage/usageSubscriptionLimits.test.ts b/apps/server/src/usage/usageSubscriptionLimits.test.ts index 976f2c0faf6c..48fdeff6014d 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.test.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.test.ts @@ -275,7 +275,7 @@ describe("subscription usage limits", () => { expect(limits?.windows.map((window) => window.usedPercent)).toEqual([100, 0]); }); - it.effect("returns after five seconds while a slow provider probe keeps running", () => + it.effect("returns ready limits without waiting for a slow provider refresh", () => Effect.gen(function* () { const limits = { provider: "codex", @@ -289,19 +289,58 @@ describe("subscription usage limits", () => { }, ], } satisfies UsageProviderLimits; - const providerFiber = yield* Effect.sleep(Duration.seconds(10)).pipe( - Effect.as([limits] as readonly UsageProviderLimits[]), - Effect.forkScoped, + const providerFiber = yield* Effect.sleep(Duration.seconds(10)).pipe(Effect.forkScoped); + const result = yield* awaitSubscriptionLimits(providerFiber, Effect.succeed([limits])); + + expect(result).toEqual([limits]); + expect(providerFiber.pollUnsafe()).toBeUndefined(); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("returns after five seconds while a slow provider refresh keeps running", () => + Effect.gen(function* () { + const providerFiber = yield* Effect.sleep(Duration.seconds(10)).pipe(Effect.forkScoped); + const waitFiber = yield* awaitSubscriptionLimits(providerFiber, Effect.succeed([])).pipe( + Effect.forkChild, ); - const waitFiber = yield* awaitSubscriptionLimits(providerFiber).pipe(Effect.forkChild); yield* Effect.yieldNow; yield* TestClock.adjust(Duration.seconds(5)); - expect(yield* Fiber.join(waitFiber)).toEqual([]); yield* TestClock.adjust(Duration.seconds(5)); - expect(yield* Fiber.join(providerFiber)).toEqual([limits]); + expect(yield* Fiber.join(providerFiber)).toBeUndefined(); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("re-reads limits after the refresh budget", () => + Effect.gen(function* () { + let current: readonly UsageProviderLimits[] = []; + const limits = { + provider: "codex", + plan: "plus", + windows: [ + { + kind: "weekly", + usedPercent: 42, + resetsAt: null, + unlimited: false, + }, + ], + } satisfies UsageProviderLimits; + const providerFiber = yield* Effect.sleep(Duration.seconds(3)).pipe( + Effect.tap(() => Effect.sync(() => (current = [limits]))), + Effect.forkScoped, + ); + const waitFiber = yield* awaitSubscriptionLimits( + providerFiber, + Effect.sync(() => current), + ).pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.seconds(3)); + + expect(yield* Fiber.join(waitFiber)).toEqual([limits]); }).pipe(Effect.provide(TestClock.layer())), ); diff --git a/apps/server/src/usage/usageSubscriptionLimits.ts b/apps/server/src/usage/usageSubscriptionLimits.ts index c83de25b34c6..b865d1b8df5b 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.ts @@ -56,19 +56,20 @@ export const runSubscriptionLimitsProbe = Effect.fn("runSubscriptionLimitsProbe" ), ); -/** Bounds the page response without interrupting the service-owned probe fiber. */ -export const awaitSubscriptionLimits = Effect.fn("awaitSubscriptionLimits")( - (fiber: Fiber.Fiber) => - Fiber.join(fiber).pipe( - Effect.timeoutOption(SUBSCRIPTION_LIMITS_READ_BUDGET_MS), - Effect.map( - Option.match({ - onNone: (): readonly UsageProviderLimits[] => [], - onSome: (limits) => limits, - }), - ), - ), -); +/** Returns ready limits immediately, otherwise gives the background refresh a short budget. */ +export const awaitSubscriptionLimits = Effect.fn("awaitSubscriptionLimits")(function* ( + refreshFiber: Fiber.Fiber, + readCurrent: Effect.Effect, +) { + const ready = yield* readCurrent; + if (ready.length > 0) return ready; + + yield* Fiber.join(refreshFiber).pipe( + Effect.timeoutOption(SUBSCRIPTION_LIMITS_READ_BUDGET_MS), + Effect.asVoid, + ); + return yield* readCurrent; +}); export function makeSubscriptionLimitsCacheEntry( outcome: SubscriptionLimitsProbeOutcome, From 45b0207810445c232ef0c8a1e95184116f377927 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Wed, 26 Aug 2026 22:51:08 -0600 Subject: [PATCH 13/18] fix(usage): recover Codex limits from transcripts --- apps/server/src/usage/UsageService.ts | 17 +++++++++-------- .../src/usage/usageSubscriptionLimits.test.ts | 16 ++++++++++++++++ .../server/src/usage/usageSubscriptionLimits.ts | 11 +++++++++++ 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index f1c95ac35a43..6091ab2040cf 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -67,6 +67,7 @@ import { normalizeCodexSubscriptionLimits, readSubscriptionLimitsCacheEntry, runSubscriptionLimitsProbe, + shouldReadCodexTranscriptSnapshot, SUBSCRIPTION_LIMITS_SUCCESS_TTL_MS, type CodexTranscriptRateLimitsSnapshot, type SubscriptionLimitsCacheEntry, @@ -220,7 +221,7 @@ export const make = Effect.gen(function* () { // past the page response budget or its own freshness deadline. if ( codexSnapshot !== null && - readSubscriptionLimitsCacheEntry(subscriptionLimitsCache.get("codex"), now) === undefined + shouldReadCodexTranscriptSnapshot(subscriptionLimitsCache.get("codex"), now) ) { subscriptionLimitsCache.set( "codex", @@ -502,18 +503,18 @@ export const make = Effect.gen(function* () { ? [] : yield* Effect.promise(() => listTranscriptFiles(codexDir, windowStartMs)); if (codexDir !== undefined) prefetchedFiles.set(codexDir, codexFiles); - const codexLimitsCacheActive = - readSubscriptionLimitsCacheEntry(subscriptionLimitsCache.get("codex"), startedAtMs) !== - undefined; - const codexSnapshot = codexLimitsCacheActive - ? null - : yield* Effect.promise(() => + const codexSnapshot = shouldReadCodexTranscriptSnapshot( + subscriptionLimitsCache.get("codex"), + startedAtMs, + ) + ? yield* Effect.promise(() => readFreshCodexRateLimitsSnapshot( codexFiles, startedAtMs - SUBSCRIPTION_LIMITS_SUCCESS_TTL_MS, startedAtMs, ), - ); + ) + : null; const subscriptionLimitsFiber = yield* refreshSubscriptionLimits(codexSnapshot).pipe( // Subscription meters are optional. Provider payload drift must not make // transcript usage unavailable. diff --git a/apps/server/src/usage/usageSubscriptionLimits.test.ts b/apps/server/src/usage/usageSubscriptionLimits.test.ts index 48fdeff6014d..ce3e88f7fcc4 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.test.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.test.ts @@ -14,6 +14,7 @@ import { parseCodexTranscriptRateLimitsSnapshot, readSubscriptionLimitsCacheEntry, runSubscriptionLimitsProbe, + shouldReadCodexTranscriptSnapshot, } from "./usageSubscriptionLimits.ts"; describe("subscription usage limits", () => { @@ -388,6 +389,21 @@ describe("subscription usage limits", () => { expect(readSubscriptionLimitsCacheEntry(entry, 601_000)).toBeUndefined(); }); + it("checks Codex transcripts during probe backoff", () => { + const success = makeSubscriptionLimitsCacheEntry({ _tag: "Success", limits: null }, 1_000); + const failure = makeSubscriptionLimitsCacheEntry({ _tag: "Failure" }, 1_000); + const failureWithLastKnownGood = makeSubscriptionLimitsCacheEntry( + { _tag: "Failure" }, + 181_000, + success, + ); + + expect(shouldReadCodexTranscriptSnapshot(success, 1_001)).toBe(false); + expect(shouldReadCodexTranscriptSnapshot(success, 181_000)).toBe(true); + expect(shouldReadCodexTranscriptSnapshot(failure, 1_001)).toBe(true); + expect(shouldReadCodexTranscriptSnapshot(failureWithLastKnownGood, 181_001)).toBe(true); + }); + it("retains the last known good limits when a refresh fails", () => { const success = makeSubscriptionLimitsCacheEntry( { diff --git a/apps/server/src/usage/usageSubscriptionLimits.ts b/apps/server/src/usage/usageSubscriptionLimits.ts index b865d1b8df5b..5e78cc4af3a0 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.ts @@ -110,6 +110,17 @@ export function readSubscriptionLimitsCacheEntry( }; } +/** Probe failures back off subprocesses but must not suppress free local recovery. */ +export function shouldReadCodexTranscriptSnapshot( + entry: SubscriptionLimitsCacheEntry | undefined, + nowMs: number, +): boolean { + return ( + entry?.outcome._tag === "Failure" || + readSubscriptionLimitsCacheEntry(entry, nowMs) === undefined + ); +} + function stampSubscriptionLimits( limits: UsageProviderLimits | null, observedAtMs: number | undefined, From 419e3c5b41bc65a8a543afd040da66cb102a0344 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Thu, 27 Aug 2026 10:10:42 -0600 Subject: [PATCH 14/18] fix(usage): address post-merge review findings --- apps/server/src/usage/UsageService.ts | 12 ++++-- .../src/usage/usageSubscriptionLimits.test.ts | 11 ++++++ .../src/usage/usageSubscriptionLimits.ts | 3 +- .../src/components/usage/UsagePage.test.tsx | 39 ++++++++++++++++++- apps/web/src/components/usage/UsagePage.tsx | 11 +++++- 5 files changed, 68 insertions(+), 8 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index ea66188a9938..f7b7dfb4a29f 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -168,7 +168,7 @@ export const make = Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; const fixture = makeSubscriptionLimitsDevFixture( config.devUrl !== undefined, - process.env.T3CODE_DEV_USAGE_LIMITS_FIXTURE, + hostEnvironment.T3CODE_DEV_USAGE_LIMITS_FIXTURE, now, ); if (fixture !== null) return fixture; @@ -214,7 +214,7 @@ export const make = Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; const fixture = makeSubscriptionLimitsDevFixture( config.devUrl !== undefined, - process.env.T3CODE_DEV_USAGE_LIMITS_FIXTURE, + hostEnvironment.T3CODE_DEV_USAGE_LIMITS_FIXTURE, now, ); if (fixture !== null) return; @@ -272,7 +272,11 @@ export const make = Effect.gen(function* () { [ settings.providers.claudeAgent.enabled && cachedClaude === undefined ? runSubscriptionLimitsProbe( - probeClaudeUsage(settings.providers.claudeAgent, process.env, config.cwd).pipe( + probeClaudeUsage( + settings.providers.claudeAgent, + hostEnvironment, + config.cwd, + ).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ), @@ -281,7 +285,7 @@ export const make = Effect.gen(function* () { : Effect.void, settings.providers.codex.enabled && cachedCodex === undefined ? runSubscriptionLimitsProbe( - probeCodexRateLimits(codexProbeSettings, process.env, config.cwd).pipe( + probeCodexRateLimits(codexProbeSettings, hostEnvironment, config.cwd).pipe( Effect.provideService( ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner, diff --git a/apps/server/src/usage/usageSubscriptionLimits.test.ts b/apps/server/src/usage/usageSubscriptionLimits.test.ts index ce3e88f7fcc4..32781027d99b 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.test.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.test.ts @@ -169,6 +169,17 @@ describe("subscription usage limits", () => { ]); }); + it("normalizes blank Codex plan names to null", () => { + expect( + normalizeCodexSubscriptionLimits({ + rateLimits: { + planType: " ", + primary: { usedPercent: 44, windowDurationMins: 10_080, resetsAt: null }, + }, + })?.plan, + ).toBeNull(); + }); + it("classifies Codex windows within the upstream five-percent tolerance", () => { const limits = normalizeCodexSubscriptionLimits({ rateLimits: { diff --git a/apps/server/src/usage/usageSubscriptionLimits.ts b/apps/server/src/usage/usageSubscriptionLimits.ts index 5e78cc4af3a0..3bc1dd95f30d 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.ts @@ -400,9 +400,10 @@ export function normalizeCodexSubscriptionLimits( ].filter((window): window is UsageLimitWindow => window !== null); if (meteredWindows.length === 0) return null; + const plan = response.rateLimits.planType?.trim() ?? ""; return { provider: "codex", - plan: response.rateLimits.planType ?? null, + plan: plan.length > 0 ? plan : null, windows: meteredWindows, }; } diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index aa918ada0c3e..7a1d03b57731 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -74,6 +74,7 @@ vi.mock("./usageProviders", async (importOriginal) => { }); import { UsagePage } from "./UsagePage"; +import { PROVIDER_ORDER } from "./usageProviders"; const providerTotals = (codex: number, claude: number) => new Map([ @@ -187,6 +188,42 @@ describe("UsagePage model breakdown", () => { }); describe("UsagePage subscription limits", () => { + it("keeps provider marks keyed to their chart colors without quota meters", () => { + const current = testState.useUsage(); + testState.useUsage.mockReturnValue({ + ...current, + merged: { + ...current.merged, + providers: [ + { + provider: "codex", + costUsd: 12, + totalTokens: 2_000, + records: 2, + sessions: 1, + costShare: 0.6, + tokenShare: 0.6, + }, + { + provider: "claude", + costUsd: 8, + totalTokens: 1_000, + records: 1, + sessions: 1, + costShare: 0.4, + tokenShare: 0.4, + }, + ], + }, + }); + + const markup = renderToStaticMarkup(); + + expect(markup).toContain('style="color:white;fill:white"'); + expect(markup).toContain('style="color:orange;fill:orange"'); + expect(markup).not.toContain('role="progressbar"'); + }); + it("keeps provider share copy and adds compact quota meters", () => { testState.useUsage.mockReturnValue({ merged: { @@ -246,7 +283,7 @@ describe("UsagePage subscription limits", () => { const markup = renderToStaticMarkup(); - expect(markup.match(/grid-rows-\[auto_auto\]/g)).toHaveLength(4); + expect(markup.match(/grid-rows-\[auto_auto\]/g)).toHaveLength(PROVIDER_ORDER.length * 2); expect(markup).toContain("min-h-60 flex-1"); expect(markup).not.toContain("size-2 shrink-0 rounded-full bg-muted"); }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index a8be71721f08..34d31731ccc0 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -496,8 +496,15 @@ function ProviderMark({ readonly provider: UsageProviderKind; readonly className: string; }) { - const Mark = PROVIDER_PRESENTATION[provider].mark; - return ; + const presentation = PROVIDER_PRESENTATION[provider]; + const Mark = presentation.mark; + return ( + + ); } function usageLimitWindowLabel(window: UsageLimitWindow): string { From afe77ebb6d125f32e3c6a695b9511950fef273e5 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Thu, 27 Aug 2026 10:25:47 -0600 Subject: [PATCH 15/18] refactor(usage): remove unused unlimited state --- .../src/features/usage/UsageRouteScreen.tsx | 27 +++++--------- .../src/usage/usageSubscriptionLimits.test.ts | 25 +++---------- .../src/usage/usageSubscriptionLimits.ts | 3 -- .../src/components/usage/UsagePage.test.tsx | 7 ++-- apps/web/src/components/usage/UsagePage.tsx | 35 +++++++------------ packages/contracts/src/usage.ts | 2 -- packages/shared/src/usageMerge.test.ts | 6 ++-- 7 files changed, 30 insertions(+), 75 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index cac9764f0be4..d7f62ad53779 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -419,40 +419,31 @@ function UsageLimitMeters(props: { - {window.unlimited ? "∞" : `${Math.round(percent)}%`} + {Math.round(percent)}% - {window.unlimited - ? "No limit" - : countdown === "now" - ? "Reset due" - : countdown - ? `Resets in ${countdown}` - : "Reset time unavailable"} + {countdown === "now" + ? "Reset due" + : countdown + ? `Resets in ${countdown}` + : "Reset time unavailable"} ); diff --git a/apps/server/src/usage/usageSubscriptionLimits.test.ts b/apps/server/src/usage/usageSubscriptionLimits.test.ts index 32781027d99b..36de5bbb7991 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.test.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.test.ts @@ -37,14 +37,12 @@ describe("subscription usage limits", () => { label: "5h", usedPercent: 10.4, resetsAt: "2026-08-26T19:00:00.000Z", - unlimited: false, }, { kind: "weekly", label: "Week", usedPercent: 3, resetsAt: "2026-09-01T23:00:00.000Z", - unlimited: false, }, ], }); @@ -75,14 +73,12 @@ describe("subscription usage limits", () => { label: "Week", usedPercent: 3, resetsAt: "2026-09-01T23:00:00.000Z", - unlimited: false, }, { kind: "weekly:fable", label: "Fable", usedPercent: 95, resetsAt: "2026-09-01T23:00:00.000Z", - unlimited: false, }, ]); }); @@ -107,7 +103,6 @@ describe("subscription usage limits", () => { label: "Future Model", usedPercent: 72, resetsAt: "2026-09-01T23:00:00.000Z", - unlimited: false, }, ]); }); @@ -149,14 +144,13 @@ describe("subscription usage limits", () => { label: "5h", usedPercent: 42, resetsAt: "2026-08-29T10:40:00.000Z", - unlimited: false, }, - { kind: "weekly", label: "Week", usedPercent: 8, resetsAt: null, unlimited: false }, + { kind: "weekly", label: "Week", usedPercent: 8, resetsAt: null }, ], }); }); - it("does not invent an unlimited window from the Codex plan name", () => { + it("does not invent a five-hour window from the Codex plan name", () => { const limits = normalizeCodexSubscriptionLimits({ rateLimits: { planType: "pro", @@ -165,7 +159,7 @@ describe("subscription usage limits", () => { }); expect(limits?.windows).toEqual([ - { kind: "weekly", label: "Week", usedPercent: 44, resetsAt: null, unlimited: false }, + { kind: "weekly", label: "Week", usedPercent: 44, resetsAt: null }, ]); }); @@ -194,9 +188,8 @@ describe("subscription usage limits", () => { label: "Week", usedPercent: 44, resetsAt: null, - unlimited: false, }, - { kind: "monthly", label: "Month", usedPercent: 12, resetsAt: null, unlimited: false }, + { kind: "monthly", label: "Month", usedPercent: 12, resetsAt: null }, ]); expect( @@ -226,14 +219,12 @@ describe("subscription usage limits", () => { label: "Usage", usedPercent: 44, resetsAt: null, - unlimited: false, }, { kind: "codex:secondary", label: "Secondary", usedPercent: 12, resetsAt: null, - unlimited: false, }, ]); }); @@ -297,7 +288,6 @@ describe("subscription usage limits", () => { kind: "weekly", usedPercent: 42, resetsAt: null, - unlimited: false, }, ], } satisfies UsageProviderLimits; @@ -336,7 +326,6 @@ describe("subscription usage limits", () => { kind: "weekly", usedPercent: 42, resetsAt: null, - unlimited: false, }, ], } satisfies UsageProviderLimits; @@ -428,7 +417,6 @@ describe("subscription usage limits", () => { label: "Week", usedPercent: 57, resetsAt: null, - unlimited: false, }, ], }, @@ -448,7 +436,6 @@ describe("subscription usage limits", () => { label: "Week", usedPercent: 57, resetsAt: null, - unlimited: false, }, ], observedAt: "1970-01-01T00:00:01.000Z", @@ -471,7 +458,6 @@ describe("subscription usage limits", () => { label: "Week", usedPercent: 47, resetsAt: "2026-09-04T10:40:00.000Z", - unlimited: false, }, ], }, @@ -484,21 +470,18 @@ describe("subscription usage limits", () => { label: "5h", usedPercent: 68, resetsAt: "2026-08-29T12:40:00.000Z", - unlimited: false, }, { kind: "weekly", label: "Week", usedPercent: 32, resetsAt: "2026-09-03T10:40:00.000Z", - unlimited: false, }, { kind: "weekly:fable", label: "Fable", usedPercent: 91, resetsAt: "2026-09-02T10:40:00.000Z", - unlimited: false, }, ], }, diff --git a/apps/server/src/usage/usageSubscriptionLimits.ts b/apps/server/src/usage/usageSubscriptionLimits.ts index 3bc1dd95f30d..fc6c7217b94e 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.ts @@ -240,7 +240,6 @@ function claudeWindow( label, usedPercent: percent, resetsAt: window?.resets_at ?? null, - unlimited: false, }; } @@ -285,7 +284,6 @@ function readClaudeScopedWindows(value: unknown): readonly UsageLimitWindow[] { label: normalizedLabel, usedPercent: percent, resetsAt, - unlimited: false, }, ]; }); @@ -385,7 +383,6 @@ function codexWindow( label: presentation.label, usedPercent: percent, resetsAt, - unlimited: false, }; } diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 7a1d03b57731..66266d8e5985 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -246,12 +246,11 @@ describe("UsagePage subscription limits", () => { observedAt: "2026-08-26T20:00:00.000Z", stale: true, windows: [ - { kind: "fiveHour", usedPercent: 0, resetsAt: null, unlimited: true }, + { kind: "fiveHour", usedPercent: 0, resetsAt: null }, { kind: "weekly", usedPercent: 8, resetsAt: "2030-08-29T21:00:00.000Z", - unlimited: false, }, ], }, @@ -267,9 +266,7 @@ describe("UsagePage subscription limits", () => { expect(markup).toContain('aria-label="Codex 5h limit"'); expect(markup).toContain('aria-label="Codex Week limit"'); - expect(markup).toContain('aria-valuetext="Unlimited. No limit on this plan."'); - expect(markup).toContain("∞"); - expect(markup).toContain("No limit"); + expect(markup).toContain('aria-valuetext="0% used."'); expect(markup).toContain("Resets in"); expect(markup).toContain("Limits last updated"); expect(markup).toContain("Aug 29, 9:00 PM"); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 34d31731ccc0..f8fe4f431c27 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -543,9 +543,7 @@ function UsageLimitMeters({ : null; const label = usageLimitWindowLabel(window); const resetText = reset ? ` Resets ${reset}.` : ""; - const usageText = window.unlimited - ? "Unlimited. No limit on this plan." - : `${Math.round(percent)}% used.${resetText}`; + const usageText = `${Math.round(percent)}% used.${resetText}`; return ( }> @@ -557,41 +555,32 @@ function UsageLimitMeters({ aria-label={`${providerLabel} ${label} limit`} aria-valuemin={0} aria-valuemax={100} - aria-valuenow={window.unlimited ? undefined : Math.round(percent)} + aria-valuenow={Math.round(percent)} aria-valuetext={usageText} className="col-start-2 row-start-1 h-1 overflow-hidden rounded-full bg-muted" > - - {window.unlimited ? "∞" : `${Math.round(percent)}%`} + + {Math.round(percent)}% - {window.unlimited - ? "No limit" - : countdown === "now" - ? "Reset due" - : countdown - ? `Resets in ${countdown}` - : "Reset time unavailable"} + {countdown === "now" + ? "Reset due" + : countdown + ? `Resets in ${countdown}` + : "Reset time unavailable"} - {providerLabel} {label}:{" "} - {window.unlimited ? "Unlimited" : `${Math.round(percent)}% used`} - {!window.unlimited && reset ? ` · Resets ${reset}` : null} + {providerLabel} {label}: {Math.round(percent)}% used + {reset ? ` · Resets ${reset}` : null} ); diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index f6e98183e380..aa431fa21e8d 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -181,8 +181,6 @@ export const UsageLimitWindow = Schema.Struct({ label: Schema.optionalKey(TrimmedNonEmptyString), usedPercent: Schema.Number, resetsAt: Schema.NullOr(Schema.String), - /** True when this plan has no cap for the window. */ - unlimited: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), }); export type UsageLimitWindow = typeof UsageLimitWindow.Type; diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 8d567f21dee4..4b3f7080128b 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -318,7 +318,7 @@ describe("mergeUsage", () => { { provider: "codex", plan: "plus", - windows: [{ kind: "fiveHour", usedPercent: 20, resetsAt: null, unlimited: false }], + windows: [{ kind: "fiveHour", usedPercent: 20, resetsAt: null }], }, ], }), @@ -329,7 +329,7 @@ describe("mergeUsage", () => { { provider: "codex", plan: "pro", - windows: [{ kind: "fiveHour", usedPercent: 35, resetsAt: null, unlimited: false }], + windows: [{ kind: "fiveHour", usedPercent: 35, resetsAt: null }], }, ], }), @@ -341,7 +341,7 @@ describe("mergeUsage", () => { { provider: "codex", plan: "pro", - windows: [{ kind: "fiveHour", usedPercent: 35, resetsAt: null, unlimited: false }], + windows: [{ kind: "fiveHour", usedPercent: 35, resetsAt: null }], }, ]); }); From 67b7533d1f445752001de7f571311296915d83b2 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Thu, 27 Aug 2026 10:52:40 -0600 Subject: [PATCH 16/18] fix(usage): scope limit cache by account --- apps/server/src/usage/UsageService.ts | 195 +++++++++++------- .../src/usage/usageSubscriptionLimits.test.ts | 25 +++ .../src/usage/usageSubscriptionLimits.ts | 18 ++ 3 files changed, 161 insertions(+), 77 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index f7b7dfb4a29f..552c0444b58f 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -15,6 +15,8 @@ import * as NodeOS from "node:os"; import { USAGE_CONTRACT_VERSION, + type ClaudeSettings, + type CodexSettings, type UsageProviderKind, type UsageSource, type UsageSummary, @@ -63,6 +65,7 @@ import { import type { UsageRecord } from "./usageTranscripts.ts"; import { awaitSubscriptionLimits, + makeSubscriptionLimitsCacheKey, makeSubscriptionLimitsCacheEntry, makeSubscriptionLimitsDevFixture, normalizeClaudeSubscriptionLimits, @@ -141,6 +144,20 @@ export const layerTest = Layer.succeed( }), ); +interface SubscriptionLimitsContext { + readonly claude: { + readonly enabled: boolean; + readonly cacheKey: string; + readonly settings: ClaudeSettings; + }; + readonly codex: { + readonly enabled: boolean; + readonly cacheKey: string; + readonly settings: CodexSettings; + readonly canUseTranscriptSnapshot: boolean; + }; +} + export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -161,10 +178,10 @@ export const make = Effect.gen(function* () { let rates: RateTable = new Map(); let ratesFetchedAtMs: number | null = null; let ratesStatus: UsageSummary["pricing"]["status"] = "unavailable"; - const subscriptionLimitsCache = new Map(); + const subscriptionLimitsCache = new Map(); const readCurrentSubscriptionLimits = Effect.fn("UsageService.readCurrentSubscriptionLimits")( - function* () { + function* (context: SubscriptionLimitsContext) { const now = yield* Clock.currentTimeMillis; const fixture = makeSubscriptionLimitsDevFixture( config.devUrl !== undefined, @@ -173,20 +190,11 @@ export const make = Effect.gen(function* () { ); if (fixture !== null) return fixture; - const settings = yield* settingsService.getSettings.pipe( - Effect.catchCause(() => Effect.succeed(null)), - ); - const providers: readonly UsageProviderKind[] = - settings === null - ? ["codex", "claude"] - : [ - ...(settings.providers.codex.enabled ? (["codex"] as const) : []), - ...(settings.providers.claudeAgent.enabled ? (["claude"] as const) : []), - ]; - - return providers.flatMap((provider) => { + const providers = [context.codex, context.claude].filter(({ enabled }) => enabled); + + return providers.flatMap(({ cacheKey }) => { const outcome = readSubscriptionLimitsCacheEntry( - subscriptionLimitsCache.get(provider), + subscriptionLimitsCache.get(cacheKey), now, ); return outcome?._tag === "Success" && outcome.limits !== null ? [outcome.limits] : []; @@ -195,20 +203,21 @@ export const make = Effect.gen(function* () { ); const cacheSubscriptionLimitsProbe = Effect.fn("UsageService.cacheSubscriptionLimitsProbe")( - function* (provider: UsageProviderKind, outcome: SubscriptionLimitsProbeOutcome) { + function* (cacheKey: string, outcome: SubscriptionLimitsProbeOutcome) { const fetchedAtMs = yield* Clock.currentTimeMillis; subscriptionLimitsCache.set( - provider, + cacheKey, makeSubscriptionLimitsCacheEntry( outcome, fetchedAtMs, - subscriptionLimitsCache.get(provider), + subscriptionLimitsCache.get(cacheKey), ), ); }, ); const refreshSubscriptionLimits = Effect.fn("UsageService.refreshSubscriptionLimits")(function* ( + context: SubscriptionLimitsContext, codexSnapshot: CodexTranscriptRateLimitsSnapshot | null, ) { const now = yield* Clock.currentTimeMillis; @@ -223,18 +232,20 @@ export const make = Effect.gen(function* () { // it before taking the probe lock so a slow Claude refresh cannot hold it // past the page response budget or its own freshness deadline. if ( + context.codex.enabled && + context.codex.canUseTranscriptSnapshot && codexSnapshot !== null && - shouldReadCodexTranscriptSnapshot(subscriptionLimitsCache.get("codex"), now) + shouldReadCodexTranscriptSnapshot(subscriptionLimitsCache.get(context.codex.cacheKey), now) ) { subscriptionLimitsCache.set( - "codex", + context.codex.cacheKey, makeSubscriptionLimitsCacheEntry( { _tag: "Success", limits: normalizeCodexSubscriptionLimits(codexSnapshot.response), }, now, - subscriptionLimitsCache.get("codex"), + subscriptionLimitsCache.get(context.codex.cacheKey), codexSnapshot.observedAtMs, ), ); @@ -244,55 +255,47 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const probeStartedAtMs = yield* Clock.currentTimeMillis; - const cachedOutcomes = new Map(); - for (const [provider, cached] of subscriptionLimitsCache) { - const outcome = readSubscriptionLimitsCacheEntry(cached, probeStartedAtMs); - if (outcome !== undefined) cachedOutcomes.set(provider, outcome); - } - - const settings = yield* settingsService.getSettings.pipe( - Effect.catchCause(() => Effect.succeed(null)), - ); - if (settings === null) return; - - const cachedClaude = settings.providers.claudeAgent.enabled - ? cachedOutcomes.get("claude") + const cachedClaude = context.claude.enabled + ? readSubscriptionLimitsCacheEntry( + subscriptionLimitsCache.get(context.claude.cacheKey), + probeStartedAtMs, + ) : undefined; - const cachedCodex = settings.providers.codex.enabled - ? cachedOutcomes.get("codex") + const cachedCodex = context.codex.enabled + ? readSubscriptionLimitsCacheEntry( + subscriptionLimitsCache.get(context.codex.cacheKey), + probeStartedAtMs, + ) : undefined; - const codexHomeLayout = yield* resolveCodexHomeLayout(settings.providers.codex).pipe( - Effect.provideService(Path.Path, path), - ); - const codexProbeSettings = { - ...settings.providers.codex, - homePath: codexHomeLayout.effectiveHomePath ?? "", - }; yield* Effect.all( [ - settings.providers.claudeAgent.enabled && cachedClaude === undefined + context.claude.enabled && cachedClaude === undefined ? runSubscriptionLimitsProbe( - probeClaudeUsage( - settings.providers.claudeAgent, - hostEnvironment, - config.cwd, - ).pipe( + probeClaudeUsage(context.claude.settings, hostEnvironment, config.cwd).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ), normalizeClaudeSubscriptionLimits, - ).pipe(Effect.flatMap((outcome) => cacheSubscriptionLimitsProbe("claude", outcome))) + ).pipe( + Effect.flatMap((outcome) => + cacheSubscriptionLimitsProbe(context.claude.cacheKey, outcome), + ), + ) : Effect.void, - settings.providers.codex.enabled && cachedCodex === undefined + context.codex.enabled && cachedCodex === undefined ? runSubscriptionLimitsProbe( - probeCodexRateLimits(codexProbeSettings, hostEnvironment, config.cwd).pipe( + probeCodexRateLimits(context.codex.settings, hostEnvironment, config.cwd).pipe( Effect.provideService( ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner, ), ), normalizeCodexSubscriptionLimits, - ).pipe(Effect.flatMap((outcome) => cacheSubscriptionLimitsProbe("codex", outcome))) + ).pipe( + Effect.flatMap((outcome) => + cacheSubscriptionLimitsProbe(context.codex.cacheKey, outcome), + ), + ) : Effect.void, ], { concurrency: "unbounded" }, @@ -394,15 +397,46 @@ export const make = Effect.gen(function* () { ? path.resolve(expandHomePath(grokHomeEnv)) : path.join(NodeOS.homedir(), ".grok"); - return [ - { provider: "claude" as const, dir: claudeDir }, - { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") }, - { - provider: "grok" as const, - dir: path.join(grokHome, "sessions"), - fileName: "updates.jsonl", - }, - ]; + const codexProbeSettings = { + ...settings.providers.codex, + homePath: codexLayout.effectiveHomePath ?? "", + }; + + return { + dirs: [ + { provider: "claude" as const, dir: claudeDir }, + { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") }, + { + provider: "grok" as const, + dir: path.join(grokHome, "sessions"), + fileName: "updates.jsonl", + }, + ], + subscriptionLimits: { + claude: { + enabled: settings.providers.claudeAgent.enabled, + cacheKey: makeSubscriptionLimitsCacheKey({ + provider: "claude", + binaryPath: settings.providers.claudeAgent.binaryPath, + homePath: claudeHome, + }), + settings: settings.providers.claudeAgent, + }, + codex: { + enabled: settings.providers.codex.enabled, + cacheKey: makeSubscriptionLimitsCacheKey({ + provider: "codex", + binaryPath: settings.providers.codex.binaryPath, + homePath: codexLayout.effectiveHomePath ?? codexLayout.sharedHomePath, + launchArgs: settings.providers.codex.launchArgs, + }), + settings: codexProbeSettings, + // Shadow homes share transcripts while keeping credentials separate, + // so a transcript snapshot cannot identify the active account there. + canUseTranscriptSnapshot: codexLayout.mode === "direct", + }, + } satisfies SubscriptionLimitsContext, + }; }); /** @@ -505,7 +539,10 @@ export const make = Effect.gen(function* () { const startedAtMs = yield* Clock.currentTimeMillis; // The home resolvers ask for `Path` themselves; satisfy them from the // instance we already hold so `readSummary` stays context-free. - const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); + const resolvedUsage = yield* resolveTranscriptDirs().pipe( + Effect.provideService(Path.Path, path), + ); + const { dirs, subscriptionLimits: subscriptionLimitsContext } = resolvedUsage; const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); if (Option.isNone(windowStart)) { return yield* new UsageReadError({ @@ -522,19 +559,23 @@ export const make = Effect.gen(function* () { ? [] : yield* Effect.promise(() => listTranscriptFiles(codexDir, windowStartMs)); if (codexDir !== undefined) prefetchedFiles.set(codexDir, codexFiles); - const codexSnapshot = shouldReadCodexTranscriptSnapshot( - subscriptionLimitsCache.get("codex"), - startedAtMs, - ) - ? yield* Effect.promise(() => - readFreshCodexRateLimitsSnapshot( - codexFiles, - startedAtMs - SUBSCRIPTION_LIMITS_SUCCESS_TTL_MS, - startedAtMs, - ), - ) - : null; - const subscriptionLimitsFiber = yield* refreshSubscriptionLimits(codexSnapshot).pipe( + const codexSnapshot = + shouldReadCodexTranscriptSnapshot( + subscriptionLimitsCache.get(subscriptionLimitsContext.codex.cacheKey), + startedAtMs, + ) && subscriptionLimitsContext.codex.canUseTranscriptSnapshot + ? yield* Effect.promise(() => + readFreshCodexRateLimitsSnapshot( + codexFiles, + startedAtMs - SUBSCRIPTION_LIMITS_SUCCESS_TTL_MS, + startedAtMs, + ), + ) + : null; + const subscriptionLimitsFiber = yield* refreshSubscriptionLimits( + subscriptionLimitsContext, + codexSnapshot, + ).pipe( // Subscription meters are optional. Provider payload drift must not make // transcript usage unavailable. Effect.catchCause(() => Effect.void), @@ -636,7 +677,7 @@ export const make = Effect.gen(function* () { const finishedAtMs = yield* Clock.currentTimeMillis; const subscriptionLimits = yield* awaitSubscriptionLimits( subscriptionLimitsFiber, - readCurrentSubscriptionLimits(), + readCurrentSubscriptionLimits(subscriptionLimitsContext), ); return { diff --git a/apps/server/src/usage/usageSubscriptionLimits.test.ts b/apps/server/src/usage/usageSubscriptionLimits.test.ts index 36de5bbb7991..8f3ce4b76fbc 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.test.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.test.ts @@ -7,6 +7,7 @@ import * as TestClock from "effect/testing/TestClock"; import { awaitSubscriptionLimits, + makeSubscriptionLimitsCacheKey, makeSubscriptionLimitsCacheEntry, makeSubscriptionLimitsDevFixture, normalizeClaudeSubscriptionLimits, @@ -18,6 +19,30 @@ import { } from "./usageSubscriptionLimits.ts"; describe("subscription usage limits", () => { + it("isolates cached limits by provider runtime and account home", () => { + const claudeAccount = makeSubscriptionLimitsCacheKey({ + provider: "claude", + binaryPath: "claude", + homePath: "/accounts/claude-a", + }); + const cache = new Map([[claudeAccount, "account-a"]]); + + const otherClaudeAccount = makeSubscriptionLimitsCacheKey({ + provider: "claude", + binaryPath: "claude", + homePath: "/accounts/claude-b", + }); + expect(cache.get(otherClaudeAccount)).toBeUndefined(); + expect( + makeSubscriptionLimitsCacheKey({ + provider: "codex", + binaryPath: "codex", + homePath: "/accounts/claude-a", + launchArgs: "--config profile=work", + }), + ).not.toBe(claudeAccount); + }); + it("normalizes Claude's five-hour and weekly windows", () => { const limits = normalizeClaudeSubscriptionLimits({ subscription_type: "max", diff --git a/apps/server/src/usage/usageSubscriptionLimits.ts b/apps/server/src/usage/usageSubscriptionLimits.ts index fc6c7217b94e..9a4333880f83 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.ts @@ -2,6 +2,7 @@ import type { SDKControlGetUsageResponse } from "@anthropic-ai/claude-agent-sdk" import type { UsageLimitWindow, UsageLimitWindowKind, + UsageProviderKind, UsageProviderLimits, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -39,6 +40,23 @@ export interface SubscriptionLimitsCacheEntry { }; } +export interface SubscriptionLimitsCacheIdentity { + readonly provider: UsageProviderKind; + readonly binaryPath: string; + readonly homePath: string; + readonly launchArgs?: string; +} + +/** Keeps cached quota data scoped to the provider runtime and account home that produced it. */ +export function makeSubscriptionLimitsCacheKey(identity: SubscriptionLimitsCacheIdentity): string { + return JSON.stringify([ + identity.provider, + identity.binaryPath, + identity.homePath, + identity.launchArgs ?? null, + ]); +} + const subscriptionLimitsProbeFailure = { _tag: "Failure" } as const; /** Tags provider responses so an empty response is distinct from a failed probe. */ From 61b1e0eab54c7361a24ccacbcb41e88b5a557268 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Thu, 27 Aug 2026 11:33:21 -0600 Subject: [PATCH 17/18] fix(usage): honor inherited provider homes --- apps/server/src/usage/UsageService.ts | 33 ++++++++++++--- .../src/usage/usageSubscriptionLimits.test.ts | 41 +++++++++++++++++-- .../src/usage/usageSubscriptionLimits.ts | 31 +++++++++++++- 3 files changed, 95 insertions(+), 10 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 552c0444b58f..9396ff513c8c 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -68,6 +68,7 @@ import { makeSubscriptionLimitsCacheKey, makeSubscriptionLimitsCacheEntry, makeSubscriptionLimitsDevFixture, + makeSubscriptionLimitsHomeIdentity, normalizeClaudeSubscriptionLimits, normalizeCodexSubscriptionLimits, readSubscriptionLimitsCacheEntry, @@ -401,6 +402,27 @@ export const make = Effect.gen(function* () { ...settings.providers.codex, homePath: codexLayout.effectiveHomePath ?? "", }; + const claudeHomeIdentity = makeSubscriptionLimitsHomeIdentity({ + ...(settings.providers.claudeAgent.homePath.trim().length > 0 + ? { configuredHomePath: claudeHome } + : {}), + environmentVariable: "CLAUDE_CONFIG_DIR", + environmentHomePath: hostEnvironment.CLAUDE_CONFIG_DIR, + defaultHomePath: path.join(NodeOS.homedir(), ".claude"), + cwd: config.cwd, + }); + const codexHomeIdentity = makeSubscriptionLimitsHomeIdentity({ + ...(codexLayout.effectiveHomePath === undefined + ? {} + : { configuredHomePath: codexLayout.effectiveHomePath }), + environmentVariable: "CODEX_HOME", + environmentHomePath: hostEnvironment.CODEX_HOME, + defaultHomePath: codexLayout.sharedHomePath, + cwd: config.cwd, + }); + const codexInheritsHome = + codexLayout.effectiveHomePath === undefined && + (hostEnvironment.CODEX_HOME?.trim().length ?? 0) > 0; return { dirs: [ @@ -418,7 +440,7 @@ export const make = Effect.gen(function* () { cacheKey: makeSubscriptionLimitsCacheKey({ provider: "claude", binaryPath: settings.providers.claudeAgent.binaryPath, - homePath: claudeHome, + homeIdentity: claudeHomeIdentity, }), settings: settings.providers.claudeAgent, }, @@ -427,13 +449,14 @@ export const make = Effect.gen(function* () { cacheKey: makeSubscriptionLimitsCacheKey({ provider: "codex", binaryPath: settings.providers.codex.binaryPath, - homePath: codexLayout.effectiveHomePath ?? codexLayout.sharedHomePath, + homeIdentity: codexHomeIdentity, launchArgs: settings.providers.codex.launchArgs, }), settings: codexProbeSettings, - // Shadow homes share transcripts while keeping credentials separate, - // so a transcript snapshot cannot identify the active account there. - canUseTranscriptSnapshot: codexLayout.mode === "direct", + // Shadow homes share transcripts while keeping credentials separate. + // An inherited CODEX_HOME can also differ from the default transcript + // root resolved above, so neither snapshot identifies the probe account. + canUseTranscriptSnapshot: codexLayout.mode === "direct" && !codexInheritsHome, }, } satisfies SubscriptionLimitsContext, }; diff --git a/apps/server/src/usage/usageSubscriptionLimits.test.ts b/apps/server/src/usage/usageSubscriptionLimits.test.ts index 8f3ce4b76fbc..045701120bc8 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.test.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.test.ts @@ -10,6 +10,7 @@ import { makeSubscriptionLimitsCacheKey, makeSubscriptionLimitsCacheEntry, makeSubscriptionLimitsDevFixture, + makeSubscriptionLimitsHomeIdentity, normalizeClaudeSubscriptionLimits, normalizeCodexSubscriptionLimits, parseCodexTranscriptRateLimitsSnapshot, @@ -23,26 +24,60 @@ describe("subscription usage limits", () => { const claudeAccount = makeSubscriptionLimitsCacheKey({ provider: "claude", binaryPath: "claude", - homePath: "/accounts/claude-a", + homeIdentity: "/accounts/claude-a", }); const cache = new Map([[claudeAccount, "account-a"]]); const otherClaudeAccount = makeSubscriptionLimitsCacheKey({ provider: "claude", binaryPath: "claude", - homePath: "/accounts/claude-b", + homeIdentity: "/accounts/claude-b", }); expect(cache.get(otherClaudeAccount)).toBeUndefined(); expect( makeSubscriptionLimitsCacheKey({ provider: "codex", binaryPath: "codex", - homePath: "/accounts/claude-a", + homeIdentity: "/accounts/claude-a", launchArgs: "--config profile=work", }), ).not.toBe(claudeAccount); }); + it("includes inherited provider homes in the cache identity", () => { + const inheritedClaude = makeSubscriptionLimitsHomeIdentity({ + environmentVariable: "CLAUDE_CONFIG_DIR", + environmentHomePath: "/accounts/claude-work", + defaultHomePath: "/users/test/.claude", + cwd: "/workspace", + }); + const defaultClaude = makeSubscriptionLimitsHomeIdentity({ + environmentVariable: "CLAUDE_CONFIG_DIR", + environmentHomePath: undefined, + defaultHomePath: "/users/test/.claude", + cwd: "/workspace", + }); + const inheritedCodex = makeSubscriptionLimitsHomeIdentity({ + environmentVariable: "CODEX_HOME", + environmentHomePath: ".codex-work", + defaultHomePath: "/users/test/.codex", + cwd: "/workspace", + }); + + expect(inheritedClaude).not.toBe(defaultClaude); + expect(inheritedCodex).toContain(".codex-work"); + expect(inheritedCodex).toContain("/workspace"); + expect( + makeSubscriptionLimitsHomeIdentity({ + configuredHomePath: "/accounts/explicit", + environmentVariable: "CODEX_HOME", + environmentHomePath: "/accounts/inherited", + defaultHomePath: "/users/test/.codex", + cwd: "/workspace", + }), + ).toBe('["configured","/accounts/explicit"]'); + }); + it("normalizes Claude's five-hour and weekly windows", () => { const limits = normalizeClaudeSubscriptionLimits({ subscription_type: "max", diff --git a/apps/server/src/usage/usageSubscriptionLimits.ts b/apps/server/src/usage/usageSubscriptionLimits.ts index 9a4333880f83..9f7797d8a37d 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.ts @@ -43,16 +43,43 @@ export interface SubscriptionLimitsCacheEntry { export interface SubscriptionLimitsCacheIdentity { readonly provider: UsageProviderKind; readonly binaryPath: string; - readonly homePath: string; + readonly homeIdentity: string; readonly launchArgs?: string; } +interface SubscriptionLimitsHomeIdentityInput { + readonly configuredHomePath?: string; + readonly environmentVariable: "CLAUDE_CONFIG_DIR" | "CODEX_HOME"; + readonly environmentHomePath: string | undefined; + readonly defaultHomePath: string; + readonly cwd: string; +} + +/** Mirrors the home precedence inherited by a provider probe without rewriting its environment. */ +export function makeSubscriptionLimitsHomeIdentity( + input: SubscriptionLimitsHomeIdentityInput, +): string { + const configuredHomePath = input.configuredHomePath?.trim() ?? ""; + if (configuredHomePath.length > 0) { + return JSON.stringify(["configured", configuredHomePath]); + } + + const environmentHomePath = input.environmentHomePath?.trim() ?? ""; + if (environmentHomePath.length > 0) { + // Relative environment paths are resolved by the child from its cwd. Keep + // both values in the identity instead of changing what the probe receives. + return JSON.stringify([input.environmentVariable, environmentHomePath, input.cwd]); + } + + return JSON.stringify(["default", input.defaultHomePath]); +} + /** Keeps cached quota data scoped to the provider runtime and account home that produced it. */ export function makeSubscriptionLimitsCacheKey(identity: SubscriptionLimitsCacheIdentity): string { return JSON.stringify([ identity.provider, identity.binaryPath, - identity.homePath, + identity.homeIdentity, identity.launchArgs ?? null, ]); } From c902fe16ed5fa1c834b1f84452e1616d3d3c90fe Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Thu, 27 Aug 2026 11:52:36 -0600 Subject: [PATCH 18/18] fix(usage): read Codex rate limits from real rollout shape The transcript snapshot parser required `payload.info.rate_limits`, but codex-rs persists `rate_limits` as a sibling of `info`, so no real rollout ever matched and every cold read still spawned the app-server probe. Read the real shape and ignore model-scoped buckets (any `limit_id` other than `codex`, or absent on older builds) on both the transcript and probe paths so a Spark turn cannot present its windows as the account limit. Only short-circuit the page's wait when every enabled provider has settled, so a fast Codex snapshot no longer ships the page without Claude's meters. Verified against live rollouts in ~/.codex/sessions: the account line parses and the codex_bengalfox line is rejected. --- apps/server/src/usage/UsageService.ts | 19 ++-- .../src/usage/usageSubscriptionLimits.test.ts | 94 ++++++++++++++++--- .../src/usage/usageSubscriptionLimits.ts | 44 ++++++--- .../src/usage/usageTranscriptReader.test.ts | 30 +++--- 4 files changed, 141 insertions(+), 46 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 9396ff513c8c..aa0a8a239c9f 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -189,17 +189,18 @@ export const make = Effect.gen(function* () { hostEnvironment.T3CODE_DEV_USAGE_LIMITS_FIXTURE, now, ); - if (fixture !== null) return fixture; + if (fixture !== null) return { limits: fixture, settled: true }; const providers = [context.codex, context.claude].filter(({ enabled }) => enabled); - - return providers.flatMap(({ cacheKey }) => { - const outcome = readSubscriptionLimitsCacheEntry( - subscriptionLimitsCache.get(cacheKey), - now, - ); - return outcome?._tag === "Success" && outcome.limits !== null ? [outcome.limits] : []; - }); + const outcomes = providers.map(({ cacheKey }) => + readSubscriptionLimitsCacheEntry(subscriptionLimitsCache.get(cacheKey), now), + ); + return { + limits: outcomes.flatMap((outcome) => + outcome?._tag === "Success" && outcome.limits !== null ? [outcome.limits] : [], + ), + settled: outcomes.every((outcome) => outcome !== undefined), + }; }, ); diff --git a/apps/server/src/usage/usageSubscriptionLimits.test.ts b/apps/server/src/usage/usageSubscriptionLimits.test.ts index 045701120bc8..54f91c7ff307 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.test.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.test.ts @@ -297,16 +297,16 @@ describe("subscription usage limits", () => { type: "event_msg", payload: { type: "token_count", - info: { - rate_limits: { - primary: null, - secondary: { - used_percent: 57, - window_minutes: 10_080, - resets_at: 1_788_000_000, - }, - plan_type: "prolite", + info: null, + rate_limits: { + limit_id: "codex", + primary: null, + secondary: { + used_percent: 57, + window_minutes: 10_080, + resets_at: 1_788_000_000, }, + plan_type: "prolite", }, }, }), @@ -315,6 +315,7 @@ describe("subscription usage limits", () => { observedAtMs: Date.parse("2026-08-27T02:10:00.000Z"), response: { rateLimits: { + limitId: "codex", planType: "prolite", primary: null, secondary: { @@ -327,6 +328,38 @@ describe("subscription usage limits", () => { }); }); + it("ignores model-scoped Codex limit buckets", () => { + const line = (limitId: string | null | undefined) => + JSON.stringify({ + timestamp: "2026-08-27T02:10:00.000Z", + type: "event_msg", + payload: { + type: "token_count", + rate_limits: { + ...(limitId === undefined ? {} : { limit_id: limitId }), + primary: { used_percent: 0, window_minutes: 300, resets_at: 1_788_000_000 }, + secondary: null, + plan_type: "prolite", + }, + }, + }); + + expect(parseCodexTranscriptRateLimitsSnapshot(line("codex_bengalfox"))).toBeNull(); + expect(parseCodexTranscriptRateLimitsSnapshot(line("codex"))).not.toBeNull(); + expect(parseCodexTranscriptRateLimitsSnapshot(line(null))).not.toBeNull(); + expect(parseCodexTranscriptRateLimitsSnapshot(line(undefined))).not.toBeNull(); + + expect( + normalizeCodexSubscriptionLimits({ + rateLimits: { + limitId: "codex_bengalfox", + planType: "prolite", + primary: { usedPercent: 0, windowDurationMins: 300, resetsAt: 1_788_000_000 }, + }, + }), + ).toBeNull(); + }); + it("clamps provider percentages to the progress bar range", () => { const limits = normalizeCodexSubscriptionLimits({ rateLimits: { @@ -352,7 +385,10 @@ describe("subscription usage limits", () => { ], } satisfies UsageProviderLimits; const providerFiber = yield* Effect.sleep(Duration.seconds(10)).pipe(Effect.forkScoped); - const result = yield* awaitSubscriptionLimits(providerFiber, Effect.succeed([limits])); + const result = yield* awaitSubscriptionLimits( + providerFiber, + Effect.succeed({ limits: [limits], settled: true }), + ); expect(result).toEqual([limits]); expect(providerFiber.pollUnsafe()).toBeUndefined(); @@ -362,9 +398,10 @@ describe("subscription usage limits", () => { it.effect("returns after five seconds while a slow provider refresh keeps running", () => Effect.gen(function* () { const providerFiber = yield* Effect.sleep(Duration.seconds(10)).pipe(Effect.forkScoped); - const waitFiber = yield* awaitSubscriptionLimits(providerFiber, Effect.succeed([])).pipe( - Effect.forkChild, - ); + const waitFiber = yield* awaitSubscriptionLimits( + providerFiber, + Effect.succeed({ limits: [], settled: false }), + ).pipe(Effect.forkChild); yield* Effect.yieldNow; yield* TestClock.adjust(Duration.seconds(5)); @@ -395,7 +432,7 @@ describe("subscription usage limits", () => { ); const waitFiber = yield* awaitSubscriptionLimits( providerFiber, - Effect.sync(() => current), + Effect.sync(() => ({ limits: current, settled: current.length > 0 })), ).pipe(Effect.forkChild); yield* Effect.yieldNow; @@ -405,6 +442,35 @@ describe("subscription usage limits", () => { }).pipe(Effect.provide(TestClock.layer())), ); + it.effect("waits for the slower provider when only one provider is ready", () => + Effect.gen(function* () { + const codex = { + provider: "codex", + plan: "plus", + windows: [{ kind: "weekly", usedPercent: 42, resetsAt: null }], + } satisfies UsageProviderLimits; + const claude = { + provider: "claude", + plan: "max", + windows: [{ kind: "fiveHour", usedPercent: 7, resetsAt: null }], + } satisfies UsageProviderLimits; + let current: readonly UsageProviderLimits[] = [codex]; + const providerFiber = yield* Effect.sleep(Duration.seconds(2)).pipe( + Effect.tap(() => Effect.sync(() => (current = [codex, claude]))), + Effect.forkScoped, + ); + const waitFiber = yield* awaitSubscriptionLimits( + providerFiber, + Effect.sync(() => ({ limits: current, settled: current.length === 2 })), + ).pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.seconds(2)); + + expect(yield* Fiber.join(waitFiber)).toEqual([codex, claude]); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("distinguishes a successful empty response from a failed probe", () => Effect.gen(function* () { const [emptyOutcome, failedOutcome] = yield* Effect.all([ diff --git a/apps/server/src/usage/usageSubscriptionLimits.ts b/apps/server/src/usage/usageSubscriptionLimits.ts index 9f7797d8a37d..a2f6f0f7fd92 100644 --- a/apps/server/src/usage/usageSubscriptionLimits.ts +++ b/apps/server/src/usage/usageSubscriptionLimits.ts @@ -101,19 +101,29 @@ export const runSubscriptionLimitsProbe = Effect.fn("runSubscriptionLimitsProbe" ), ); -/** Returns ready limits immediately, otherwise gives the background refresh a short budget. */ +export interface SubscriptionLimitsReadResult { + readonly limits: readonly UsageProviderLimits[]; + /** True once every enabled provider has a live cache entry, success or failure. */ + readonly settled: boolean; +} + +/** + * Returns limits immediately once every enabled provider has settled, otherwise + * gives the background refresh a short budget so a fast provider does not + * ship the page without the slower one's meters. + */ export const awaitSubscriptionLimits = Effect.fn("awaitSubscriptionLimits")(function* ( refreshFiber: Fiber.Fiber, - readCurrent: Effect.Effect, + readCurrent: Effect.Effect, ) { const ready = yield* readCurrent; - if (ready.length > 0) return ready; + if (ready.settled) return ready.limits; yield* Fiber.join(refreshFiber).pipe( Effect.timeoutOption(SUBSCRIPTION_LIMITS_READ_BUDGET_MS), Effect.asVoid, ); - return yield* readCurrent; + return (yield* readCurrent).limits; }); export function makeSubscriptionLimitsCacheEntry( @@ -194,6 +204,8 @@ interface CodexRateLimitWindowResponse { export interface CodexUsageLimitsResponse { readonly rateLimits: { + /** Codex reports model-scoped buckets under other ids; only `codex` is the account limit. */ + readonly limitId?: string | null; readonly planType?: string | null; readonly primary?: CodexRateLimitWindowResponse | null; readonly secondary?: CodexRateLimitWindowResponse | null; @@ -211,19 +223,25 @@ const CodexTranscriptRateLimitWindow = Schema.Struct({ window_minutes: Schema.optionalKey(NullableNumber), resets_at: Schema.optionalKey(NullableNumber), }); +const NullableString = Schema.Union([Schema.String, Schema.Null]); +// `rate_limits` is a sibling of `info` on codex-rs `TokenCountEvent`, not nested inside it. const CodexTranscriptRateLimitsEvent = Schema.Struct({ timestamp: Schema.String, payload: Schema.Struct({ type: Schema.Literal("token_count"), - info: Schema.Struct({ - rate_limits: Schema.Struct({ - primary: Schema.optionalKey(Schema.Union([CodexTranscriptRateLimitWindow, Schema.Null])), - secondary: Schema.optionalKey(Schema.Union([CodexTranscriptRateLimitWindow, Schema.Null])), - plan_type: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - }), + rate_limits: Schema.Struct({ + limit_id: Schema.optionalKey(NullableString), + primary: Schema.optionalKey(Schema.Union([CodexTranscriptRateLimitWindow, Schema.Null])), + secondary: Schema.optionalKey(Schema.Union([CodexTranscriptRateLimitWindow, Schema.Null])), + plan_type: Schema.optionalKey(NullableString), }), }), }); + +/** Older Codex builds omit `limit_id`; newer ones tag model-scoped buckets such as Spark. */ +function isCodexAccountLimit(limitId: string | null | undefined): boolean { + return limitId === undefined || limitId === null || limitId === "codex"; +} const decodeCodexTranscriptRateLimitsEvent = Schema.decodeUnknownOption( Schema.fromJsonString(CodexTranscriptRateLimitsEvent), ); @@ -247,13 +265,15 @@ export function parseCodexTranscriptRateLimitsSnapshot( if (Option.isNone(decoded)) return null; const observedAtMs = Date.parse(decoded.value.timestamp); if (!Number.isFinite(observedAtMs)) return null; - const limits = decoded.value.payload.info.rate_limits; + const limits = decoded.value.payload.rate_limits; + if (!isCodexAccountLimit(limits.limit_id)) return null; const primary = codexTranscriptWindow(limits.primary); const secondary = codexTranscriptWindow(limits.secondary); return { observedAtMs, response: { rateLimits: { + ...(limits.limit_id === undefined ? {} : { limitId: limits.limit_id }), ...(limits.plan_type === undefined ? {} : { planType: limits.plan_type }), ...(primary === undefined ? {} : { primary }), ...(secondary === undefined ? {} : { secondary }), @@ -434,7 +454,7 @@ function codexWindow( export function normalizeCodexSubscriptionLimits( response: CodexUsageLimitsResponse | undefined, ): UsageProviderLimits | null { - if (!response) return null; + if (!response || !isCodexAccountLimit(response.rateLimits.limitId)) return null; const meteredWindows = [ codexWindow(response.rateLimits.primary, "primary"), diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index 30023e59a9ac..e7ca327fcd3b 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -7,22 +7,22 @@ import { describe, expect, it } from "@effect/vitest"; import { readFreshCodexRateLimitsSnapshot } from "./usageTranscriptReader.ts"; -function rateLimitLine(timestamp: string, usedPercent: number): string { +function rateLimitLine(timestamp: string, usedPercent: number, limitId = "codex"): string { return JSON.stringify({ timestamp, type: "event_msg", payload: { type: "token_count", - info: { - rate_limits: { - primary: null, - secondary: { - used_percent: usedPercent, - window_minutes: 10_080, - resets_at: 1_788_000_000, - }, - plan_type: "prolite", + info: null, + rate_limits: { + limit_id: limitId, + primary: null, + secondary: { + used_percent: usedPercent, + window_minutes: 10_080, + resets_at: 1_788_000_000, }, + plan_type: "prolite", }, }, }); @@ -36,9 +36,17 @@ describe("Codex transcript rate-limit snapshots", () => { const olderAt = "2026-08-27T02:09:00.000Z"; const newerAt = "2026-08-27T02:10:00.000Z"; const futureAt = "2036-08-27T02:10:00.000Z"; + const sparkAt = "2026-08-27T02:10:20.000Z"; await NodeFSP.writeFile( path, - `${rateLimitLine(olderAt, 40)}\n${rateLimitLine(newerAt, 57)}\n${rateLimitLine(futureAt, 99)}\n`, + [ + rateLimitLine(olderAt, 40), + rateLimitLine(newerAt, 57), + // A newer model-scoped bucket must not shadow the account limit. + rateLimitLine(sparkAt, 0, "codex_bengalfox"), + rateLimitLine(futureAt, 99), + "", + ].join("\n"), ); const stats = await NodeFSP.stat(path); const files = [{ path, size: stats.size, mtimeMs: stats.mtimeMs }];