diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 817e6d7f9543..d7f62ad53779 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,13 @@ import { formatHourShort, formatPercent, formatTokens, + formatUsageObservationAge, + formatUsageResetCountdown, + formatUsageResetDateTime, 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 +45,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 +148,12 @@ export function UsageRouteScreen() { isPast24Hours={isPast24Hours} timeZone={window.timeZone} /> - + @@ -295,52 +310,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 +377,81 @@ function ProviderSection(props: { ); } +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; + readonly color: string; + 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 + ? formatUsageResetDateTime(window.resetsAt, props.timeZone) + : null; + const countdown = window.resetsAt + ? formatUsageResetCountdown(window.resetsAt, props.nowMs) + : null; + const label = usageLimitWindowLabel(window); + return ( + + + + {label} + + + + + + {Math.round(percent)}% + + + + {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 224662e9dca7..aa0a8a239c9f 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, @@ -27,23 +29,30 @@ 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 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"; import { ServerConfig } from "../config.ts"; import { expandHomePath } from "../pathExpansion.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 { listTranscriptFiles, readDirectoryVolumeId, + readFreshCodexRateLimitsSnapshot, readTranscriptRecords, } from "./usageTranscriptReader.ts"; import { @@ -54,6 +63,22 @@ import { type ScanCache, } from "./usageScanCache.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; +import { + awaitSubscriptionLimits, + makeSubscriptionLimitsCacheKey, + makeSubscriptionLimitsCacheEntry, + makeSubscriptionLimitsDevFixture, + makeSubscriptionLimitsHomeIdentity, + normalizeClaudeSubscriptionLimits, + normalizeCodexSubscriptionLimits, + readSubscriptionLimitsCacheEntry, + runSubscriptionLimitsProbe, + shouldReadCodexTranscriptSnapshot, + SUBSCRIPTION_LIMITS_SUCCESS_TTL_MS, + type CodexTranscriptRateLimitsSnapshot, + type SubscriptionLimitsCacheEntry, + type SubscriptionLimitsProbeOutcome, +} from "./usageSubscriptionLimits.ts"; const LITELLM_RATES_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; @@ -108,6 +133,7 @@ export const layerTest = Layer.succeed( untilDay: input.untilDay, buckets: [], sources: [], + subscriptionLimits: [], pricing: { status: "unavailable", source: LITELLM_RATES_URL, @@ -119,12 +145,30 @@ 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; const config = yield* ServerConfig; const settingsService = yield* ServerSettings.ServerSettingsService; 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 hostEnvironment = yield* HostProcessEnvironment; const fileCache: ScanCache = new Map(); @@ -135,6 +179,132 @@ 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 readCurrentSubscriptionLimits = Effect.fn("UsageService.readCurrentSubscriptionLimits")( + function* (context: SubscriptionLimitsContext) { + const now = yield* Clock.currentTimeMillis; + const fixture = makeSubscriptionLimitsDevFixture( + config.devUrl !== undefined, + hostEnvironment.T3CODE_DEV_USAGE_LIMITS_FIXTURE, + now, + ); + if (fixture !== null) return { limits: fixture, settled: true }; + + const providers = [context.codex, context.claude].filter(({ enabled }) => enabled); + 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), + }; + }, + ); + + const cacheSubscriptionLimitsProbe = Effect.fn("UsageService.cacheSubscriptionLimitsProbe")( + function* (cacheKey: string, outcome: SubscriptionLimitsProbeOutcome) { + const fetchedAtMs = yield* Clock.currentTimeMillis; + subscriptionLimitsCache.set( + cacheKey, + makeSubscriptionLimitsCacheEntry( + outcome, + fetchedAtMs, + subscriptionLimitsCache.get(cacheKey), + ), + ); + }, + ); + + const refreshSubscriptionLimits = Effect.fn("UsageService.refreshSubscriptionLimits")(function* ( + context: SubscriptionLimitsContext, + codexSnapshot: CodexTranscriptRateLimitsSnapshot | null, + ) { + const now = yield* Clock.currentTimeMillis; + const fixture = makeSubscriptionLimitsDevFixture( + config.devUrl !== undefined, + hostEnvironment.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 ( + context.codex.enabled && + context.codex.canUseTranscriptSnapshot && + codexSnapshot !== null && + shouldReadCodexTranscriptSnapshot(subscriptionLimitsCache.get(context.codex.cacheKey), now) + ) { + subscriptionLimitsCache.set( + context.codex.cacheKey, + makeSubscriptionLimitsCacheEntry( + { + _tag: "Success", + limits: normalizeCodexSubscriptionLimits(codexSnapshot.response), + }, + now, + subscriptionLimitsCache.get(context.codex.cacheKey), + codexSnapshot.observedAtMs, + ), + ); + } + + yield* subscriptionLimitsSemaphore.withPermits(1)( + Effect.gen(function* () { + const probeStartedAtMs = yield* Clock.currentTimeMillis; + + const cachedClaude = context.claude.enabled + ? readSubscriptionLimitsCacheEntry( + subscriptionLimitsCache.get(context.claude.cacheKey), + probeStartedAtMs, + ) + : undefined; + const cachedCodex = context.codex.enabled + ? readSubscriptionLimitsCacheEntry( + subscriptionLimitsCache.get(context.codex.cacheKey), + probeStartedAtMs, + ) + : undefined; + yield* Effect.all( + [ + context.claude.enabled && cachedClaude === undefined + ? runSubscriptionLimitsProbe( + probeClaudeUsage(context.claude.settings, hostEnvironment, config.cwd).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + normalizeClaudeSubscriptionLimits, + ).pipe( + Effect.flatMap((outcome) => + cacheSubscriptionLimitsProbe(context.claude.cacheKey, outcome), + ), + ) + : Effect.void, + context.codex.enabled && cachedCodex === undefined + ? runSubscriptionLimitsProbe( + probeCodexRateLimits(context.codex.settings, hostEnvironment, config.cwd).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + childProcessSpawner, + ), + ), + normalizeCodexSubscriptionLimits, + ).pipe( + Effect.flatMap((outcome) => + cacheSubscriptionLimitsProbe(context.codex.cacheKey, outcome), + ), + ) + : Effect.void, + ], + { concurrency: "unbounded" }, + ); + }), + ); + }); /** * Loads the LiteLLM rate table, preferring a fresh copy and falling back to @@ -229,15 +399,68 @@ 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 ?? "", + }; + 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: [ + { 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, + homeIdentity: claudeHomeIdentity, + }), + settings: settings.providers.claudeAgent, + }, + codex: { + enabled: settings.providers.codex.enabled, + cacheKey: makeSubscriptionLimitsCacheKey({ + provider: "codex", + binaryPath: settings.providers.codex.binaryPath, + homeIdentity: codexHomeIdentity, + launchArgs: settings.providers.codex.launchArgs, + }), + settings: codexProbeSettings, + // 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, + }; }); /** @@ -338,13 +561,12 @@ export const make = Effect.gen(function* () { } const startedAtMs = yield* Clock.currentTimeMillis; - 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)); + 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({ @@ -354,6 +576,39 @@ 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 = + 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), + Effect.forkIn(subscriptionLimitsScope), + ); + yield* ensureRates(); + yield* ensureScanCacheLoaded; + + const hostId = NodeOS.hostname(); const aggregator = new UsageAggregator({ timeZone: input.timeZone, @@ -388,9 +643,16 @@ export const make = Effect.gen(function* () { } walkedRoots.push(dir); - const files = yield* Effect.promise(() => - listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), - ); + const prefetched = prefetchedFiles.get(dir); + const files = + prefetched ?? + (yield* Effect.promise(() => + listTranscriptFiles( + dir, + windowStartMs, + fileName === undefined ? undefined : { fileName }, + ), + )); let scannedFiles = 0; let skippedFiles = 0; // Distinct per directory. Buckets carry per-cell session counts, but a @@ -437,6 +699,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, + readCurrentSubscriptionLimits(subscriptionLimitsContext), + ); return { contractVersion: USAGE_CONTRACT_VERSION, @@ -446,6 +712,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..54f91c7ff307 --- /dev/null +++ b/apps/server/src/usage/usageSubscriptionLimits.test.ts @@ -0,0 +1,616 @@ +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"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; + +import { + awaitSubscriptionLimits, + makeSubscriptionLimitsCacheKey, + makeSubscriptionLimitsCacheEntry, + makeSubscriptionLimitsDevFixture, + makeSubscriptionLimitsHomeIdentity, + normalizeClaudeSubscriptionLimits, + normalizeCodexSubscriptionLimits, + parseCodexTranscriptRateLimitsSnapshot, + readSubscriptionLimitsCacheEntry, + runSubscriptionLimitsProbe, + shouldReadCodexTranscriptSnapshot, +} from "./usageSubscriptionLimits.ts"; + +describe("subscription usage limits", () => { + it("isolates cached limits by provider runtime and account home", () => { + const claudeAccount = makeSubscriptionLimitsCacheKey({ + provider: "claude", + binaryPath: "claude", + homeIdentity: "/accounts/claude-a", + }); + const cache = new Map([[claudeAccount, "account-a"]]); + + const otherClaudeAccount = makeSubscriptionLimitsCacheKey({ + provider: "claude", + binaryPath: "claude", + homeIdentity: "/accounts/claude-b", + }); + expect(cache.get(otherClaudeAccount)).toBeUndefined(); + expect( + makeSubscriptionLimitsCacheKey({ + provider: "codex", + binaryPath: "codex", + 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", + 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", + label: "5h", + usedPercent: 10.4, + resetsAt: "2026-08-26T19:00:00.000Z", + }, + { + kind: "weekly", + label: "Week", + usedPercent: 3, + resetsAt: "2026-09-01T23:00:00.000Z", + }, + ], + }); + }); + + it("normalizes Claude's live model-scoped weekly window shape", () => { + 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" } }, + }, + ], + }, + } as Parameters[0]; + const limits = normalizeClaudeSubscriptionLimits(response); + + expect(limits?.windows).toEqual([ + { + kind: "weekly", + label: "Week", + usedPercent: 3, + resetsAt: "2026-09-01T23:00:00.000Z", + }, + { + kind: "weekly:fable", + label: "Fable", + usedPercent: 95, + resetsAt: "2026-09-01T23:00:00.000Z", + }, + ]); + }); + + 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", + }, + ]); + }); + + it("omits Claude limits when plan rate limits are unavailable", () => { + expect( + normalizeClaudeSubscriptionLimits({ + subscription_type: null, + rate_limits_available: false, + rate_limits: null, + }), + ).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: { + 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", + label: "5h", + usedPercent: 42, + resetsAt: "2026-08-29T10:40:00.000Z", + }, + { kind: "weekly", label: "Week", usedPercent: 8, resetsAt: null }, + ], + }); + }); + + it("does not invent a five-hour 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: "weekly", label: "Week", usedPercent: 44, resetsAt: null }, + ]); + }); + + 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: { + primary: { usedPercent: 44, windowDurationMins: 10_079, resetsAt: null }, + secondary: { usedPercent: 12, windowDurationMins: 43_201, resetsAt: null }, + }, + }); + + expect(limits?.windows).toEqual([ + { + kind: "weekly", + label: "Week", + usedPercent: 44, + resetsAt: null, + }, + { kind: "monthly", label: "Month", usedPercent: 12, resetsAt: null }, + ]); + + 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, + }, + { + kind: "codex:secondary", + label: "Secondary", + usedPercent: 12, + resetsAt: null, + }, + ]); + }); + + 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: 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", + }, + }, + }), + ), + ).toEqual({ + observedAtMs: Date.parse("2026-08-27T02:10:00.000Z"), + response: { + rateLimits: { + limitId: "codex", + planType: "prolite", + primary: null, + secondary: { + usedPercent: 57, + windowDurationMins: 10_080, + resetsAt: 1_788_000_000, + }, + }, + }, + }); + }); + + 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: { + primary: { usedPercent: 140 }, + secondary: { usedPercent: -5 }, + }, + }); + + expect(limits?.windows.map((window) => window.usedPercent)).toEqual([100, 0]); + }); + + it.effect("returns ready limits without waiting for a slow provider refresh", () => + Effect.gen(function* () { + const limits = { + provider: "codex", + plan: "plus", + windows: [ + { + kind: "weekly", + usedPercent: 42, + resetsAt: null, + }, + ], + } satisfies UsageProviderLimits; + const providerFiber = yield* Effect.sleep(Duration.seconds(10)).pipe(Effect.forkScoped); + const result = yield* awaitSubscriptionLimits( + providerFiber, + Effect.succeed({ limits: [limits], settled: true }), + ); + + 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({ limits: [], settled: false }), + ).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)).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, + }, + ], + } 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(() => ({ limits: current, settled: current.length > 0 })), + ).pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.seconds(3)); + + expect(yield* Fiber.join(waitFiber)).toEqual([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([ + runSubscriptionLimitsProbe(Effect.succeed({}), () => null), + runSubscriptionLimitsProbe(Effect.void, () => null), + ]); + + expect(emptyOutcome).toEqual({ _tag: "Success", limits: null }); + expect(failedOutcome).toEqual({ _tag: "Failure" }); + }), + ); + + it("caches a successful empty response for three minutes", () => { + const entry = makeSubscriptionLimitsCacheEntry({ _tag: "Success", limits: null }, 1_000); + + expect(readSubscriptionLimitsCacheEntry(entry, 180_999)).toEqual({ + _tag: "Success", + limits: null, + }); + 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); + + expect(readSubscriptionLimitsCacheEntry(entry, 600_999)).toEqual({ _tag: "Failure" }); + 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( + { + _tag: "Success", + limits: { + provider: "codex", + plan: "prolite", + windows: [ + { + kind: "weekly", + label: "Week", + usedPercent: 57, + resetsAt: null, + }, + ], + }, + }, + 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, + }, + ], + 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(); + + expect(makeSubscriptionLimitsDevFixture(true, "review", 1_788_000_000_000)).toEqual([ + { + provider: "codex", + plan: "pro", + windows: [ + { + kind: "weekly", + label: "Week", + usedPercent: 47, + resetsAt: "2026-09-04T10:40:00.000Z", + }, + ], + }, + { + provider: "claude", + plan: "max", + windows: [ + { + kind: "fiveHour", + label: "5h", + usedPercent: 68, + resetsAt: "2026-08-29T12:40:00.000Z", + }, + { + kind: "weekly", + label: "Week", + usedPercent: 32, + resetsAt: "2026-09-03T10:40:00.000Z", + }, + { + kind: "weekly:fable", + label: "Fable", + usedPercent: 91, + resetsAt: "2026-09-02T10:40:00.000Z", + }, + ], + }, + ]); + }); +}); diff --git a/apps/server/src/usage/usageSubscriptionLimits.ts b/apps/server/src/usage/usageSubscriptionLimits.ts new file mode 100644 index 000000000000..a2f6f0f7fd92 --- /dev/null +++ b/apps/server/src/usage/usageSubscriptionLimits.ts @@ -0,0 +1,515 @@ +import type { SDKControlGetUsageResponse } from "@anthropic-ai/claude-agent-sdk"; +import type { + UsageLimitWindow, + UsageLimitWindowKind, + UsageProviderKind, + UsageProviderLimits, +} 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 * as Predicate from "effect/Predicate"; +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; + +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 = 10 * 60_000; + +export type SubscriptionLimitsProbeOutcome = + | { + readonly _tag: "Success"; + readonly limits: UsageProviderLimits | null; + } + | { readonly _tag: "Failure" }; + +export interface SubscriptionLimitsCacheEntry { + readonly expiresAtMs: number; + readonly outcome: SubscriptionLimitsProbeOutcome; + readonly lastSuccess?: { + readonly limits: UsageProviderLimits | null; + readonly observedAtMs: number; + }; +} + +export interface SubscriptionLimitsCacheIdentity { + readonly provider: UsageProviderKind; + readonly binaryPath: 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.homeIdentity, + identity.launchArgs ?? null, + ]); +} + +const subscriptionLimitsProbeFailure = { _tag: "Failure" } as const; + +/** 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, + ) => + Effect.map( + probe, + (response): SubscriptionLimitsProbeOutcome => + response === undefined + ? subscriptionLimitsProbeFailure + : { _tag: "Success", limits: normalize(response) }, + ), +); + +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, +) { + const ready = yield* readCurrent; + if (ready.settled) return ready.limits; + + yield* Fiber.join(refreshFiber).pipe( + Effect.timeoutOption(SUBSCRIPTION_LIMITS_READ_BUDGET_MS), + Effect.asVoid, + ); + return (yield* readCurrent).limits; +}); + +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; + 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 { + 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), + }; +} + +/** 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, + stale: boolean, +): UsageProviderLimits | null { + if (limits === null || observedAtMs === undefined) return limits; + return { + ...limits, + observedAt: DateTime.formatIso(DateTime.makeUnsafe(observedAtMs)), + stale, + }; +} + +type ClaudeUsageLimitsResponse = Partial< + Pick +> & { + /** Compatibility fallback for SDK builds that project model-scoped limits at the top level. */ + readonly limits?: unknown; +}; + +interface CodexRateLimitWindowResponse { + readonly usedPercent: number; + readonly windowDurationMins?: number | null; + readonly resetsAt?: number | null; +} + +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; + }; +} + +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 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"), + 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), +); + +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.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 }), + }, + }, + }; +} + +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, + label: string, + 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, + label, + usedPercent: percent, + resetsAt: window?.resets_at ?? null, + }; +} + +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, + }, + ]; + }); +} + +export function normalizeClaudeSubscriptionLimits( + response: ClaudeUsageLimitsResponse | undefined, +): UsageProviderLimits | null { + const rateLimits = response?.rate_limits; + if (!response?.rate_limits_available || rateLimits === null || rateLimits === undefined) + return null; + + const windows = [ + 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() ?? ""; + return { + provider: "claude", + plan: plan.length > 0 ? plan : null, + windows, + }; +} + +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: CodexRateLimitWindowResponse | null | undefined, + position: "primary" | "secondary", +): 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)); + const presentation = codexWindowPresentation(window, position); + return { + kind: presentation.kind, + label: presentation.label, + usedPercent: percent, + resetsAt, + }; +} + +export function normalizeCodexSubscriptionLimits( + response: CodexUsageLimitsResponse | undefined, +): UsageProviderLimits | null { + if (!response || !isCodexAccountLimit(response.rateLimits.limitId)) return null; + + const meteredWindows = [ + codexWindow(response.rateLimits.primary, "primary"), + codexWindow(response.rateLimits.secondary, "secondary"), + ].filter((window): window is UsageLimitWindow => window !== null); + if (meteredWindows.length === 0) return null; + + const plan = response.rateLimits.planType?.trim() ?? ""; + return { + provider: "codex", + plan: plan.length > 0 ? plan : null, + windows: meteredWindows, + }; +} + +/** 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", + 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/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts new file mode 100644 index 000000000000..e7ca327fcd3b --- /dev/null +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -0,0 +1,101 @@ +// @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, limitId = "codex"): string { + return JSON.stringify({ + timestamp, + type: "event_msg", + payload: { + type: "token_count", + 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", + }, + }, + }); +} + +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"; + const futureAt = "2036-08-27T02:10:00.000Z"; + const sparkAt = "2026-08-27T02:10:20.000Z"; + await NodeFSP.writeFile( + path, + [ + 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 }]; + + 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"), + Date.parse("2026-08-27T02:11:30.000Z"), + ), + ).resolves.toBeNull(); + } finally { + 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 33aef8fae25c..77f111782cc9 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, @@ -26,6 +30,10 @@ import { type UsageRecord, } from "./usageTranscripts.ts"; +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; readonly size: number; @@ -100,6 +108,60 @@ 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, + 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) + .slice(0, CODEX_RATE_LIMIT_MAX_CANDIDATES); + 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 || + snapshot.observedAtMs > latestAllowedMs + ) { + 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 8e86b521e890..66266d8e5985 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", @@ -69,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([ @@ -180,3 +186,102 @@ 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: { + ...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", + observedAt: "2026-08-26T20:00:00.000Z", + stale: true, + windows: [ + { kind: "fiveHour", usedPercent: 0, resetsAt: null }, + { + kind: "weekly", + usedPercent: 8, + resetsAt: "2030-08-29T21:00:00.000Z", + }, + ], + }, + ], + }, + 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('aria-valuetext="0% used."'); + 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"')); + }); + + 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(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 7474bb9d6120..f8fe4f431c27 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,9 @@ import { formatHourShort, formatPercent, formatTokens, + formatUsageObservationAge, + formatUsageResetCountdown, + formatUsageResetDateTime, formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; @@ -24,6 +27,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, @@ -41,6 +45,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, @@ -48,10 +56,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 +98,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 +241,7 @@ export function UsagePage() { />
-
+
{metric === "cost" @@ -236,8 +255,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 +267,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"} @@ -472,8 +496,97 @@ 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 { + if (window.label) return window.label; + if (window.kind === "fiveHour") return "5h"; + if (window.kind === "weekly") return "Week"; + return window.kind; +} + +function UsageLimitMeters({ + limits, + nowMs, + timeZone, +}: { + readonly limits: UsageProviderLimits; + readonly nowMs: number; + 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; + const countdown = window.resetsAt + ? formatUsageResetCountdown(window.resetsAt, nowMs) + : null; + const label = usageLimitWindowLabel(window); + const resetText = reset ? ` Resets ${reset}.` : ""; + const usageText = `${Math.round(percent)}% used.${resetText}`; + return ( + + }> + + {label} + + + + + + {Math.round(percent)}% + + + {countdown === "now" + ? "Reset due" + : countdown + ? `Resets in ${countdown}` + : "Reset time unavailable"} + + + + {providerLabel} {label}: {Math.round(percent)}% used + {reset ? ` · Resets ${reset}` : null} + + + ); + })} +
+ ); } function Metric({ label, value }: { readonly label: string; readonly value: string }) { @@ -592,30 +705,47 @@ function UsageSkeleton() { return ( <>
-
+
{PROVIDER_ORDER.map((provider) => ( -
-
- - - -
- -
+
+
+
+ + + + + + + + +
+
+
+
+ {["fiveHour", "weekly"].map((window) => ( +
+ + + + +
+ ))}
-
))}
-
+
-
-
+
+
+
+
+
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 ff38c730c1cd..9fabe5d44bbe 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -8,6 +8,17 @@ separate from the raw token cost shown here. Grok Build totals come from persisted session updates. Interactive turns that never wrote a completed-turn record will not appear. +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. Claude also shows model-scoped weekly windows, such +as Fable, when the subscription reports them. + +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 headline and chart, and refreshing rescans every connected environment. diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 8c099ddb33aa..aa431fa21e8d 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"; @@ -169,6 +170,32 @@ export const UsagePricing = Schema.Struct({ }); export type UsagePricing = typeof UsagePricing.Type; +/** 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), +}); +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), + /** 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; + export const UsageSummaryInput = Schema.Struct({ /** Inclusive first day of the window, in `timeZone`. */ sinceDay: UsageDay, @@ -196,6 +223,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..aeec4dd1683e 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -6,6 +6,9 @@ import { formatDateTimeShort, formatHourShort, formatRelativeHourShort, + formatUsageObservationAge, + formatUsageResetCountdown, + formatUsageResetDateTime, makeWindow, } from "./usageFormat.ts"; @@ -71,3 +74,34 @@ 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(); + }); + + 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(); + }); + + 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 bd751829dd87..972706c4599f 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,54 @@ 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`; +} + +/** 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); + 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 6c706395c6ff..4b3f7080128b 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, @@ -50,6 +53,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, @@ -169,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("keeps the previous compatible contract version so additive provider expansions still merge", () => { const merged = mergeUsage( [ @@ -290,6 +306,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 }], + }, + ], + }), + environment("env-b", { + ...newer, + readAt: "2026-08-07T11:00:00.000Z", + subscriptionLimits: [ + { + provider: "codex", + plan: "pro", + windows: [{ kind: "fiveHour", usedPercent: 35, resetsAt: null }], + }, + ], + }), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.subscriptionLimits).toEqual([ + { + provider: "codex", + plan: "pro", + windows: [{ kind: "fiveHour", usedPercent: 35, resetsAt: null }], + }, + ]); + }); + 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 428599d51c74..f74df6dfeb43 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -11,6 +11,7 @@ import { type EnvironmentId, type UsageBucket, type UsageProviderKind, + type UsageProviderLimits, type UsageSourceFingerprint, type UsageSummary, } from "@t3tools/contracts"; @@ -73,6 +74,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[]; @@ -188,6 +190,7 @@ const EMPTY_MERGED: MergedUsage = { records: 0, sessions: 0, providers: [], + subscriptionLimits: [], models: [], daily: [], hourly: [], @@ -228,6 +231,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; @@ -407,6 +423,7 @@ export function mergeUsage( records, sessions, providers, + subscriptionLimits: [...subscriptionLimitsByProvider.values()], models, daily, hourly,