From b5e4074425cb57cc26705effeb80185288266e43 Mon Sep 17 00:00:00 2001 From: Ahmed Shareef Date: Fri, 28 Aug 2026 13:41:01 +0400 Subject: [PATCH 01/10] feat(usage): add Claude subscription limit tracking - Expose usage-limits RPC with defensive Claude OAuth parsing - Add Usage/Limits dashboard with refresh and reset countdowns - Document subscription limit behavior --- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/server.test.ts | 2 + apps/server/src/server.ts | 4 + apps/server/src/usage/UsageLimitsService.ts | 220 +++++++++++++ .../src/usage/usageLimitsClaude.test.ts | 141 +++++++++ apps/server/src/usage/usageLimitsClaude.ts | 214 +++++++++++++ apps/server/src/ws.ts | 6 + .../components/usage/UsageLimitsContent.tsx | 292 ++++++++++++++++++ apps/web/src/components/usage/UsagePage.tsx | 230 +++++++++----- apps/web/src/state/usage.ts | 151 +++++++++ docs/user/usage.md | 5 + packages/client-runtime/src/state/server.ts | 7 + packages/contracts/src/index.ts | 1 + packages/contracts/src/rpc.ts | 9 + packages/contracts/src/usageLimits.ts | 88 ++++++ 15 files changed, 1288 insertions(+), 83 deletions(-) create mode 100644 apps/server/src/usage/UsageLimitsService.ts create mode 100644 apps/server/src/usage/usageLimitsClaude.test.ts create mode 100644 apps/server/src/usage/usageLimitsClaude.ts create mode 100644 apps/web/src/components/usage/UsageLimitsContent.tsx create mode 100644 packages/contracts/src/usageLimits.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 28ceac4cec99..619b12b4fa11 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -46,6 +46,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetUsageLimits]: AuthOrchestrationReadScope, [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a9a2c3fa10d6..219ff57a8fbe 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -158,6 +158,7 @@ import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; +import * as UsageLimitsService from "./usage/UsageLimitsService.ts"; import * as Data from "effect/Data"; import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts"; @@ -849,6 +850,7 @@ const buildAppUnderTest = (options?: { const appLayer = servedRoutesLayer.pipe( Layer.provide(resourceTelemetryLayer), Layer.provide(UsageService.layerTest), + Layer.provide(UsageLimitsService.layerTest), Layer.provide( Layer.mock(AnalyticsService.AnalyticsService)({ record: () => Effect.void, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d5bebe3d5000..0c6ab90caa26 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -110,6 +110,7 @@ import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageService from "./usage/UsageService.ts"; +import * as UsageLimitsService from "./usage/UsageLimitsService.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, @@ -172,6 +173,8 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe( const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive)); +const UsageLimitsLayerLive = UsageLimitsService.layer.pipe(Layer.provide(ServerSettingsLayerLive)); + const ResourceDiagnosticsLayerLive = Layer.mergeAll( ResourceTelemetryLayerLive, ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), @@ -428,6 +431,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( Layer.provideMerge(BackgroundLayerLive), Layer.provideMerge(ResourceDiagnosticsLayerLive), Layer.provideMerge(UsageLayerLive), + Layer.provideMerge(UsageLimitsLayerLive), Layer.provideMerge(TraceDiagnostics.layer), Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), diff --git a/apps/server/src/usage/UsageLimitsService.ts b/apps/server/src/usage/UsageLimitsService.ts new file mode 100644 index 000000000000..8de0f91f126e --- /dev/null +++ b/apps/server/src/usage/UsageLimitsService.ts @@ -0,0 +1,220 @@ +/** + * UsageLimitsService - reports subscription rate-window consumption. + * + * Where {@link UsageService} answers "what did my sessions cost", this service + * answers "how close am I to being rate limited". The figures only exist for + * subscription sign-ins, so the service reuses the provider CLI's own OAuth + * grant (credential file under the Claude home, or the macOS login keychain) + * and asks Anthropic's OAuth usage endpoint. API-key auth has no rate + * windows; those environments answer `unsupported` in-band instead of + * failing the RPC. + * + * @module UsageLimitsService + */ +import { + USAGE_LIMITS_CONTRACT_VERSION, + type ProviderUsageLimits, + type UsageLimitsSummary, +} from "@t3tools/contracts"; +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 Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as ServerSettings from "../serverSettings.ts"; +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; +import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; +import { + claudePlanLabel, + parseClaudeOauthCredentials, + parseClaudeUsageWindows, +} from "./usageLimitsClaude.ts"; + +const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; + +/** The OAuth endpoints require the same beta marker the Claude CLI sends. */ +const CLAUDE_OAUTH_BETA_HEADER = "oauth-2025-04-20"; + +/** The credential payload the CLI stores in the macOS login keychain. */ +const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials"; + +const REQUEST_TIMEOUT_MS = 10_000; + +const SUBSCRIPTION_ONLY_MESSAGE = + "Limit info is only available for subscription sign-ins. API usage is billed per token and has no rate windows."; + +export class UsageLimitsService extends Context.Service< + UsageLimitsService, + { + readonly readLimits: () => Effect.Effect; + } +>()("t3/usage/UsageLimitsService") {} + +/** Empty summary, for suites that only need the RPC surface to resolve. */ +export const layerTest = Layer.succeed( + UsageLimitsService, + UsageLimitsService.of({ + readLimits: () => + Effect.succeed({ + contractVersion: USAGE_LIMITS_CONTRACT_VERSION, + readAt: "1970-01-01T00:00:00.000Z", + providers: [], + }), + }), +); + +function claudeLimits( + availability: ProviderUsageLimits["availability"], + plan: string | null, + message: string | null, + windows: ProviderUsageLimits["windows"] = [], +): ProviderUsageLimits { + return { provider: "claude", availability, plan, windows, message }; +} + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const settingsService = yield* ServerSettings.ServerSettingsService; + const httpClient = yield* HttpClient.HttpClient; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const platform = yield* HostProcessPlatform; + + /** + * Reads the CLI's keychain entry. The CLI only writes it on macOS; elsewhere + * (and when the entry is absent or access is denied) this yields null. + */ + const readMacKeychainCredentials = Effect.fn("UsageLimitsService.readMacKeychainCredentials")( + function* () { + const child = yield* spawner.spawn( + ChildProcess.make("security", [ + "find-generic-password", + "-s", + CLAUDE_KEYCHAIN_SERVICE, + "-w", + ]), + ); + yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); + const [stdout, exitCode] = yield* Effect.all( + [collectUint8StreamText({ stream: child.stdout, maxBytes: 1024 * 1024 }), child.exitCode], + { concurrency: "unbounded" }, + ); + const text = stdout.text.trim(); + return Number(exitCode) === 0 && text.length > 0 ? text : null; + }, + Effect.scoped, + Effect.timeoutOption(5_000), + (effect) => + effect.pipe( + Effect.map(Option.getOrNull), + Effect.orElseSucceed(() => null), + ), + ); + + /** + * Finds the OAuth grant the CLI signed in with, or null when there is none + * (not signed in, or authenticated with an API key). Mirrors the CLI's own + * storage order: credential file under the Claude home first, then the + * macOS login keychain. + */ + const readClaudeCredentials = Effect.fn("UsageLimitsService.readClaudeCredentials")(function* () { + const settings = yield* settingsService.getSettings.pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + if (settings === null) return null; + + const home = yield* resolveClaudeHomePath(settings.providers.claudeAgent).pipe( + Effect.provideService(Path.Path, path), + ); + // The configured home is either the user home (default install nests + // under `.claude`) or the config dir itself, mirroring the transcript + // probe in UsageService. + const candidates = [ + path.join(home, ".claude", ".credentials.json"), + path.join(home, ".credentials.json"), + ]; + for (const candidate of candidates) { + const raw = yield* fileSystem + .readFileString(candidate) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + if (raw === null) continue; + const parsed = parseClaudeOauthCredentials(raw); + if (parsed !== null) return parsed; + } + + if (platform === "darwin") { + const raw = yield* readMacKeychainCredentials(); + if (raw !== null) return parseClaudeOauthCredentials(raw); + } + return null; + }); + + const readClaudeLimits = Effect.fn("UsageLimitsService.readClaudeLimits")(function* () { + const credentials = yield* readClaudeCredentials(); + if (credentials === null) { + return claudeLimits("unsupported", null, SUBSCRIPTION_ONLY_MESSAGE); + } + + const plan = claudePlanLabel(credentials.subscriptionType); + const request = HttpClientRequest.get(CLAUDE_USAGE_URL).pipe( + HttpClientRequest.setHeaders({ + authorization: `Bearer ${credentials.accessToken}`, + "anthropic-beta": CLAUDE_OAUTH_BETA_HEADER, + }), + ); + const response = yield* httpClient.execute(request).pipe( + Effect.timeoutOption(REQUEST_TIMEOUT_MS), + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isNone(response)) { + return claudeLimits("unavailable", plan, "Claude's limit service could not be reached."); + } + const status = response.value.status; + if (status === 401 || status === 403) { + return claudeLimits( + "unauthenticated", + plan, + "The stored Claude sign-in was rejected. Open Claude Code to refresh it, then retry.", + ); + } + if (status < 200 || status >= 300) { + return claudeLimits( + "unavailable", + plan, + `Claude's limit service answered with status ${status}.`, + ); + } + + const payload = yield* response.value.json.pipe(Effect.orElseSucceed(() => null)); + const windows = parseClaudeUsageWindows(payload); + if (windows.length === 0) { + return claudeLimits( + "unavailable", + plan, + "Claude's limit service answered in a shape this version does not understand.", + ); + } + return claudeLimits("available", plan, null, windows); + }); + + const readLimits = Effect.fn("UsageLimitsService.readLimits")(function* () { + const claude = yield* readClaudeLimits(); + const readAt = yield* DateTime.now; + return { + contractVersion: USAGE_LIMITS_CONTRACT_VERSION, + readAt: DateTime.formatIso(readAt), + providers: [claude], + } satisfies UsageLimitsSummary; + }); + + return { readLimits } as const; +}); + +export const layer = Layer.effect(UsageLimitsService, make); diff --git a/apps/server/src/usage/usageLimitsClaude.test.ts b/apps/server/src/usage/usageLimitsClaude.test.ts new file mode 100644 index 000000000000..12c791dc2642 --- /dev/null +++ b/apps/server/src/usage/usageLimitsClaude.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + claudePlanLabel, + parseClaudeOauthCredentials, + parseClaudeUsageWindows, +} from "./usageLimitsClaude.ts"; + +describe("parseClaudeOauthCredentials", () => { + it("reads the CLI's credential document", () => { + const parsed = parseClaudeOauthCredentials( + JSON.stringify({ + claudeAiOauth: { + accessToken: "sk-ant-oat01-abc", + refreshToken: "sk-ant-ort01-def", + expiresAt: 1_800_000_000_000, + subscriptionType: "max", + }, + }), + ); + expect(parsed).toEqual({ accessToken: "sk-ant-oat01-abc", subscriptionType: "max" }); + }); + + it("tolerates a missing subscription type", () => { + const parsed = parseClaudeOauthCredentials( + JSON.stringify({ claudeAiOauth: { accessToken: "token" } }), + ); + expect(parsed).toEqual({ accessToken: "token", subscriptionType: null }); + }); + + it("rejects documents without an OAuth grant", () => { + expect(parseClaudeOauthCredentials("not json")).toBeNull(); + expect(parseClaudeOauthCredentials("{}")).toBeNull(); + expect(parseClaudeOauthCredentials(JSON.stringify({ claudeAiOauth: {} }))).toBeNull(); + expect( + parseClaudeOauthCredentials(JSON.stringify({ claudeAiOauth: { accessToken: " " } })), + ).toBeNull(); + }); +}); + +describe("claudePlanLabel", () => { + it("maps known plans and humanises unknown ones", () => { + expect(claudePlanLabel("max")).toBe("Claude Max"); + expect(claudePlanLabel("pro")).toBe("Claude Pro"); + expect(claudePlanLabel("supermax")).toBe("Claude Supermax"); + expect(claudePlanLabel(null)).toBeNull(); + expect(claudePlanLabel(" ")).toBeNull(); + }); +}); + +describe("parseClaudeUsageWindows", () => { + it("prefers the structured limits array, including model-scoped windows", () => { + const windows = parseClaudeUsageWindows({ + five_hour: { utilization: 99, resets_at: "2026-08-12T18:00:00Z" }, + limits: [ + { kind: "session", percent: 12, resets_at: "2026-08-12T18:00:00+00:00", scope: null }, + { kind: "weekly_all", percent: 6, resets_at: "2026-08-18T00:59:59+00:00", scope: null }, + { + kind: "weekly_scoped", + percent: 10, + resets_at: "2026-08-18T00:59:59+00:00", + scope: { model: { id: null, display_name: "Fable" }, surface: null }, + }, + ], + }); + expect(windows).toEqual([ + { + id: "session", + label: "Session limit", + detail: "Rolling 5-hour window", + utilization: 12, + resetsAt: "2026-08-12T18:00:00+00:00", + }, + { + id: "weekly_all", + label: "Weekly limit", + detail: "All models · rolling 7-day window", + utilization: 6, + resetsAt: "2026-08-18T00:59:59+00:00", + }, + { + id: "weekly_scoped:Fable", + label: "Weekly limit (Fable)", + detail: "Rolling 7-day window", + utilization: 10, + resetsAt: "2026-08-18T00:59:59+00:00", + }, + ]); + }); + + it("falls back to known legacy windows and drops codename slots", () => { + const windows = parseClaudeUsageWindows({ + seven_day: { utilization: 34.5, resets_at: "2026-08-18T00:00:00+00:00" }, + five_hour: { utilization: -3, resets_at: "2026-08-12T18:00:00+00:00" }, + seven_day_opus: { utilization: 0, resets_at: null }, + nimbus_quill: { utilization: 0, resets_at: null }, + tangelo: null, + }); + expect(windows.map((window) => window.id)).toEqual([ + "five_hour", + "seven_day", + "seven_day_opus", + ]); + expect(windows[0]?.utilization).toBe(0); + expect(windows[2]?.resetsAt).toBeNull(); + }); + + it("appends the extra-usage credit budget with money figures", () => { + const windows = parseClaudeUsageWindows({ + limits: [{ kind: "session", percent: 1, resets_at: null, scope: null }], + extra_usage: { + is_enabled: false, + monthly_limit: 5000, + used_credits: 1944, + utilization: 38.88, + currency: "USD", + decimal_places: 2, + }, + }); + expect(windows.at(-1)).toEqual({ + id: "extra_usage", + label: "Extra usage credits", + detail: "$19.44 of $50.00 monthly usage credits", + utilization: 38.88, + resetsAt: null, + }); + }); + + it("omits an untouched, disabled extra-usage budget", () => { + const windows = parseClaudeUsageWindows({ + limits: [{ kind: "session", percent: 1, resets_at: null, scope: null }], + extra_usage: { is_enabled: false, utilization: 0 }, + }); + expect(windows.map((window) => window.id)).toEqual(["session"]); + }); + + it("returns empty for non-object documents", () => { + expect(parseClaudeUsageWindows(null)).toEqual([]); + expect(parseClaudeUsageWindows("nope")).toEqual([]); + }); +}); diff --git a/apps/server/src/usage/usageLimitsClaude.ts b/apps/server/src/usage/usageLimitsClaude.ts new file mode 100644 index 000000000000..44d47078ed57 --- /dev/null +++ b/apps/server/src/usage/usageLimitsClaude.ts @@ -0,0 +1,214 @@ +/** + * Pure parsing for Claude subscription limits. + * + * The Claude CLI stores its OAuth grant either in `.credentials.json` under + * the Claude home or in the macOS login keychain; the usage figures come from + * Anthropic's OAuth usage endpoint. Neither shape is a published contract, so + * both parsers are defensive: an unrecognised document yields `null`/empty + * rather than an error, and unknown window ids still render with a humanised + * label instead of being dropped. + * + * @module usageLimitsClaude + */ +import type { UsageLimitWindow } from "@t3tools/contracts"; + +export interface ClaudeOauthCredentials { + readonly accessToken: string; + /** Raw plan marker from the credential store, e.g. `max`. */ + readonly subscriptionType: string | null; +} + +/** + * Reads the CLI's credential document (file contents or keychain payload). + * Only OAuth grants qualify: an API key never produces this shape, which is + * exactly the signal that limits do not apply. + */ +export function parseClaudeOauthCredentials(raw: string): ClaudeOauthCredentials | null { + let document: unknown; + try { + document = JSON.parse(raw); + } catch { + return null; + } + if (typeof document !== "object" || document === null) return null; + const oauth = (document as Record).claudeAiOauth; + if (typeof oauth !== "object" || oauth === null) return null; + const { accessToken, subscriptionType } = oauth as Record; + if (typeof accessToken !== "string" || accessToken.trim().length === 0) return null; + return { + accessToken: accessToken.trim(), + subscriptionType: + typeof subscriptionType === "string" && subscriptionType.trim().length > 0 + ? subscriptionType.trim() + : null, + }; +} + +const PLAN_LABELS: Record = { + free: "Claude Free", + pro: "Claude Pro", + max: "Claude Max", + team: "Claude Team", + enterprise: "Claude Enterprise", +}; + +export function claudePlanLabel(subscriptionType: string | null): string | null { + if (subscriptionType === null) return null; + const normalized = subscriptionType.trim().toLowerCase(); + if (normalized.length === 0) return null; + return ( + PLAN_LABELS[normalized] ?? `Claude ${normalized.charAt(0).toUpperCase()}${normalized.slice(1)}` + ); +} + +/** + * Legacy top-level windows, kept as a fallback for responses without the + * structured `limits` array. Only these ids qualify: the response also + * carries codenamed experiment slots (`nimbus_quill`, `tangelo`, ...) in the + * same shape, and those are provider-internal, not user-meaningful windows. + */ +const LEGACY_WINDOWS: readonly { id: string; label: string; detail: string }[] = [ + { id: "five_hour", label: "Session limit", detail: "Rolling 5-hour window" }, + { id: "seven_day", label: "Weekly limit", detail: "All models · rolling 7-day window" }, + { id: "seven_day_opus", label: "Weekly limit (Opus)", detail: "Rolling 7-day window" }, + { id: "seven_day_sonnet", label: "Weekly limit (Sonnet)", detail: "Rolling 7-day window" }, +]; + +/** `weekly_all` → "Weekly all". */ +function humanizeId(id: string): string { + const words = id.replaceAll(/[_-]+/g, " ").trim(); + return words.length === 0 ? id : `${words.charAt(0).toUpperCase()}${words.slice(1)}`; +} + +/** + * Percent consumed. Clamped, not rounded: the provider may legitimately sit + * fractionally above 100 at the edge of a window. + */ +function clampUtilization(value: number): number { + return Math.min(Math.max(value, 0), 999); +} + +function readInstant(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +/** The model display name on a `weekly_scoped` limit entry, e.g. "Fable". */ +function readScopedModelName(scope: unknown): string | null { + if (typeof scope !== "object" || scope === null) return null; + const model = (scope as Record).model; + if (typeof model !== "object" || model === null) return null; + const name = (model as Record).display_name; + return typeof name === "string" && name.trim().length > 0 ? name.trim() : null; +} + +function labelForLimitKind( + kind: string, + scopeName: string | null, +): { label: string; detail: string | null } { + switch (kind) { + case "session": + return { label: "Session limit", detail: "Rolling 5-hour window" }; + case "weekly_all": + return { label: "Weekly limit", detail: "All models · rolling 7-day window" }; + case "weekly_scoped": + return { + label: scopeName === null ? "Weekly limit (scoped)" : `Weekly limit (${scopeName})`, + detail: "Rolling 7-day window", + }; + default: + return { + label: scopeName === null ? humanizeId(kind) : `${humanizeId(kind)} (${scopeName})`, + detail: null, + }; + } +} + +/** + * The extra-usage credit budget, rendered as a window when the account has + * one. It is spend, not a rate window, so the caption carries the money + * figures instead of a reset cadence. + */ +function parseExtraUsage(value: unknown): UsageLimitWindow | null { + if (typeof value !== "object" || value === null) return null; + const { utilization, used_credits, monthly_limit, currency, decimal_places, is_enabled } = + value as Record; + if (typeof utilization !== "number" || !Number.isFinite(utilization)) return null; + if (utilization <= 0 && is_enabled !== true) return null; + + let detail: string | null = null; + if ( + typeof used_credits === "number" && + typeof monthly_limit === "number" && + typeof decimal_places === "number" && + Number.isInteger(decimal_places) && + decimal_places >= 0 && + decimal_places <= 4 + ) { + const scale = 10 ** decimal_places; + const format = new Intl.NumberFormat("en-US", { + style: "currency", + currency: typeof currency === "string" && currency.length === 3 ? currency : "USD", + }); + detail = `${format.format(used_credits / scale)} of ${format.format(monthly_limit / scale)} monthly usage credits`; + } + + return { + id: "extra_usage", + label: "Extra usage credits", + detail, + utilization: clampUtilization(utilization), + resetsAt: null, + }; +} + +/** + * Extracts rate windows from the OAuth usage response. + * + * The structured `limits` array is authoritative when present: it is the only + * place model-scoped weekly windows (e.g. Fable) appear, and its entries are + * curated rather than experiment slots. Responses without it fall back to the + * known legacy top-level keys. The extra-usage credit budget is appended + * either way. + */ +export function parseClaudeUsageWindows(document: unknown): UsageLimitWindow[] { + if (typeof document !== "object" || document === null) return []; + const record = document as Record; + + const windows: UsageLimitWindow[] = []; + + if (Array.isArray(record.limits)) { + for (const entry of record.limits) { + if (typeof entry !== "object" || entry === null) continue; + const { kind, percent, resets_at, scope } = entry as Record; + if (typeof percent !== "number" || !Number.isFinite(percent)) continue; + const kindId = typeof kind === "string" && kind.trim().length > 0 ? kind.trim() : "unknown"; + const scopeName = readScopedModelName(scope); + windows.push({ + id: scopeName === null ? kindId : `${kindId}:${scopeName}`, + ...labelForLimitKind(kindId, scopeName), + utilization: clampUtilization(percent), + resetsAt: readInstant(resets_at), + }); + } + } + + if (windows.length === 0) { + for (const { id, label, detail } of LEGACY_WINDOWS) { + const value = record[id]; + if (typeof value !== "object" || value === null) continue; + const { utilization, resets_at } = value as Record; + if (typeof utilization !== "number" || !Number.isFinite(utilization)) continue; + windows.push({ + id, + label, + detail, + utilization: clampUtilization(utilization), + resetsAt: readInstant(resets_at), + }); + } + } + + const extraUsage = parseExtraUsage(record.extra_usage); + if (extraUsage !== null) windows.push(extraUsage); + return windows; +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 226c82cdb1ac..4b9045430c74 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -118,6 +118,7 @@ import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as UsageService from "./usage/UsageService.ts"; +import * as UsageLimitsService from "./usage/UsageLimitsService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; @@ -520,6 +521,7 @@ const makeWsRpcLayer = ( const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const usage = yield* UsageService.UsageService; + const usageLimits = yield* UsageLimitsService.UsageLimitsService; const relayClient = yield* RelayClient.RelayClient; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ @@ -1723,6 +1725,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetUsageSummary, usage.readSummary(input), { "rpc.aggregate": "server", }), + [WS_METHODS.serverGetUsageLimits]: (_input) => + observeRpcEffect(WS_METHODS.serverGetUsageLimits, usageLimits.readLimits(), { + "rpc.aggregate": "server", + }), [WS_METHODS.serverRetryResourceTelemetry]: (_input) => observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, { "rpc.aggregate": "server", diff --git a/apps/web/src/components/usage/UsageLimitsContent.tsx b/apps/web/src/components/usage/UsageLimitsContent.tsx new file mode 100644 index 000000000000..959b842f134d --- /dev/null +++ b/apps/web/src/components/usage/UsageLimitsContent.tsx @@ -0,0 +1,292 @@ +import type { UsageLimitWindow, UsageProviderKind } from "@t3tools/contracts"; +import { RefreshCwIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { formatDateTimeShort } from "@t3tools/shared/usageFormat"; + +import { cn } from "../../lib/utils"; +import { + useUsageLimits, + type EnvironmentUsageLimitsStatus, + type ProviderLimitsStatus, +} from "../../state/usage"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { PROVIDER_PRESENTATION } from "./usageProviders"; + +/** + * Claude leads: it is the only provider reporting limits today, and the page + * introduces the others as placeholders in this order as support lands. + */ +const LIMITS_PROVIDER_ORDER: readonly UsageProviderKind[] = ["claude", "codex"]; + +/** + * The "Limits" half of the usage page: how much of each subscription rate + * window is consumed, one card per provider. + * + */ +export function UsageLimitsContent() { + const { providers, environments, isPending, isPartial, refresh } = useUsageLimits(); + + // Same settling rule as the usage view: hold until every environment is + // terminal so cards do not pop in one at a time. + const settling = isPending || isPartial; + + // Reset countdowns must keep moving while the page sits open; a frozen + // "in 4m" that is long past is worse than no countdown at all. + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + const timer = setInterval(() => setNowMs(Date.now()), 60_000); + return () => clearInterval(timer); + }, []); + + const answered = environments.some((environment) => environment.summary !== null); + const ordered = LIMITS_PROVIDER_ORDER.flatMap((provider) => + providers.filter((candidate) => candidate.provider === provider), + ); + const unreported = LIMITS_PROVIDER_ORDER.filter( + (provider) => !providers.some((candidate) => candidate.provider === provider), + ); + + return ( + <> +
+

+ How much of each plan's rate windows is used right now. +

+ +
+ + {settling ? ( + + ) : !answered ? ( + // Without a single answer, "coming soon" cards would misread as "no + // provider supports this"; say what actually happened instead. + <> + +

+ No connected environment reported limits. +

+ + ) : ( + <> + +
+ {ordered.map((entry) => ( + 1} + /> + ))} + {unreported.map((provider) => ( + + ))} +
+ + )} + + ); +} + +function ProviderLimitsCard({ + entry, + nowMs, + multiEnvironment, +}: { + readonly entry: ProviderLimitsStatus; + readonly nowMs: number; + readonly multiEnvironment: boolean; +}) { + const { provider, limits } = entry; + const Mark = PROVIDER_PRESENTATION[provider].mark; + return ( +
+
+ + + {PROVIDER_PRESENTATION[provider].label} + + {limits.plan !== null ? ( + + {limits.plan} + + ) : null} +
+ + {limits.availability === "available" ? ( +
+ {limits.windows.map((window) => ( + + ))} +
+ ) : ( +

+ {limits.message ?? "This provider did not report limits."} +

+ )} + + {multiEnvironment ? ( + + Reported by {entry.environmentLabels.join(", ")} + + ) : null} +
+ ); +} + +function LimitWindowRow({ + provider, + window, + nowMs, +}: { + readonly provider: UsageProviderKind; + readonly window: UsageLimitWindow; + readonly nowMs: number; +}) { + const resetsIn = window.resetsAt === null ? null : formatResetsIn(window.resetsAt, nowMs); + const caption = [window.detail, resetsIn === null ? null : `Resets ${resetsIn}`] + .filter((part) => part !== null) + .join(" · "); + return ( +
+
+ {window.label} + = 90 + ? "text-destructive" + : window.utilization >= 75 + ? "text-warning" + : "text-foreground", + )} + > + {Math.round(window.utilization)}% used + +
+
+
+
+ {caption.length > 0 ? ( + window.resetsAt === null ? ( + {caption} + ) : ( + + {caption}} + /> + {formatDateTimeShort(window.resetsAt)} + + ) + ) : null} +
+ ); +} + +/** Provider brand color until the window runs hot, then the alert tokens. */ +function utilizationColor(provider: UsageProviderKind, utilization: number): string { + if (utilization >= 90) return "var(--color-destructive)"; + if (utilization >= 75) return "var(--color-warning)"; + return PROVIDER_PRESENTATION[provider].color; +} + +/** "in 3h 12m" / "in 2d 4h" / "in under a minute". */ +function formatResetsIn(resetsAt: string, nowMs: number): string | null { + const resetMs = Date.parse(resetsAt); + if (Number.isNaN(resetMs)) return null; + // A reset instant in the past means the figures themselves are stale; + // dropping the countdown beats counting up from a moment that already + // happened. + if (resetMs <= nowMs) return null; + const remainingMinutes = Math.floor((resetMs - nowMs) / 60_000); + if (remainingMinutes < 1) return "in under a minute"; + const days = Math.floor(remainingMinutes / (60 * 24)); + const hours = Math.floor((remainingMinutes % (60 * 24)) / 60); + const minutes = remainingMinutes % 60; + if (days > 0) return `in ${days}d ${hours}h`; + if (hours > 0) return `in ${hours}h ${minutes}m`; + return `in ${minutes}m`; +} + +function UpcomingProviderCard({ provider }: { readonly provider: UsageProviderKind }) { + const Mark = PROVIDER_PRESENTATION[provider].mark; + return ( +
+ + + {PROVIDER_PRESENTATION[provider].label} + +

+ Limit tracking for {PROVIDER_PRESENTATION[provider].label} is not wired up yet. +

+
+ ); +} + +/** Environments that could not answer, in the usage view's notice style. */ +function UsageLimitsCoverageNotice({ + environments, +}: { + readonly environments: readonly EnvironmentUsageLimitsStatus[]; +}) { + const failed = environments.filter((environment) => environment.error !== null); + if (failed.length === 0) return null; + return ( +
+ {failed.map((environment) => ( + + {environment.label} could not report limits. It may run an older server version. + + ))} +
+ ); +} + +/** Deterministic widths, mirroring the loaded cards' shape. */ +const SKELETON_BAR_WIDTHS = [62, 34, 81]; + +function UsageLimitsSkeleton() { + return ( +
+ {LIMITS_PROVIDER_ORDER.map((provider) => { + const Mark = PROVIDER_PRESENTATION[provider].mark; + return ( +
+ + + {PROVIDER_PRESENTATION[provider].label} + +
+ {SKELETON_BAR_WIDTHS.map((width) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 7474bb9d6120..775e45979450 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -31,6 +31,7 @@ import { } from "../WorkspaceBreadcrumb"; import { WorkspacePageContainer } from "../WorkspacePageContainer"; import { WorkspacePageHeader } from "../WorkspacePageHeader"; +import { UsageLimitsContent } from "./UsageLimitsContent"; import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; @@ -41,6 +42,17 @@ const WINDOW_OPTIONS = [ { days: 90, label: "90 days" }, ] as const; +/** + * The page's two halves: what the sessions consumed (usage) versus how much + * of the subscription's rate windows is left (limits). + */ +type UsagePageView = "usage" | "limits"; + +const VIEW_OPTIONS = [ + { value: "usage", label: "Usage" }, + { value: "limits", label: "Limits" }, +] as const satisfies readonly { value: UsagePageView; label: string }[]; + export function UsagePage() { const [windowSelection, setWindowSelection] = useState(() => ({ days: 30, @@ -48,6 +60,7 @@ export function UsagePage() { })); const [metric, setMetric] = useState("cost"); const [breakdown, setBreakdown] = useState<"model" | "time">("model"); + const [view, setView] = useState("usage"); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; const { merged, environments, isPending, isPartial, refresh } = useUsage(window); @@ -115,89 +128,111 @@ export function UsagePage() {

Usage

- - - {windowLabel} - + {view === "usage" ? ( + <> + + + {windowLabel} + + + ) : null} -
- { - const value = next[0]; - if (value === "cost" || value === "tokens") setMetric(value); - }} - > - {(["cost", "tokens"] as const).map((option) => ( - - {option === "cost" ? "Cost" : "Tokens"} - - ))} - - { - const value = next[0]; - if (value) selectWindow(Number(value)); - }} - > - {WINDOW_OPTIONS.map((option) => ( - - {option.label} - - ))} - - -
-
- - - -
+ + {view === "usage" ? ( + <> +
+ { + const value = next[0]; + if (value === "cost" || value === "tokens") setMetric(value); + }} + > + {(["cost", "tokens"] as const).map((option) => ( + + {option === "cost" ? "Cost" : "Tokens"} + + ))} + + { + const value = next[0]; + if (value) selectWindow(Number(value)); + }} + > + {WINDOW_OPTIONS.map((option) => ( + + {option.label} + + ))} + + +
+
+ + + +
+ + ) : null}
); @@ -208,7 +243,9 @@ export function UsagePage() { - {settling ? ( + {view === "limits" ? ( + + ) : settling ? ( <> {environments.length > 1 ? : null} @@ -464,6 +501,33 @@ export function UsagePage() { ); } +/** Switches between the consumption view and the rate-limit view. */ +function UsageViewToggle({ + view, + onViewChange, +}: { + readonly view: UsagePageView; + readonly onViewChange: (view: UsagePageView) => void; +}) { + return ( + { + const value = next[0]; + if (value === "usage" || value === "limits") onViewChange(value); + }} + > + {VIEW_OPTIONS.map((option) => ( + + {option.label} + + ))} + + ); +} + /** Brand mark for the harness a row belongs to. */ function ProviderMark({ provider, diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index ba78a61d8a88..723d2224fce6 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -9,7 +9,11 @@ import { useAtomValue } from "@effect/atom-react"; import { USAGE_CONTRACT_VERSION, + USAGE_LIMITS_CONTRACT_VERSION, type EnvironmentId, + type ProviderUsageLimits, + type UsageLimitsSummary, + type UsageProviderKind, type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; @@ -134,3 +138,150 @@ export function useUsage(input: UsageSummaryInput): UsageView { refresh, }; } + +export interface EnvironmentUsageLimitsStatus { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly isPending: boolean; + readonly error: string | null; + readonly summary: UsageLimitsSummary | null; +} + +const usageLimitsAtom = Atom.make((get): readonly EnvironmentUsageLimitsStatus[] => { + const presentations = get(environmentPresentations.presentationsAtom); + + const statuses: EnvironmentUsageLimitsStatus[] = []; + for (const [environmentId, presentation] of presentations) { + const result = get(serverEnvironment.usageLimits({ environmentId, input: {} })); + const summary = Option.getOrNull(AsyncResult.value(result)); + // Version bumps are incompatible in either direction, and a mismatch must + // be terminal: a null summary with a null error would read as "still + // answering" and hold the page on its skeleton forever. + const incompatible = + summary !== null && summary.contractVersion !== USAGE_LIMITS_CONTRACT_VERSION; + statuses.push({ + environmentId, + label: presentation.entry.target.label, + isPending: result.waiting, + error: + result._tag === "Failure" + ? "This environment could not report limits." + : incompatible + ? "This environment runs an incompatible server version." + : null, + summary: summary !== null && !incompatible ? summary : null, + }); + } + return statuses; +}).pipe(Atom.withLabel("web-usage:limits")); + +export interface ProviderLimitsStatus { + readonly provider: UsageProviderKind; + /** The figures this card renders. */ + readonly limits: ProviderUsageLimits; + /** Environments whose answers this card covers. */ + readonly environmentLabels: readonly string[]; +} + +export interface UsageLimitsView { + readonly providers: readonly ProviderLimitsStatus[]; + readonly environments: readonly EnvironmentUsageLimitsStatus[]; + /** True until at least one environment has answered. */ + readonly isPending: boolean; + /** True while environments that have not failed are still answering. */ + readonly isPartial: boolean; + readonly refresh: () => void; +} + +/** + * Ranks a provider's answers across environments: real figures beat every + * failure mode, and a broken sign-in is more actionable than "API key". + */ +const AVAILABILITY_RANK: Record = { + available: 0, + unauthenticated: 1, + unavailable: 2, + unsupported: 3, +}; + +export function useUsageLimits(): UsageLimitsView { + const environments = useAtomValue(usageLimitsAtom); + + const refresh = useCallback(() => { + for (const environment of environments) { + appAtomRegistry.refresh( + serverEnvironment.usageLimits({ environmentId: environment.environmentId, input: {} }), + ); + } + }, [environments]); + + // One card per provider account. Environments sharing one machine + // (worktree servers) resolve the same credentials and must not repeat the + // card, but two environments signed into different accounts must both stay + // visible: hiding one could hide the account that is about to hit a limit. + // Reset instants identify the account well enough for that grouping - the + // windows follow the account's own clock, while utilization drifts between + // fetches. Failure answers only surface when no environment produced + // figures for the provider, best-ranked first. + const providers = useMemo(() => { + interface ProviderMerge { + readonly accounts: Map; + fallback: { limits: ProviderUsageLimits; labels: string[] } | null; + } + const byProvider = new Map(); + for (const environment of environments) { + if (environment.summary === null) continue; + for (const limits of environment.summary.providers) { + let merge = byProvider.get(limits.provider); + if (merge === undefined) { + merge = { accounts: new Map(), fallback: null }; + byProvider.set(limits.provider, merge); + } + if (limits.availability === "available") { + const accountKey = JSON.stringify([ + limits.plan, + limits.windows.map((window) => [window.id, window.resetsAt]), + ]); + const account = merge.accounts.get(accountKey); + if (account === undefined) { + merge.accounts.set(accountKey, { limits, labels: [environment.label] }); + } else { + account.labels.push(environment.label); + } + } else if ( + merge.fallback === null || + AVAILABILITY_RANK[limits.availability] < + AVAILABILITY_RANK[merge.fallback.limits.availability] + ) { + merge.fallback = { limits, labels: [environment.label] }; + } + } + } + const cards: ProviderLimitsStatus[] = []; + for (const [provider, merge] of byProvider) { + const entries = + merge.accounts.size > 0 + ? [...merge.accounts.values()] + : merge.fallback === null + ? [] + : [merge.fallback]; + for (const entry of entries) { + cards.push({ provider, limits: entry.limits, environmentLabels: entry.labels }); + } + } + return cards; + }, [environments]); + + const answeredCount = environments.filter((environment) => environment.summary !== null).length; + const stillReporting = environments.filter( + (environment) => environment.summary === null && environment.error === null, + ).length; + + return { + providers, + environments, + isPending: answeredCount === 0 && stillReporting > 0, + isPartial: answeredCount > 0 && stillReporting > 0, + refresh, + }; +} diff --git a/docs/user/usage.md b/docs/user/usage.md index ff38c730c1cd..9de419061656 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -11,3 +11,8 @@ completed-turn record will not appear. 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. + +The **Limits** view shows how much of each subscription plan's rate windows is currently used, +with reset countdowns per window. Limit info is only available for subscription sign-ins: API-key +authentication is billed per token and has no rate windows, so those providers show a notice +instead. Claude Code reports limits today; other providers will follow. diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 2fef689a9bbb..2f48a3b176eb 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -728,6 +728,13 @@ export function createServerEnvironmentAtoms( tag: WS_METHODS.serverGetUsageSummary, staleTimeMs: 60_000, }), + // Limit figures move slowly and the provider endpoint is rate limited + // itself; a minute of staleness is invisible next to a 5-hour window. + usageLimits: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:usage-limits", + tag: WS_METHODS.serverGetUsageLimits, + staleTimeMs: 60_000, + }), configProjection, welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:welcome", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c6daef8687ba..1c5ff7af9e45 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -30,4 +30,5 @@ export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; export * from "./usage.ts"; +export * from "./usageLimits.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 14363cfedff9..eddee0bcc3f3 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -193,6 +193,7 @@ import { ResourceTelemetrySnapshot, } from "./resourceTelemetry.ts"; import { UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; +import { UsageLimitsInput, UsageLimitsSummary } from "./usageLimits.ts"; import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; import { SourceControlCloneRepositoryInput, @@ -291,6 +292,7 @@ export const WS_METHODS = { serverReportHostPowerState: "server.reportHostPowerState", serverGetBackgroundPolicy: "server.getBackgroundPolicy", serverGetUsageSummary: "server.getUsageSummary", + serverGetUsageLimits: "server.getUsageLimits", // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", @@ -453,6 +455,12 @@ export const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSumm error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]), }); +export const WsServerGetUsageLimitsRpc = Rpc.make(WS_METHODS.serverGetUsageLimits, { + payload: UsageLimitsInput, + success: UsageLimitsSummary, + error: EnvironmentAuthorizationError, +}); + export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { payload: ServerSignalProcessInput, success: ServerSignalProcessResult, @@ -1035,6 +1043,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetResourceTelemetryHistoryRpc, WsServerRetryResourceTelemetryRpc, WsServerGetUsageSummaryRpc, + WsServerGetUsageLimitsRpc, WsServerSignalProcessRpc, WsServerReportClientActivityRpc, WsServerReportHostPowerStateRpc, diff --git a/packages/contracts/src/usageLimits.ts b/packages/contracts/src/usageLimits.ts new file mode 100644 index 000000000000..41493cc94afc --- /dev/null +++ b/packages/contracts/src/usageLimits.ts @@ -0,0 +1,88 @@ +/** + * Usage limits contract. + * + * Environments report how much of each provider's subscription rate windows + * is currently consumed (the figures behind "you are approaching your weekly + * limit"). Limits only exist for subscription sign-ins: API keys are billed + * per token and have no windows, so those providers answer with an + * `unsupported` availability instead of numbers. + * + * Failures travel in-band per provider rather than failing the RPC: one + * provider's expired login must not blank the others. + * + * @module usageLimits + */ +import * as Schema from "effect/Schema"; + +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { UsageProviderKind } from "./usage.ts"; + +/** + * Bumped whenever the shape of {@link UsageLimitsSummary} changes + * incompatibly. Clients drop environments reporting an older version from the + * merged view rather than failing the page. + */ +export const USAGE_LIMITS_CONTRACT_VERSION = 1 as const; + +/** + * One rolling rate window, e.g. Claude's 5-hour session window or its weekly + * all-model window. + */ +export const UsageLimitWindow = Schema.Struct({ + /** Stable provider-side identifier, e.g. `five_hour`, `seven_day`. */ + id: TrimmedNonEmptyString, + /** Human label the client renders, e.g. "Current session". */ + label: TrimmedNonEmptyString, + /** Cadence detail, e.g. "Resets every 5 hours". Null when unknown. */ + detail: Schema.NullOr(TrimmedNonEmptyString), + /** + * Percent of the window consumed, usually 0-100. Providers may briefly + * report slightly more than 100 at the edge of a window. + */ + utilization: Schema.Number, + /** ISO instant the window resets, when the provider reports one. */ + resetsAt: Schema.NullOr(Schema.String), +}); +export type UsageLimitWindow = typeof UsageLimitWindow.Type; + +/** + * Whether limit figures exist for a provider on this environment. + * + * - `available` - subscription sign-in with windows to show. + * - `unsupported` - authenticated, but not through a subscription (API key, + * Bedrock, ...): there are no limit windows to report. + * - `unauthenticated` - no usable credentials, or the provider rejected them. + * - `unavailable` - credentials looked fine but the read failed (network, + * unexpected response shape). + */ +export const UsageLimitsAvailability = Schema.Literals([ + "available", + "unsupported", + "unauthenticated", + "unavailable", +]); +export type UsageLimitsAvailability = typeof UsageLimitsAvailability.Type; + +export const ProviderUsageLimits = Schema.Struct({ + provider: UsageProviderKind, + availability: UsageLimitsAvailability, + /** Subscription plan label, e.g. "Claude Max". Null when unknown. */ + plan: Schema.NullOr(TrimmedNonEmptyString), + /** Empty unless `availability` is `available`. */ + windows: Schema.Array(UsageLimitWindow), + /** + * Why there are no figures, phrased for the page. Null when `available`. + */ + message: Schema.NullOr(TrimmedNonEmptyString), +}); +export type ProviderUsageLimits = typeof ProviderUsageLimits.Type; + +export const UsageLimitsInput = Schema.Struct({}); +export type UsageLimitsInput = typeof UsageLimitsInput.Type; + +export const UsageLimitsSummary = Schema.Struct({ + contractVersion: Schema.Number, + readAt: Schema.String, + providers: Schema.Array(ProviderUsageLimits), +}); +export type UsageLimitsSummary = typeof UsageLimitsSummary.Type; From e8bfe097d9e3eb211df346327d3e09e964d33f27 Mon Sep 17 00:00:00 2001 From: Ahmed Shareef Date: Fri, 28 Aug 2026 13:41:01 +0400 Subject: [PATCH 02/10] feat(usage): add Codex subscription limit reporting - Read Codex rate limits through its app server - Add auth parsing, plan labels, window mapping, and tests --- apps/server/src/usage/UsageLimitsService.ts | 137 ++++++++++++-- .../server/src/usage/usageLimitsCodex.test.ts | 126 +++++++++++++ apps/server/src/usage/usageLimitsCodex.ts | 177 ++++++++++++++++++ docs/user/usage.md | 2 +- 4 files changed, 427 insertions(+), 15 deletions(-) create mode 100644 apps/server/src/usage/usageLimitsCodex.test.ts create mode 100644 apps/server/src/usage/usageLimitsCodex.ts diff --git a/apps/server/src/usage/UsageLimitsService.ts b/apps/server/src/usage/UsageLimitsService.ts index 8de0f91f126e..0b69aa0dd990 100644 --- a/apps/server/src/usage/UsageLimitsService.ts +++ b/apps/server/src/usage/UsageLimitsService.ts @@ -3,11 +3,12 @@ * * Where {@link UsageService} answers "what did my sessions cost", this service * answers "how close am I to being rate limited". The figures only exist for - * subscription sign-ins, so the service reuses the provider CLI's own OAuth - * grant (credential file under the Claude home, or the macOS login keychain) - * and asks Anthropic's OAuth usage endpoint. API-key auth has no rate - * windows; those environments answer `unsupported` in-band instead of - * failing the RPC. + * subscription sign-ins, so each provider read reuses the provider CLI's own + * credentials: Claude's OAuth grant (credential file under the Claude home, + * or the macOS login keychain) against Anthropic's OAuth usage endpoint, and + * Codex's ChatGPT sign-in via a short-lived `codex app-server` asked for + * `account/rateLimits/read`. API-key auth has no rate windows; those + * providers answer `unsupported` in-band instead of failing the RPC. * * @module UsageLimitsService */ @@ -15,6 +16,7 @@ import { USAGE_LIMITS_CONTRACT_VERSION, type ProviderUsageLimits, type UsageLimitsSummary, + type UsageProviderKind, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -25,17 +27,22 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as CodexClient from "effect-codex-app-server/client"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as ServerSettings from "../serverSettings.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { codexAppServerArgs } from "../provider/Layers/codexLaunchArgs.ts"; import { claudePlanLabel, parseClaudeOauthCredentials, parseClaudeUsageWindows, } from "./usageLimitsClaude.ts"; +import { codexPlanLabel, mapCodexRateLimits, parseCodexAuthKind } from "./usageLimitsCodex.ts"; const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; @@ -47,6 +54,13 @@ const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials"; const REQUEST_TIMEOUT_MS = 10_000; +/** + * Covers spawning `codex app-server`, the initialize handshake and one read. + * The probe in CodexProvider budgets similarly for the same round trip. + */ +const CODEX_APP_SERVER_TIMEOUT_MS = 15_000; +const CODEX_APP_SERVER_FORCE_KILL_AFTER = "2 seconds" as const; + const SUBSCRIPTION_ONLY_MESSAGE = "Limit info is only available for subscription sign-ins. API usage is billed per token and has no rate windows."; @@ -70,14 +84,16 @@ export const layerTest = Layer.succeed( }), ); -function claudeLimits( - availability: ProviderUsageLimits["availability"], - plan: string | null, - message: string | null, - windows: ProviderUsageLimits["windows"] = [], -): ProviderUsageLimits { - return { provider: "claude", availability, plan, windows, message }; +function makeProviderLimits(provider: UsageProviderKind) { + return ( + availability: ProviderUsageLimits["availability"], + plan: string | null, + message: string | null, + windows: ProviderUsageLimits["windows"] = [], + ): ProviderUsageLimits => ({ provider, availability, plan, windows, message }); } +const claudeLimits = makeProviderLimits("claude"); +const codexLimits = makeProviderLimits("codex"); export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -204,13 +220,106 @@ export const make = Effect.gen(function* () { return claudeLimits("available", plan, null, windows); }); + /** + * Spawns a short-lived `codex app-server` and asks it for the account's + * rate windows. No thread is needed: the read answers right after the + * initialize handshake. Lifetime is scope-bound; the timeout and + * force-kill bound a hung binary. + */ + const requestCodexRateLimits = Effect.fn("UsageLimitsService.requestCodexRateLimits")( + function* (input: { + readonly binaryPath: string; + readonly homePath: string | undefined; + readonly launchArgs: string; + }) { + const environment = input.homePath === undefined ? {} : { CODEX_HOME: input.homePath }; + const spawnCommand = yield* resolveSpawnCommand( + input.binaryPath, + codexAppServerArgs(input.launchArgs), + { env: environment, extendEnv: true }, + ); + const child = yield* spawner.spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + extendEnv: true, + forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, + shell: spawnCommand.shell, + }), + ); + const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); + const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( + Effect.provide(clientContext), + ); + yield* client.request("initialize", { + clientInfo: { name: "t3code_server", title: "T3 Code", version: "0.1.0" }, + capabilities: { experimentalApi: true }, + }); + yield* client.notify("initialized", undefined); + return yield* client.request("account/rateLimits/read", undefined); + }, + Effect.scoped, + Effect.timeoutOption(CODEX_APP_SERVER_TIMEOUT_MS), + (effect) => + effect.pipe( + Effect.map(Option.getOrNull), + Effect.orElseSucceed(() => null), + ), + ); + + const readCodexLimits = Effect.fn("UsageLimitsService.readCodexLimits")(function* () { + const settings = yield* settingsService.getSettings.pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + if (settings === null) { + return codexLimits("unavailable", null, "Server settings could not be read."); + } + const codexSettings = settings.providers.codex; + const layout = yield* resolveCodexHomeLayout(codexSettings).pipe( + Effect.provideService(Path.Path, path), + ); + // Credentials live in the auth home: the shadow home in authOverlay mode, + // unlike transcripts, which UsageService reads from the shared home. + const authHome = layout.effectiveHomePath ?? layout.sharedHomePath; + const raw = yield* fileSystem + .readFileString(path.join(authHome, "auth.json")) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + const authKind = raw === null ? "none" : parseCodexAuthKind(raw); + if (authKind === "apiKey") { + return codexLimits("unsupported", null, SUBSCRIPTION_ONLY_MESSAGE); + } + if (authKind === "none") { + return codexLimits("unauthenticated", null, "Codex is not signed in on this environment."); + } + + const response = yield* requestCodexRateLimits({ + binaryPath: codexSettings.binaryPath, + homePath: layout.effectiveHomePath, + launchArgs: codexSettings.launchArgs, + }); + if (response === null) { + return codexLimits("unavailable", null, "Codex's app server could not be reached."); + } + const { windows, planType } = mapCodexRateLimits(response); + const plan = codexPlanLabel(planType); + if (windows.length === 0) { + return codexLimits( + "unavailable", + plan, + "Codex answered in a shape this version does not understand.", + ); + } + return codexLimits("available", plan, null, windows); + }); + const readLimits = Effect.fn("UsageLimitsService.readLimits")(function* () { - const claude = yield* readClaudeLimits(); + const [claude, codex] = yield* Effect.all([readClaudeLimits(), readCodexLimits()], { + concurrency: "unbounded", + }); const readAt = yield* DateTime.now; return { contractVersion: USAGE_LIMITS_CONTRACT_VERSION, readAt: DateTime.formatIso(readAt), - providers: [claude], + providers: [claude, codex], } satisfies UsageLimitsSummary; }); diff --git a/apps/server/src/usage/usageLimitsCodex.test.ts b/apps/server/src/usage/usageLimitsCodex.test.ts new file mode 100644 index 000000000000..e9193f46ee9a --- /dev/null +++ b/apps/server/src/usage/usageLimitsCodex.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; + +import { codexPlanLabel, mapCodexRateLimits, parseCodexAuthKind } from "./usageLimitsCodex.ts"; + +describe("parseCodexAuthKind", () => { + it("recognises a ChatGPT sign-in", () => { + expect( + parseCodexAuthKind( + JSON.stringify({ + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { id_token: "a", access_token: "b", refresh_token: "c", account_id: "d" }, + }), + ), + ).toBe("chatgpt"); + expect(parseCodexAuthKind(JSON.stringify({ tokens: { access_token: "b" } }))).toBe("chatgpt"); + }); + + it("recognises API-key auth", () => { + expect(parseCodexAuthKind(JSON.stringify({ OPENAI_API_KEY: "sk-test" }))).toBe("apiKey"); + }); + + it("treats everything else as signed out", () => { + expect(parseCodexAuthKind("not json")).toBe("none"); + expect(parseCodexAuthKind("{}")).toBe("none"); + expect(parseCodexAuthKind(JSON.stringify({ OPENAI_API_KEY: " " }))).toBe("none"); + }); +}); + +describe("codexPlanLabel", () => { + it("maps known plans and humanises unknown ones", () => { + expect(codexPlanLabel("prolite")).toBe("ChatGPT Pro 5x"); + expect(codexPlanLabel("pro")).toBe("ChatGPT Pro 20x"); + expect(codexPlanLabel("plus")).toBe("ChatGPT Plus"); + expect(codexPlanLabel("unknown")).toBe("ChatGPT"); + expect(codexPlanLabel("megaplan")).toBe("ChatGPT Megaplan"); + expect(codexPlanLabel(null)).toBeNull(); + }); +}); + +describe("mapCodexRateLimits", () => { + // Shape observed live from `codex app-server` v0.147.0. + const liveResponse = { + rateLimits: { + limitId: "codex", + primary: { usedPercent: 10, windowDurationMins: 10080, resetsAt: 1_787_033_397 }, + secondary: null, + credits: { hasCredits: false, unlimited: false, balance: "0" }, + spendControlReached: false, + planType: "prolite", + rateLimitReachedType: null, + }, + rateLimitsByLimitId: { + codex: { + limitId: "codex", + limitName: null, + primary: { usedPercent: 10, windowDurationMins: 10080, resetsAt: 1_787_033_397 }, + secondary: null, + planType: "prolite", + }, + codex_bengalfox: { + limitId: "codex_bengalfox", + limitName: "GPT-5.3-Codex-Spark", + primary: { usedPercent: 0, windowDurationMins: 10080, resetsAt: 1_787_033_397 }, + secondary: null, + }, + }, + rateLimitResetCredits: { availableCount: 0, credits: [] }, + }; + + it("prefers the multi-bucket view and names scoped buckets", () => { + const { windows, planType } = mapCodexRateLimits(liveResponse); + expect(planType).toBe("prolite"); + expect(windows).toEqual([ + { + id: "codex:primary", + label: "Weekly limit", + detail: "Rolling 7-day window", + utilization: 10, + resetsAt: DateTime.formatIso(DateTime.makeUnsafe(1_787_033_397 * 1000)), + }, + { + id: "codex_bengalfox:primary", + label: "Weekly limit (GPT-5.3-Codex-Spark)", + detail: "Rolling 7-day window", + utilization: 0, + resetsAt: DateTime.formatIso(DateTime.makeUnsafe(1_787_033_397 * 1000)), + }, + ]); + }); + + it("falls back to the single-bucket view and titles session windows", () => { + const { windows, planType } = mapCodexRateLimits({ + rateLimits: { + primary: { usedPercent: 42.5, windowDurationMins: 300, resetsAt: 1_787_000_000 }, + secondary: { usedPercent: 8, windowDurationMins: 10080, resetsAt: null }, + planType: "plus", + }, + }); + expect(planType).toBe("plus"); + expect(windows).toEqual([ + { + id: "codex:primary", + label: "Session limit", + detail: "Rolling 5-hour window", + utilization: 42.5, + resetsAt: DateTime.formatIso(DateTime.makeUnsafe(1_787_000_000 * 1000)), + }, + { + id: "codex:secondary", + label: "Weekly limit", + detail: "Rolling 7-day window", + utilization: 8, + resetsAt: null, + }, + ]); + }); + + it("returns empty for malformed documents", () => { + expect(mapCodexRateLimits(null).windows).toEqual([]); + expect( + mapCodexRateLimits({ rateLimits: { primary: { usedPercent: "high" } } }).windows, + ).toEqual([]); + }); +}); diff --git a/apps/server/src/usage/usageLimitsCodex.ts b/apps/server/src/usage/usageLimitsCodex.ts new file mode 100644 index 000000000000..91aa07753a64 --- /dev/null +++ b/apps/server/src/usage/usageLimitsCodex.ts @@ -0,0 +1,177 @@ +/** + * Pure parsing for Codex subscription limits. + * + * The auth kind comes from the Codex CLI's `auth.json` (a ChatGPT sign-in + * carries OAuth tokens; API-key auth carries only the key), and the figures + * come from the `codex app-server` RPC `account/rateLimits/read`. The mapper + * takes the response as `unknown` and reads it defensively: the same window + * shape arrives camelCased from the app server and the generated schema has + * been stricter than the wire in the past (integer-only percents), so the + * mapper must not depend on a successful decode. + * + * @module usageLimitsCodex + */ +import type { UsageLimitWindow } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; + +export type CodexAuthKind = "chatgpt" | "apiKey" | "none"; + +/** Classifies the CLI's `auth.json` document. */ +export function parseCodexAuthKind(raw: string): CodexAuthKind { + let document: unknown; + try { + document = JSON.parse(raw); + } catch { + return "none"; + } + if (typeof document !== "object" || document === null) return "none"; + const { auth_mode, OPENAI_API_KEY, tokens } = document as Record; + if (auth_mode === "chatgpt") return "chatgpt"; + if (typeof tokens === "object" && tokens !== null) { + const { access_token } = tokens as Record; + if (typeof access_token === "string" && access_token.trim().length > 0) return "chatgpt"; + } + if (typeof OPENAI_API_KEY === "string" && OPENAI_API_KEY.trim().length > 0) return "apiKey"; + return "none"; +} + +/** + * Plan chip labels, mirroring the provider snapshot's auth labels but without + * the "Subscription" suffix the compact chip has no room for. + */ +const PLAN_LABELS: Record = { + free: "ChatGPT Free", + go: "ChatGPT Go", + plus: "ChatGPT Plus", + pro: "ChatGPT Pro 20x", + prolite: "ChatGPT Pro 5x", + team: "ChatGPT Team", + self_serve_business_usage_based: "ChatGPT Business", + business: "ChatGPT Business", + enterprise_cbp_usage_based: "ChatGPT Enterprise", + enterprise: "ChatGPT Enterprise", + edu: "ChatGPT Edu", + unknown: "ChatGPT", +}; + +export function codexPlanLabel(planType: string | null): string | null { + if (planType === null) return null; + const normalized = planType.trim().toLowerCase(); + if (normalized.length === 0) return null; + return ( + PLAN_LABELS[normalized] ?? `ChatGPT ${normalized.charAt(0).toUpperCase()}${normalized.slice(1)}` + ); +} + +/** See {@link usageLimitsClaude}: clamped, not rounded. */ +function clampUtilization(value: number): number { + return Math.min(Math.max(value, 0), 999); +} + +const MINUTES_PER_DAY = 24 * 60; + +/** + * Codex windows carry a duration instead of a name; title by cadence so a + * 5-hour primary reads like Claude's session window and 10080 minutes reads + * as weekly. + */ +function windowTitle(durationMins: number | null): { label: string; detail: string | null } { + if (durationMins === null) return { label: "Rate limit", detail: null }; + if (durationMins < MINUTES_PER_DAY) { + const hours = Math.max(1, Math.round(durationMins / 60)); + return { label: "Session limit", detail: `Rolling ${hours}-hour window` }; + } + const days = Math.max(1, Math.round(durationMins / MINUTES_PER_DAY)); + if (days === 7) return { label: "Weekly limit", detail: "Rolling 7-day window" }; + return { label: `${days}-day limit`, detail: `Rolling ${days}-day window` }; +} + +interface RawWindow { + readonly usedPercent: number; + readonly windowDurationMins: number | null; + readonly resetsAt: string | null; +} + +/** One `primary`/`secondary` window object; unix seconds become ISO instants. */ +function readWindow(value: unknown): RawWindow | null { + if (typeof value !== "object" || value === null) return null; + const { usedPercent, windowDurationMins, resetsAt } = value as Record; + if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) return null; + return { + usedPercent, + windowDurationMins: + typeof windowDurationMins === "number" && Number.isFinite(windowDurationMins) + ? windowDurationMins + : null, + resetsAt: + typeof resetsAt === "number" && Number.isFinite(resetsAt) + ? DateTime.formatIso(DateTime.makeUnsafe(resetsAt * 1000)) + : null, + }; +} + +function snapshotWindows(limitId: string, snapshot: unknown): UsageLimitWindow[] { + if (typeof snapshot !== "object" || snapshot === null) return []; + const { primary, secondary, limitName } = snapshot as Record; + const name = + typeof limitName === "string" && limitName.trim().length > 0 ? limitName.trim() : null; + + const windows: UsageLimitWindow[] = []; + for (const [part, value] of [ + ["primary", primary], + ["secondary", secondary], + ] as const) { + const window = readWindow(value); + if (window === null) continue; + const title = windowTitle(window.windowDurationMins); + windows.push({ + id: `${limitId}:${part}`, + label: name === null ? title.label : `${title.label} (${name})`, + detail: title.detail, + utilization: clampUtilization(window.usedPercent), + resetsAt: window.resetsAt, + }); + } + return windows; +} + +function snapshotPlanType(snapshot: unknown): string | null { + if (typeof snapshot !== "object" || snapshot === null) return null; + const { planType } = snapshot as Record; + return typeof planType === "string" && planType.trim().length > 0 ? planType.trim() : null; +} + +export interface CodexRateLimits { + readonly windows: UsageLimitWindow[]; + readonly planType: string | null; +} + +/** + * Extracts rate windows from an `account/rateLimits/read` response. + * + * The multi-bucket `rateLimitsByLimitId` view is preferred: it is the only + * place model-scoped buckets (with human `limitName`s) appear, and its + * default bucket duplicates the legacy single-bucket `rateLimits` field. + */ +export function mapCodexRateLimits(response: unknown): CodexRateLimits { + if (typeof response !== "object" || response === null) { + return { windows: [], planType: null }; + } + const { rateLimits, rateLimitsByLimitId } = response as Record; + + const windows: UsageLimitWindow[] = []; + let planType: string | null = null; + + if (typeof rateLimitsByLimitId === "object" && rateLimitsByLimitId !== null) { + for (const [limitId, snapshot] of Object.entries(rateLimitsByLimitId)) { + windows.push(...snapshotWindows(limitId, snapshot)); + planType ??= snapshotPlanType(snapshot); + } + } + if (windows.length === 0) { + windows.push(...snapshotWindows("codex", rateLimits)); + } + planType ??= snapshotPlanType(rateLimits); + + return { windows, planType }; +} diff --git a/docs/user/usage.md b/docs/user/usage.md index 9de419061656..db20da6ae49d 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -15,4 +15,4 @@ headline and chart, and refreshing rescans every connected environment. The **Limits** view shows how much of each subscription plan's rate windows is currently used, with reset countdowns per window. Limit info is only available for subscription sign-ins: API-key authentication is billed per token and has no rate windows, so those providers show a notice -instead. Claude Code reports limits today; other providers will follow. +instead. Claude Code and Codex report limits today; other providers will follow. From 1d8adb89ea4fef6d6cfd7d494bab3b7a909eed8a Mon Sep 17 00:00:00 2001 From: Ahmed Shareef Date: Fri, 28 Aug 2026 13:41:01 +0400 Subject: [PATCH 03/10] fix(server): handle Codex auth and fractional rate limits - Read account authentication state alongside usage windows - Parse raw rate-limit responses and prioritize account-wide buckets --- apps/server/src/usage/UsageLimitsService.ts | 53 ++++++++++++++++----- apps/server/src/usage/usageLimitsCodex.ts | 7 ++- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/apps/server/src/usage/UsageLimitsService.ts b/apps/server/src/usage/UsageLimitsService.ts index 0b69aa0dd990..844522f82401 100644 --- a/apps/server/src/usage/UsageLimitsService.ts +++ b/apps/server/src/usage/UsageLimitsService.ts @@ -221,12 +221,18 @@ export const make = Effect.gen(function* () { }); /** - * Spawns a short-lived `codex app-server` and asks it for the account's - * rate windows. No thread is needed: the read answers right after the - * initialize handshake. Lifetime is scope-bound; the timeout and - * force-kill bound a hung binary. + * Spawns a short-lived `codex app-server` and asks it who is signed in and + * how much of the rate windows is used. No thread is needed: both reads + * answer right after the initialize handshake. Lifetime is scope-bound; the + * timeout and force-kill bound a hung binary. + * + * The rate-limits read deliberately uses the raw (undecoded) RPC surface: + * the generated response schema requires integer `usedPercent`s while the + * wire is known to carry fractional ones, and a decode failure here would + * misreport a healthy app server as unreachable. `mapCodexRateLimits` + * parses the raw document defensively instead. */ - const requestCodexRateLimits = Effect.fn("UsageLimitsService.requestCodexRateLimits")( + const requestCodexAccountLimits = Effect.fn("UsageLimitsService.requestCodexAccountLimits")( function* (input: { readonly binaryPath: string; readonly homePath: string | undefined; @@ -255,7 +261,13 @@ export const make = Effect.gen(function* () { capabilities: { experimentalApi: true }, }); yield* client.notify("initialized", undefined); - return yield* client.request("account/rateLimits/read", undefined); + const accountResponse = yield* client.request("account/read", {}); + const account = accountResponse.account ?? null; + const rateLimits = + account === null && accountResponse.requiresOpenaiAuth + ? null + : yield* client.raw.request("account/rateLimits/read", undefined); + return { account, requiresOpenaiAuth: accountResponse.requiresOpenaiAuth, rateLimits }; }, Effect.scoped, Effect.timeoutOption(CODEX_APP_SERVER_TIMEOUT_MS), @@ -279,6 +291,10 @@ export const make = Effect.gen(function* () { ); // Credentials live in the auth home: the shadow home in authOverlay mode, // unlike transcripts, which UsageService reads from the shared home. + // The file is only a cheap pre-check to skip the spawn for unambiguous + // API-key auth; a missing file is NOT proof of being signed out, because + // Codex can keep credentials in the OS keyring or take a key from the + // environment. The app server is the canonical auth state. const authHome = layout.effectiveHomePath ?? layout.sharedHomePath; const raw = yield* fileSystem .readFileString(path.join(authHome, "auth.json")) @@ -287,20 +303,31 @@ export const make = Effect.gen(function* () { if (authKind === "apiKey") { return codexLimits("unsupported", null, SUBSCRIPTION_ONLY_MESSAGE); } - if (authKind === "none") { - return codexLimits("unauthenticated", null, "Codex is not signed in on this environment."); - } - const response = yield* requestCodexRateLimits({ + const response = yield* requestCodexAccountLimits({ binaryPath: codexSettings.binaryPath, homePath: layout.effectiveHomePath, launchArgs: codexSettings.launchArgs, }); if (response === null) { - return codexLimits("unavailable", null, "Codex's app server could not be reached."); + // Without local credential evidence, a dead app server most likely + // means Codex is absent or signed out rather than broken. + return authKind === "chatgpt" + ? codexLimits("unavailable", null, "Codex's app server could not be reached.") + : codexLimits("unauthenticated", null, "Codex is not signed in on this environment."); + } + const account = response.account; + if (account === null && response.requiresOpenaiAuth) { + return codexLimits("unauthenticated", null, "Codex is not signed in on this environment."); } - const { windows, planType } = mapCodexRateLimits(response); - const plan = codexPlanLabel(planType); + if (account !== null && (account.type === "apiKey" || account.type === "amazonBedrock")) { + return codexLimits("unsupported", null, SUBSCRIPTION_ONLY_MESSAGE); + } + + const { windows, planType } = mapCodexRateLimits(response.rateLimits); + const plan = codexPlanLabel( + planType ?? (account !== null && account.type === "chatgpt" ? account.planType : null), + ); if (windows.length === 0) { return codexLimits( "unavailable", diff --git a/apps/server/src/usage/usageLimitsCodex.ts b/apps/server/src/usage/usageLimitsCodex.ts index 91aa07753a64..a517145438d9 100644 --- a/apps/server/src/usage/usageLimitsCodex.ts +++ b/apps/server/src/usage/usageLimitsCodex.ts @@ -163,7 +163,12 @@ export function mapCodexRateLimits(response: unknown): CodexRateLimits { let planType: string | null = null; if (typeof rateLimitsByLimitId === "object" && rateLimitsByLimitId !== null) { - for (const [limitId, snapshot] of Object.entries(rateLimitsByLimitId)) { + // JSON key order is not a contract; pin the default bucket first so the + // account-wide windows always lead the model-scoped ones. + const entries = Object.entries(rateLimitsByLimitId).toSorted(([a], [b]) => + a === b ? 0 : a === "codex" ? -1 : b === "codex" ? 1 : a.localeCompare(b), + ); + for (const [limitId, snapshot] of entries) { windows.push(...snapshotWindows(limitId, snapshot)); planType ??= snapshotPlanType(snapshot); } From 8792e8f7505b5c4f2472135882e7286aa0410592 Mon Sep 17 00:00:00 2001 From: Ahmed Shareef Date: Fri, 28 Aug 2026 13:41:02 +0400 Subject: [PATCH 04/10] feat(usage): distinguish provider accounts by email - Fetch and expose Claude and Codex account emails - Group and label usage cards by account identity - Extend usage contracts and parser tests --- apps/server/src/usage/UsageLimitsService.ts | 136 ++++++++++++------ .../src/usage/usageLimitsClaude.test.ts | 12 ++ apps/server/src/usage/usageLimitsClaude.ts | 9 ++ .../components/usage/UsageLimitsContent.tsx | 13 +- apps/web/src/state/usage.ts | 20 +-- packages/contracts/src/usageLimits.ts | 6 + 6 files changed, 142 insertions(+), 54 deletions(-) diff --git a/apps/server/src/usage/UsageLimitsService.ts b/apps/server/src/usage/UsageLimitsService.ts index 844522f82401..cf6e5d83da10 100644 --- a/apps/server/src/usage/UsageLimitsService.ts +++ b/apps/server/src/usage/UsageLimitsService.ts @@ -40,11 +40,13 @@ import { codexAppServerArgs } from "../provider/Layers/codexLaunchArgs.ts"; import { claudePlanLabel, parseClaudeOauthCredentials, + parseClaudeProfileEmail, parseClaudeUsageWindows, } from "./usageLimitsClaude.ts"; import { codexPlanLabel, mapCodexRateLimits, parseCodexAuthKind } from "./usageLimitsCodex.ts"; const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; +const CLAUDE_PROFILE_URL = "https://api.anthropic.com/api/oauth/profile"; /** The OAuth endpoints require the same beta marker the Claude CLI sends. */ const CLAUDE_OAUTH_BETA_HEADER = "oauth-2025-04-20"; @@ -87,10 +89,20 @@ export const layerTest = Layer.succeed( function makeProviderLimits(provider: UsageProviderKind) { return ( availability: ProviderUsageLimits["availability"], - plan: string | null, - message: string | null, - windows: ProviderUsageLimits["windows"] = [], - ): ProviderUsageLimits => ({ provider, availability, plan, windows, message }); + fields: { + readonly plan?: string | null; + readonly email?: string | null; + readonly message?: string | null; + readonly windows?: ProviderUsageLimits["windows"]; + } = {}, + ): ProviderUsageLimits => ({ + provider, + availability, + plan: fields.plan ?? null, + email: fields.email ?? null, + windows: fields.windows ?? [], + message: fields.message ?? null, + }); } const claudeLimits = makeProviderLimits("claude"); const codexLimits = makeProviderLimits("codex"); @@ -172,52 +184,87 @@ export const make = Effect.gen(function* () { return null; }); + const claudeOauthRequest = (url: string, accessToken: string) => + HttpClientRequest.get(url).pipe( + HttpClientRequest.setHeaders({ + authorization: `Bearer ${accessToken}`, + "anthropic-beta": CLAUDE_OAUTH_BETA_HEADER, + }), + ); + + /** + * Best-effort account email from the OAuth profile endpoint. The email + * only disambiguates cards; a failure here must never degrade the limit + * figures, so every failure mode collapses to null. + */ + const readClaudeProfileEmail = Effect.fn("UsageLimitsService.readClaudeProfileEmail")( + function* (accessToken: string) { + const response = yield* httpClient.execute( + claudeOauthRequest(CLAUDE_PROFILE_URL, accessToken), + ); + if (response.status < 200 || response.status >= 300) return null; + const payload = yield* response.json; + return parseClaudeProfileEmail(payload); + }, + Effect.timeoutOption(REQUEST_TIMEOUT_MS), + (effect) => + effect.pipe( + Effect.map((email) => Option.getOrNull(email)), + Effect.orElseSucceed(() => null), + ), + ); + const readClaudeLimits = Effect.fn("UsageLimitsService.readClaudeLimits")(function* () { const credentials = yield* readClaudeCredentials(); if (credentials === null) { - return claudeLimits("unsupported", null, SUBSCRIPTION_ONLY_MESSAGE); + return claudeLimits("unsupported", { message: SUBSCRIPTION_ONLY_MESSAGE }); } const plan = claudePlanLabel(credentials.subscriptionType); - const request = HttpClientRequest.get(CLAUDE_USAGE_URL).pipe( - HttpClientRequest.setHeaders({ - authorization: `Bearer ${credentials.accessToken}`, - "anthropic-beta": CLAUDE_OAUTH_BETA_HEADER, - }), - ); - const response = yield* httpClient.execute(request).pipe( - Effect.timeoutOption(REQUEST_TIMEOUT_MS), - Effect.orElseSucceed(() => Option.none()), + const [response, email] = yield* Effect.all( + [ + httpClient.execute(claudeOauthRequest(CLAUDE_USAGE_URL, credentials.accessToken)).pipe( + Effect.timeoutOption(REQUEST_TIMEOUT_MS), + Effect.orElseSucceed(() => Option.none()), + ), + readClaudeProfileEmail(credentials.accessToken), + ], + { concurrency: "unbounded" }, ); if (Option.isNone(response)) { - return claudeLimits("unavailable", plan, "Claude's limit service could not be reached."); + return claudeLimits("unavailable", { + plan, + email, + message: "Claude's limit service could not be reached.", + }); } const status = response.value.status; if (status === 401 || status === 403) { - return claudeLimits( - "unauthenticated", + return claudeLimits("unauthenticated", { plan, - "The stored Claude sign-in was rejected. Open Claude Code to refresh it, then retry.", - ); + email, + message: + "The stored Claude sign-in was rejected. Open Claude Code to refresh it, then retry.", + }); } if (status < 200 || status >= 300) { - return claudeLimits( - "unavailable", + return claudeLimits("unavailable", { plan, - `Claude's limit service answered with status ${status}.`, - ); + email, + message: `Claude's limit service answered with status ${status}.`, + }); } const payload = yield* response.value.json.pipe(Effect.orElseSucceed(() => null)); const windows = parseClaudeUsageWindows(payload); if (windows.length === 0) { - return claudeLimits( - "unavailable", + return claudeLimits("unavailable", { plan, - "Claude's limit service answered in a shape this version does not understand.", - ); + email, + message: "Claude's limit service answered in a shape this version does not understand.", + }); } - return claudeLimits("available", plan, null, windows); + return claudeLimits("available", { plan, email, windows }); }); /** @@ -283,7 +330,7 @@ export const make = Effect.gen(function* () { Effect.catchCause(() => Effect.succeed(null)), ); if (settings === null) { - return codexLimits("unavailable", null, "Server settings could not be read."); + return codexLimits("unavailable", { message: "Server settings could not be read." }); } const codexSettings = settings.providers.codex; const layout = yield* resolveCodexHomeLayout(codexSettings).pipe( @@ -301,7 +348,7 @@ export const make = Effect.gen(function* () { .pipe(Effect.catchCause(() => Effect.succeed(null))); const authKind = raw === null ? "none" : parseCodexAuthKind(raw); if (authKind === "apiKey") { - return codexLimits("unsupported", null, SUBSCRIPTION_ONLY_MESSAGE); + return codexLimits("unsupported", { message: SUBSCRIPTION_ONLY_MESSAGE }); } const response = yield* requestCodexAccountLimits({ @@ -313,29 +360,34 @@ export const make = Effect.gen(function* () { // Without local credential evidence, a dead app server most likely // means Codex is absent or signed out rather than broken. return authKind === "chatgpt" - ? codexLimits("unavailable", null, "Codex's app server could not be reached.") - : codexLimits("unauthenticated", null, "Codex is not signed in on this environment."); + ? codexLimits("unavailable", { message: "Codex's app server could not be reached." }) + : codexLimits("unauthenticated", { + message: "Codex is not signed in on this environment.", + }); } const account = response.account; if (account === null && response.requiresOpenaiAuth) { - return codexLimits("unauthenticated", null, "Codex is not signed in on this environment."); + return codexLimits("unauthenticated", { + message: "Codex is not signed in on this environment.", + }); } if (account !== null && (account.type === "apiKey" || account.type === "amazonBedrock")) { - return codexLimits("unsupported", null, SUBSCRIPTION_ONLY_MESSAGE); + return codexLimits("unsupported", { message: SUBSCRIPTION_ONLY_MESSAGE }); } + const chatgptAccount = account !== null && account.type === "chatgpt" ? account : null; + const rawEmail = chatgptAccount?.email ?? null; + const email = rawEmail !== null && rawEmail.trim().length > 0 ? rawEmail.trim() : null; const { windows, planType } = mapCodexRateLimits(response.rateLimits); - const plan = codexPlanLabel( - planType ?? (account !== null && account.type === "chatgpt" ? account.planType : null), - ); + const plan = codexPlanLabel(planType ?? chatgptAccount?.planType ?? null); if (windows.length === 0) { - return codexLimits( - "unavailable", + return codexLimits("unavailable", { plan, - "Codex answered in a shape this version does not understand.", - ); + email, + message: "Codex answered in a shape this version does not understand.", + }); } - return codexLimits("available", plan, null, windows); + return codexLimits("available", { plan, email, windows }); }); const readLimits = Effect.fn("UsageLimitsService.readLimits")(function* () { diff --git a/apps/server/src/usage/usageLimitsClaude.test.ts b/apps/server/src/usage/usageLimitsClaude.test.ts index 12c791dc2642..c1e79d694200 100644 --- a/apps/server/src/usage/usageLimitsClaude.test.ts +++ b/apps/server/src/usage/usageLimitsClaude.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { claudePlanLabel, parseClaudeOauthCredentials, + parseClaudeProfileEmail, parseClaudeUsageWindows, } from "./usageLimitsClaude.ts"; @@ -48,6 +49,17 @@ describe("claudePlanLabel", () => { }); }); +describe("parseClaudeProfileEmail", () => { + it("reads the account email and tolerates junk", () => { + expect( + parseClaudeProfileEmail({ account: { email: "user@example.com", full_name: "User" } }), + ).toBe("user@example.com"); + expect(parseClaudeProfileEmail({ account: { email: " " } })).toBeNull(); + expect(parseClaudeProfileEmail({ account: null })).toBeNull(); + expect(parseClaudeProfileEmail(null)).toBeNull(); + }); +}); + describe("parseClaudeUsageWindows", () => { it("prefers the structured limits array, including model-scoped windows", () => { const windows = parseClaudeUsageWindows({ diff --git a/apps/server/src/usage/usageLimitsClaude.ts b/apps/server/src/usage/usageLimitsClaude.ts index 44d47078ed57..a50b671aa2ed 100644 --- a/apps/server/src/usage/usageLimitsClaude.ts +++ b/apps/server/src/usage/usageLimitsClaude.ts @@ -44,6 +44,15 @@ export function parseClaudeOauthCredentials(raw: string): ClaudeOauthCredentials }; } +/** The signed-in account's email, from the OAuth profile endpoint. */ +export function parseClaudeProfileEmail(document: unknown): string | null { + if (typeof document !== "object" || document === null) return null; + const account = (document as Record).account; + if (typeof account !== "object" || account === null) return null; + const email = (account as Record).email; + return typeof email === "string" && email.trim().length > 0 ? email.trim() : null; +} + const PLAN_LABELS: Record = { free: "Claude Free", pro: "Claude Pro", diff --git a/apps/web/src/components/usage/UsageLimitsContent.tsx b/apps/web/src/components/usage/UsageLimitsContent.tsx index 959b842f134d..bd33bdeb1f6c 100644 --- a/apps/web/src/components/usage/UsageLimitsContent.tsx +++ b/apps/web/src/components/usage/UsageLimitsContent.tsx @@ -80,7 +80,7 @@ export function UsageLimitsContent() {
{ordered.map((entry) => ( 1} @@ -133,9 +133,14 @@ function ProviderLimitsCard({

)} - {multiEnvironment ? ( - - Reported by {entry.environmentLabels.join(", ")} + {limits.email !== null || multiEnvironment ? ( + + {[ + limits.email, + multiEnvironment ? `Reported by ${entry.environmentLabels.join(", ")}` : null, + ] + .filter((part) => part !== null) + .join(" · ")} ) : null} diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 723d2224fce6..7badea73edea 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -219,10 +219,11 @@ export function useUsageLimits(): UsageLimitsView { // (worktree servers) resolve the same credentials and must not repeat the // card, but two environments signed into different accounts must both stay // visible: hiding one could hide the account that is about to hit a limit. - // Reset instants identify the account well enough for that grouping - the - // windows follow the account's own clock, while utilization drifts between - // fetches. Failure answers only surface when no environment produced - // figures for the provider, best-ranked first. + // The account email is the identity when the provider reports one; without + // it, reset instants identify the account well enough - the windows follow + // the account's own clock, while utilization drifts between fetches. + // Failure answers only surface when no environment produced figures for + // the provider, best-ranked first. const providers = useMemo(() => { interface ProviderMerge { readonly accounts: Map; @@ -238,10 +239,13 @@ export function useUsageLimits(): UsageLimitsView { byProvider.set(limits.provider, merge); } if (limits.availability === "available") { - const accountKey = JSON.stringify([ - limits.plan, - limits.windows.map((window) => [window.id, window.resetsAt]), - ]); + const accountKey = + limits.email !== null + ? `email:${limits.email}` + : `windows:${JSON.stringify([ + limits.plan, + limits.windows.map((window) => [window.id, window.resetsAt]), + ])}`; const account = merge.accounts.get(accountKey); if (account === undefined) { merge.accounts.set(accountKey, { limits, labels: [environment.label] }); diff --git a/packages/contracts/src/usageLimits.ts b/packages/contracts/src/usageLimits.ts index 41493cc94afc..3ff58d305a2f 100644 --- a/packages/contracts/src/usageLimits.ts +++ b/packages/contracts/src/usageLimits.ts @@ -68,6 +68,12 @@ export const ProviderUsageLimits = Schema.Struct({ availability: UsageLimitsAvailability, /** Subscription plan label, e.g. "Claude Max". Null when unknown. */ plan: Schema.NullOr(TrimmedNonEmptyString), + /** + * Email of the signed-in account, when the provider exposes one. This is + * what tells two accounts apart when environments report different + * sign-ins of the same provider. + */ + email: Schema.NullOr(TrimmedNonEmptyString), /** Empty unless `availability` is `available`. */ windows: Schema.Array(UsageLimitWindow), /** From 58414b5bf463ab22823a0777c7751d8dd5f1589e Mon Sep 17 00:00:00 2001 From: Ahmed Shareef Date: Fri, 28 Aug 2026 13:41:02 +0400 Subject: [PATCH 05/10] feat(usage): add Grok subscription limit reporting - Read Grok OIDC billing windows and display them in Limits - Add defensive parsers, tests, contracts, and user documentation --- apps/server/src/usage/UsageLimitsService.ts | 157 +++++++++++- apps/server/src/usage/usageLimitsGrok.test.ts | 169 +++++++++++++ apps/server/src/usage/usageLimitsGrok.ts | 225 ++++++++++++++++++ .../components/usage/UsageLimitsContent.tsx | 15 +- apps/web/src/state/usage.ts | 5 +- docs/user/usage.md | 2 +- packages/contracts/src/usageLimits.ts | 12 +- 7 files changed, 563 insertions(+), 22 deletions(-) create mode 100644 apps/server/src/usage/usageLimitsGrok.test.ts create mode 100644 apps/server/src/usage/usageLimitsGrok.ts diff --git a/apps/server/src/usage/UsageLimitsService.ts b/apps/server/src/usage/UsageLimitsService.ts index cf6e5d83da10..c475ed7556cd 100644 --- a/apps/server/src/usage/UsageLimitsService.ts +++ b/apps/server/src/usage/UsageLimitsService.ts @@ -7,16 +7,20 @@ * credentials: Claude's OAuth grant (credential file under the Claude home, * or the macOS login keychain) against Anthropic's OAuth usage endpoint, and * Codex's ChatGPT sign-in via a short-lived `codex app-server` asked for - * `account/rateLimits/read`. API-key auth has no rate windows; those - * providers answer `unsupported` in-band instead of failing the RPC. + * `account/rateLimits/read`, and Grok's OIDC sign-in (from `auth.json` under + * the Grok home) against the Grok CLI backend's billing endpoint. API-key + * auth has no rate windows; those providers answer `unsupported` in-band + * instead of failing the RPC. * * @module UsageLimitsService */ +import * as NodeOS from "node:os"; + import { USAGE_LIMITS_CONTRACT_VERSION, type ProviderUsageLimits, + type UsageLimitsProviderKind, type UsageLimitsSummary, - type UsageProviderKind, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -44,10 +48,21 @@ import { parseClaudeUsageWindows, } from "./usageLimitsClaude.ts"; import { codexPlanLabel, mapCodexRateLimits, parseCodexAuthKind } from "./usageLimitsCodex.ts"; +import { + grokPlanLabel, + parseGrokAuthCredentials, + parseGrokBillingWindows, + parseGrokUserProfile, + resolveGrokProxyBaseUrl, +} from "./usageLimitsGrok.ts"; const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; const CLAUDE_PROFILE_URL = "https://api.anthropic.com/api/oauth/profile"; +/** Paths on the Grok CLI's chat proxy; the base URL is resolved per read. */ +const GROK_BILLING_PATH = "/billing?format=credits"; +const GROK_USER_PATH = "/user?include=subscription"; + /** The OAuth endpoints require the same beta marker the Claude CLI sends. */ const CLAUDE_OAUTH_BETA_HEADER = "oauth-2025-04-20"; @@ -86,7 +101,7 @@ export const layerTest = Layer.succeed( }), ); -function makeProviderLimits(provider: UsageProviderKind) { +function makeProviderLimits(provider: UsageLimitsProviderKind) { return ( availability: ProviderUsageLimits["availability"], fields: { @@ -106,6 +121,7 @@ function makeProviderLimits(provider: UsageProviderKind) { } const claudeLimits = makeProviderLimits("claude"); const codexLimits = makeProviderLimits("codex"); +const grokLimits = makeProviderLimits("grok"); export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -390,15 +406,140 @@ export const make = Effect.gen(function* () { return codexLimits("available", { plan, email, windows }); }); - const readLimits = Effect.fn("UsageLimitsService.readLimits")(function* () { - const [claude, codex] = yield* Effect.all([readClaudeLimits(), readCodexLimits()], { - concurrency: "unbounded", + const grokRequest = (url: string, key: string) => + HttpClientRequest.get(url).pipe( + HttpClientRequest.setHeaders({ + authorization: `Bearer ${key}`, + accept: "application/json", + }), + ); + + /** + * Best-effort plan tier and email from the Grok user endpoint. Identity + * only; a failure here must never degrade the limit figures. + */ + const readGrokProfile = Effect.fn("UsageLimitsService.readGrokProfile")( + function* (input: { readonly baseUrl: string; readonly key: string }) { + const response = yield* httpClient.execute( + grokRequest(`${input.baseUrl}${GROK_USER_PATH}`, input.key), + ); + if (response.status < 200 || response.status >= 300) return parseGrokUserProfile(null); + const payload = yield* response.json; + return parseGrokUserProfile(payload); + }, + Effect.timeoutOption(REQUEST_TIMEOUT_MS), + (effect) => + effect.pipe( + Effect.map((profile) => Option.getOrNull(profile) ?? parseGrokUserProfile(null)), + Effect.orElseSucceed(() => parseGrokUserProfile(null)), + ), + ); + + const readGrokLimits = Effect.fn("UsageLimitsService.readGrokLimits")(function* () { + // Mirrors the CLI's own auth precedence: an ambient xAI API key wins + // over the stored sign-in, and API keys have no subscription windows. + const environment = process.env; + const apiKey = environment.XAI_API_KEY ?? environment.GROK_CODE_XAI_API_KEY; + if (apiKey !== undefined && apiKey.trim().length > 0) { + return grokLimits("unsupported", { message: SUBSCRIPTION_ONLY_MESSAGE }); + } + + // Grok settings do not model a home dir; the CLI's own env overrides are + // the only relocation mechanism, so honor them the way the CLI would. + const homeOverride = environment.GROK_HOME?.trim(); + const grokHome = + homeOverride !== undefined && homeOverride.length > 0 + ? homeOverride + : path.join(NodeOS.homedir(), ".grok"); + const authPath = environment.GROK_AUTH_PATH?.trim() || path.join(grokHome, "auth.json"); + const raw = yield* fileSystem + .readFileString(authPath) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + const credentials = raw === null ? null : parseGrokAuthCredentials(raw); + if (credentials === null) { + return grokLimits("unauthenticated", { + message: "Grok is not signed in on this environment.", + }); + } + if (credentials.authMode !== "oidc") { + return grokLimits("unsupported", { message: SUBSCRIPTION_ONLY_MESSAGE }); + } + + // The bearer is scoped to the CLI's configured chat proxy, which team + // setups override; it must go where the CLI would send it, never to the + // public default by accident. + const modelsCacheRaw = yield* fileSystem + .readFileString(path.join(grokHome, "models_cache.json")) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + const baseUrl = resolveGrokProxyBaseUrl({ + envBaseUrl: environment.GROK_CLI_CHAT_PROXY_BASE_URL, + modelsCacheRaw, }); + if (baseUrl === null) { + return grokLimits("unavailable", { + message: "Grok's configured proxy endpoint could not be understood.", + }); + } + + const [response, profile] = yield* Effect.all( + [ + httpClient.execute(grokRequest(`${baseUrl}${GROK_BILLING_PATH}`, credentials.key)).pipe( + Effect.timeoutOption(REQUEST_TIMEOUT_MS), + Effect.orElseSucceed(() => Option.none()), + ), + readGrokProfile({ baseUrl, key: credentials.key }), + ], + { concurrency: "unbounded" }, + ); + const email = profile.email ?? credentials.email; + const plan = grokPlanLabel(profile.subscriptionTier); + if (Option.isNone(response)) { + return grokLimits("unavailable", { + plan, + email, + message: "Grok's billing service could not be reached.", + }); + } + const status = response.value.status; + if (status === 401 || status === 403) { + // The CLI's bearer is short-lived (minutes, not days); an expired one + // is routine rather than a broken login. + return grokLimits("unauthenticated", { + plan, + email, + message: "The stored Grok sign-in has expired. Use Grok once to refresh it, then retry.", + }); + } + if (status < 200 || status >= 300) { + return grokLimits("unavailable", { + plan, + email, + message: `Grok's billing service answered with status ${status}.`, + }); + } + + const payload = yield* response.value.json.pipe(Effect.orElseSucceed(() => null)); + const windows = parseGrokBillingWindows(payload); + if (windows.length === 0) { + return grokLimits("unavailable", { + plan, + email, + message: "Grok's billing service answered in a shape this version does not understand.", + }); + } + return grokLimits("available", { plan, email, windows }); + }); + + const readLimits = Effect.fn("UsageLimitsService.readLimits")(function* () { + const [claude, codex, grok] = yield* Effect.all( + [readClaudeLimits(), readCodexLimits(), readGrokLimits()], + { concurrency: "unbounded" }, + ); const readAt = yield* DateTime.now; return { contractVersion: USAGE_LIMITS_CONTRACT_VERSION, readAt: DateTime.formatIso(readAt), - providers: [claude, codex], + providers: [claude, codex, grok], } satisfies UsageLimitsSummary; }); diff --git a/apps/server/src/usage/usageLimitsGrok.test.ts b/apps/server/src/usage/usageLimitsGrok.test.ts new file mode 100644 index 000000000000..1d97e93d2205 --- /dev/null +++ b/apps/server/src/usage/usageLimitsGrok.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + GROK_DEFAULT_PROXY_BASE_URL, + grokPlanLabel, + parseGrokAuthCredentials, + parseGrokBillingWindows, + parseGrokUserProfile, + resolveGrokProxyBaseUrl, +} from "./usageLimitsGrok.ts"; + +describe("resolveGrokProxyBaseUrl", () => { + it("prefers the env override and normalises it", () => { + expect( + resolveGrokProxyBaseUrl({ + envBaseUrl: "https://proxy.corp.example/v1/", + modelsCacheRaw: null, + }), + ).toBe("https://proxy.corp.example/v1"); + }); + + it("fails closed on an unparseable override instead of using the default", () => { + expect(resolveGrokProxyBaseUrl({ envBaseUrl: "not a url", modelsCacheRaw: null })).toBeNull(); + expect( + resolveGrokProxyBaseUrl({ envBaseUrl: "ftp://proxy.example/v1", modelsCacheRaw: null }), + ).toBeNull(); + }); + + it("derives the base from the models cache origin, else defaults", () => { + expect( + resolveGrokProxyBaseUrl({ + envBaseUrl: undefined, + modelsCacheRaw: JSON.stringify({ + auth_method: "session", + origin: "https://team-proxy.example/v1/models", + }), + }), + ).toBe("https://team-proxy.example/v1"); + expect(resolveGrokProxyBaseUrl({ envBaseUrl: undefined, modelsCacheRaw: "junk" })).toBe( + GROK_DEFAULT_PROXY_BASE_URL, + ); + expect(resolveGrokProxyBaseUrl({ envBaseUrl: undefined, modelsCacheRaw: null })).toBe( + GROK_DEFAULT_PROXY_BASE_URL, + ); + }); +}); + +describe("parseGrokAuthCredentials", () => { + it("picks the OIDC entry from the issuer-keyed map", () => { + const raw = JSON.stringify({ + "https://auth.x.ai::client-uuid": { + key: "bearer-token", + auth_mode: "oidc", + email: "user@example.com", + refresh_token: "r", + }, + }); + expect(parseGrokAuthCredentials(raw)).toEqual({ + key: "bearer-token", + authMode: "oidc", + email: "user@example.com", + }); + }); + + it("prefers OIDC over other entries and falls back otherwise", () => { + const raw = JSON.stringify({ + a: { key: "api-ish", auth_mode: "api_key" }, + b: { key: "oidc-token", auth_mode: "oidc" }, + }); + expect(parseGrokAuthCredentials(raw)?.key).toBe("oidc-token"); + expect( + parseGrokAuthCredentials(JSON.stringify({ a: { key: "k", auth_mode: "api_key" } })), + ).toEqual({ key: "k", authMode: "api_key", email: null }); + }); + + it("returns null for junk", () => { + expect(parseGrokAuthCredentials("not json")).toBeNull(); + expect(parseGrokAuthCredentials("{}")).toBeNull(); + expect(parseGrokAuthCredentials(JSON.stringify({ a: { key: " " } }))).toBeNull(); + }); +}); + +describe("grokPlanLabel", () => { + it("maps known tiers and spaces unknown camel-case ones", () => { + expect(grokPlanLabel("XPremium")).toBe("X Premium"); + expect(grokPlanLabel("SuperGrok")).toBe("SuperGrok"); + expect(grokPlanLabel("SuperGrokHeavy")).toBe("SuperGrok Heavy"); + expect(grokPlanLabel("MegaTier")).toBe("Mega Tier"); + expect(grokPlanLabel(null)).toBeNull(); + }); +}); + +describe("parseGrokUserProfile", () => { + it("reads email and tier, tolerating junk", () => { + expect( + parseGrokUserProfile({ email: "user@example.com", subscriptionTier: "XPremium" }), + ).toEqual({ email: "user@example.com", subscriptionTier: "XPremium" }); + expect(parseGrokUserProfile(null)).toEqual({ email: null, subscriptionTier: null }); + }); +}); + +describe("parseGrokBillingWindows", () => { + // Shape observed live from cli-chat-proxy.grok.com (grok CLI 1.0.3). + const liveResponse = { + config: { + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + start: "2026-08-10T03:52:10.269564+00:00", + end: "2026-08-17T03:52:10.269564+00:00", + }, + creditUsagePercent: 60.0, + onDemandCap: { val: 0 }, + productUsage: [ + { product: "GrokBuild", usagePercent: 57.0 }, + { product: "GrokChat", usagePercent: 3.0 }, + ], + isUnifiedBillingUser: true, + billingPeriodEnd: "2026-08-17T03:52:10.269564+00:00", + }, + }; + + it("maps the credit budget and per-product splits", () => { + expect(parseGrokBillingWindows(liveResponse)).toEqual([ + { + id: "credits", + label: "Weekly limit", + detail: "All products · weekly credit window", + utilization: 60, + resetsAt: "2026-08-17T03:52:10.269564+00:00", + }, + { + id: "credits:GrokBuild", + label: "Weekly limit (Grok Build)", + detail: "Weekly credit window", + utilization: 57, + resetsAt: "2026-08-17T03:52:10.269564+00:00", + }, + { + id: "credits:GrokChat", + label: "Weekly limit (Grok Chat)", + detail: "Weekly credit window", + utilization: 3, + resetsAt: "2026-08-17T03:52:10.269564+00:00", + }, + ]); + }); + + it("handles a bare config and unknown period types", () => { + const windows = parseGrokBillingWindows({ + creditUsagePercent: 12.5, + currentPeriod: { type: "USAGE_PERIOD_TYPE_MYSTERY", end: null }, + billingPeriodEnd: "2026-09-01T00:00:00+00:00", + }); + expect(windows).toEqual([ + { + id: "credits", + label: "Usage limit", + detail: "All products · credit window", + utilization: 12.5, + resetsAt: "2026-09-01T00:00:00+00:00", + }, + ]); + }); + + it("returns empty for malformed documents", () => { + expect(parseGrokBillingWindows(null)).toEqual([]); + expect(parseGrokBillingWindows({ config: { creditUsagePercent: "lots" } })).toEqual([]); + }); +}); diff --git a/apps/server/src/usage/usageLimitsGrok.ts b/apps/server/src/usage/usageLimitsGrok.ts new file mode 100644 index 000000000000..90aca730d3c5 --- /dev/null +++ b/apps/server/src/usage/usageLimitsGrok.ts @@ -0,0 +1,225 @@ +/** + * Pure parsing for Grok subscription limits. + * + * The Grok CLI stores its OIDC sign-in in `auth.json` under the Grok home + * (a map keyed by `::`), and the figures come from the + * CLI's own backend: `/v1/billing?format=credits` reports the weekly credit + * window (with per-product splits) and `/v1/user?include=subscription` + * reports the plan tier and email. Neither shape is a published contract, so + * every parser here is defensive: unrecognised documents yield `null`/empty + * rather than an error. + * + * @module usageLimitsGrok + */ +import type { UsageLimitWindow } from "@t3tools/contracts"; + +export interface GrokAuthCredentials { + /** Bearer token for the CLI backend. Short-lived; the CLI refreshes it. */ + readonly key: string; + /** `oidc` for a grok.com sign-in; anything else is not a subscription. */ + readonly authMode: string | null; + readonly email: string | null; +} + +function readAuthEntry(value: unknown): GrokAuthCredentials | null { + if (typeof value !== "object" || value === null) return null; + const { key, auth_mode, email } = value as Record; + if (typeof key !== "string" || key.trim().length === 0) return null; + return { + key: key.trim(), + authMode: + typeof auth_mode === "string" && auth_mode.trim().length > 0 ? auth_mode.trim() : null, + email: typeof email === "string" && email.trim().length > 0 ? email.trim() : null, + }; +} + +/** + * Picks the credential entry to use from the CLI's `auth.json` map. OIDC + * entries win: only a grok.com sign-in has subscription limits to report. + */ +export function parseGrokAuthCredentials(raw: string): GrokAuthCredentials | null { + let document: unknown; + try { + document = JSON.parse(raw); + } catch { + return null; + } + if (typeof document !== "object" || document === null) return null; + + let fallback: GrokAuthCredentials | null = null; + for (const value of Object.values(document)) { + const entry = readAuthEntry(value); + if (entry === null) continue; + if (entry.authMode === "oidc") return entry; + fallback ??= entry; + } + return fallback; +} + +export const GROK_DEFAULT_PROXY_BASE_URL = "https://cli-chat-proxy.grok.com/v1"; + +/** A valid absolute http(s) URL without its trailing slashes, else null. */ +function normalizeBaseUrl(value: string | undefined | null): string | null { + if (typeof value !== "string" || value.trim().length === 0) return null; + let url: URL; + try { + url = new URL(value.trim()); + } catch { + return null; + } + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + return url.toString().replace(/\/+$/, ""); +} + +/** + * Resolves the CLI chat proxy base the stored bearer is scoped to. + * + * The CLI honors `GROK_CLI_CHAT_PROXY_BASE_URL` and `[endpoints] + * cli_chat_proxy_base_url` in its config; the resolved origin it actually + * used last shows up in `models_cache.json`'s `origin`. A bearer meant for a + * team proxy must never travel to the public default, so an override that + * cannot be parsed fails closed (null) instead of falling back. + */ +export function resolveGrokProxyBaseUrl(input: { + readonly envBaseUrl: string | undefined; + readonly modelsCacheRaw: string | null; +}): string | null { + if (input.envBaseUrl !== undefined && input.envBaseUrl.trim().length > 0) { + return normalizeBaseUrl(input.envBaseUrl); + } + + if (input.modelsCacheRaw !== null) { + let document: unknown; + try { + document = JSON.parse(input.modelsCacheRaw); + } catch { + document = null; + } + if (typeof document === "object" && document !== null) { + const { origin } = document as Record; + if (typeof origin === "string" && origin.trim().endsWith("/models")) { + const base = normalizeBaseUrl(origin.trim().slice(0, -"/models".length)); + if (base !== null) return base; + } + } + } + + return GROK_DEFAULT_PROXY_BASE_URL; +} + +const PLAN_LABELS: Record = { + free: "Grok Free", + xpremium: "X Premium", + xpremiumplus: "X Premium+", + supergrok: "SuperGrok", + supergrokpro: "SuperGrok Pro", + supergrokheavy: "SuperGrok Heavy", +}; + +/** `XPremium` → "X Premium"; unknown tiers get camel-case spacing. */ +export function grokPlanLabel(subscriptionTier: string | null): string | null { + if (subscriptionTier === null) return null; + const trimmed = subscriptionTier.trim(); + if (trimmed.length === 0) return null; + return PLAN_LABELS[trimmed.toLowerCase()] ?? trimmed.replaceAll(/(?<=[a-z])(?=[A-Z])/g, " "); +} + +export interface GrokUserProfile { + readonly email: string | null; + readonly subscriptionTier: string | null; +} + +/** Best-effort account identity from `/v1/user?include=subscription`. */ +export function parseGrokUserProfile(document: unknown): GrokUserProfile { + if (typeof document !== "object" || document === null) { + return { email: null, subscriptionTier: null }; + } + const { email, subscriptionTier } = document as Record; + return { + email: typeof email === "string" && email.trim().length > 0 ? email.trim() : null, + subscriptionTier: + typeof subscriptionTier === "string" && subscriptionTier.trim().length > 0 + ? subscriptionTier.trim() + : null, + }; +} + +/** See {@link usageLimitsClaude}: clamped, not rounded. */ +function clampUtilization(value: number): number { + return Math.min(Math.max(value, 0), 999); +} + +function periodTitle(periodType: string | null): { label: string; detail: string } { + if (periodType !== null && periodType.includes("WEEKLY")) { + return { label: "Weekly limit", detail: "weekly credit window" }; + } + if (periodType !== null && periodType.includes("MONTHLY")) { + return { label: "Monthly limit", detail: "monthly credit window" }; + } + return { label: "Usage limit", detail: "credit window" }; +} + +/** `GrokBuild` → "Grok Build". */ +function productLabel(product: string): string { + return product.replaceAll(/(?<=[a-z])(?=[A-Z])/g, " "); +} + +/** + * Extracts credit windows from `/v1/billing?format=credits`. + * + * Grok has a single billing-cycle credit budget rather than rolling rate + * windows: `creditUsagePercent` is the account-wide figure, and + * `productUsage` splits it per product (Build, Chat, ...). All windows share + * the current period's end as their reset instant. + */ +export function parseGrokBillingWindows(document: unknown): UsageLimitWindow[] { + if (typeof document !== "object" || document === null) return []; + const root = (document as Record).config ?? document; + if (typeof root !== "object" || root === null) return []; + const { creditUsagePercent, currentPeriod, billingPeriodEnd, productUsage } = root as Record< + string, + unknown + >; + if (typeof creditUsagePercent !== "number" || !Number.isFinite(creditUsagePercent)) return []; + + let periodType: string | null = null; + let periodEnd: string | null = null; + if (typeof currentPeriod === "object" && currentPeriod !== null) { + const { type, end } = currentPeriod as Record; + periodType = typeof type === "string" ? type : null; + periodEnd = typeof end === "string" && end.trim().length > 0 ? end.trim() : null; + } + periodEnd ??= + typeof billingPeriodEnd === "string" && billingPeriodEnd.trim().length > 0 + ? billingPeriodEnd.trim() + : null; + + const title = periodTitle(periodType); + const windows: UsageLimitWindow[] = [ + { + id: "credits", + label: title.label, + detail: `All products · ${title.detail}`, + utilization: clampUtilization(creditUsagePercent), + resetsAt: periodEnd, + }, + ]; + + if (Array.isArray(productUsage)) { + for (const entry of productUsage) { + if (typeof entry !== "object" || entry === null) continue; + const { product, usagePercent } = entry as Record; + if (typeof product !== "string" || product.trim().length === 0) continue; + if (typeof usagePercent !== "number" || !Number.isFinite(usagePercent)) continue; + windows.push({ + id: `credits:${product.trim()}`, + label: `${title.label} (${productLabel(product.trim())})`, + detail: `${title.detail.charAt(0).toUpperCase()}${title.detail.slice(1)}`, + utilization: clampUtilization(usagePercent), + resetsAt: periodEnd, + }); + } + } + + return windows; +} diff --git a/apps/web/src/components/usage/UsageLimitsContent.tsx b/apps/web/src/components/usage/UsageLimitsContent.tsx index bd33bdeb1f6c..bc240516ce4c 100644 --- a/apps/web/src/components/usage/UsageLimitsContent.tsx +++ b/apps/web/src/components/usage/UsageLimitsContent.tsx @@ -1,4 +1,4 @@ -import type { UsageLimitWindow, UsageProviderKind } from "@t3tools/contracts"; +import type { UsageLimitWindow, UsageLimitsProviderKind } from "@t3tools/contracts"; import { RefreshCwIcon } from "lucide-react"; import { useEffect, useState } from "react"; @@ -13,11 +13,8 @@ import { import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PROVIDER_PRESENTATION } from "./usageProviders"; -/** - * Claude leads: it is the only provider reporting limits today, and the page - * introduces the others as placeholders in this order as support lands. - */ -const LIMITS_PROVIDER_ORDER: readonly UsageProviderKind[] = ["claude", "codex"]; +/** Display order; providers not yet reporting render as placeholders. */ +const LIMITS_PROVIDER_ORDER: readonly UsageLimitsProviderKind[] = ["claude", "codex", "grok"]; /** * The "Limits" half of the usage page: how much of each subscription rate @@ -152,7 +149,7 @@ function LimitWindowRow({ window, nowMs, }: { - readonly provider: UsageProviderKind; + readonly provider: UsageLimitsProviderKind; readonly window: UsageLimitWindow; readonly nowMs: number; }) { @@ -203,7 +200,7 @@ function LimitWindowRow({ } /** Provider brand color until the window runs hot, then the alert tokens. */ -function utilizationColor(provider: UsageProviderKind, utilization: number): string { +function utilizationColor(provider: UsageLimitsProviderKind, utilization: number): string { if (utilization >= 90) return "var(--color-destructive)"; if (utilization >= 75) return "var(--color-warning)"; return PROVIDER_PRESENTATION[provider].color; @@ -227,7 +224,7 @@ function formatResetsIn(resetsAt: string, nowMs: number): string | null { return `in ${minutes}m`; } -function UpcomingProviderCard({ provider }: { readonly provider: UsageProviderKind }) { +function UpcomingProviderCard({ provider }: { readonly provider: UsageLimitsProviderKind }) { const Mark = PROVIDER_PRESENTATION[provider].mark; return (
diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 7badea73edea..41d52b91468f 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -13,6 +13,7 @@ import { type EnvironmentId, type ProviderUsageLimits, type UsageLimitsSummary, + type UsageLimitsProviderKind, type UsageProviderKind, type UsageSummary, type UsageSummaryInput, @@ -176,7 +177,7 @@ const usageLimitsAtom = Atom.make((get): readonly EnvironmentUsageLimitsStatus[] }).pipe(Atom.withLabel("web-usage:limits")); export interface ProviderLimitsStatus { - readonly provider: UsageProviderKind; + readonly provider: UsageLimitsProviderKind; /** The figures this card renders. */ readonly limits: ProviderUsageLimits; /** Environments whose answers this card covers. */ @@ -229,7 +230,7 @@ export function useUsageLimits(): UsageLimitsView { readonly accounts: Map; fallback: { limits: ProviderUsageLimits; labels: string[] } | null; } - const byProvider = new Map(); + const byProvider = new Map(); for (const environment of environments) { if (environment.summary === null) continue; for (const limits of environment.summary.providers) { diff --git a/docs/user/usage.md b/docs/user/usage.md index db20da6ae49d..2f26031cc822 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -15,4 +15,4 @@ headline and chart, and refreshing rescans every connected environment. The **Limits** view shows how much of each subscription plan's rate windows is currently used, with reset countdowns per window. Limit info is only available for subscription sign-ins: API-key authentication is billed per token and has no rate windows, so those providers show a notice -instead. Claude Code and Codex report limits today; other providers will follow. +instead. Claude Code, Codex and Grok report limits today; other providers will follow. diff --git a/packages/contracts/src/usageLimits.ts b/packages/contracts/src/usageLimits.ts index 3ff58d305a2f..c0b4a37ceffc 100644 --- a/packages/contracts/src/usageLimits.ts +++ b/packages/contracts/src/usageLimits.ts @@ -15,7 +15,6 @@ import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; -import { UsageProviderKind } from "./usage.ts"; /** * Bumped whenever the shape of {@link UsageLimitsSummary} changes @@ -24,6 +23,15 @@ import { UsageProviderKind } from "./usage.ts"; */ export const USAGE_LIMITS_CONTRACT_VERSION = 1 as const; +/** + * Providers that can report limits. Deliberately its own union rather than + * {@link UsageProviderKind}: Grok reports subscription credits but has no + * transcript-based usage aggregation, so the usage-summary contract must not + * imply one exists. + */ +export const UsageLimitsProviderKind = Schema.Literals(["claude", "codex", "grok"]); +export type UsageLimitsProviderKind = typeof UsageLimitsProviderKind.Type; + /** * One rolling rate window, e.g. Claude's 5-hour session window or its weekly * all-model window. @@ -64,7 +72,7 @@ export const UsageLimitsAvailability = Schema.Literals([ export type UsageLimitsAvailability = typeof UsageLimitsAvailability.Type; export const ProviderUsageLimits = Schema.Struct({ - provider: UsageProviderKind, + provider: UsageLimitsProviderKind, availability: UsageLimitsAvailability, /** Subscription plan label, e.g. "Claude Max". Null when unknown. */ plan: Schema.NullOr(TrimmedNonEmptyString), From de56aa558861c174da3ab11a504b5ef28044015e Mon Sep 17 00:00:00 2001 From: Ahmed Shareef Date: Fri, 28 Aug 2026 13:41:02 +0400 Subject: [PATCH 06/10] fix(server): handle omitted zero Grok usage - Treat missing proto3 usage percentages as zero for valid credits documents - Add regression coverage for zero-usage billing windows --- apps/server/src/usage/usageLimitsGrok.test.ts | 32 +++++++++++++++++++ apps/server/src/usage/usageLimitsGrok.ts | 12 +++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/apps/server/src/usage/usageLimitsGrok.test.ts b/apps/server/src/usage/usageLimitsGrok.test.ts index 1d97e93d2205..7d303d096aee 100644 --- a/apps/server/src/usage/usageLimitsGrok.test.ts +++ b/apps/server/src/usage/usageLimitsGrok.test.ts @@ -162,8 +162,40 @@ describe("parseGrokBillingWindows", () => { ]); }); + it("reads zero usage when proto3 JSON omits the zero-valued percent", () => { + // Shape observed live at 0% weekly usage (grok CLI 1.0.5): the backend's + // proto3 JSON drops `creditUsagePercent` and `productUsage` entirely. + const zeroUsageResponse = { + config: { + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + start: "2026-08-17T03:52:10.269564+00:00", + end: "2026-08-24T03:52:10.269564+00:00", + }, + onDemandCap: { val: 0 }, + onDemandUsed: { val: 0 }, + isUnifiedBillingUser: true, + prepaidBalance: { val: 0 }, + topUpMethod: "TOP_UP_METHOD_SAVED_PAYMENT_METHOD", + billingPeriodStart: "2026-08-17T03:52:10.269564+00:00", + billingPeriodEnd: "2026-08-24T03:52:10.269564+00:00", + }, + }; + expect(parseGrokBillingWindows(zeroUsageResponse)).toEqual([ + { + id: "credits", + label: "Weekly limit", + detail: "All products · weekly credit window", + utilization: 0, + resetsAt: "2026-08-24T03:52:10.269564+00:00", + }, + ]); + }); + it("returns empty for malformed documents", () => { expect(parseGrokBillingWindows(null)).toEqual([]); expect(parseGrokBillingWindows({ config: { creditUsagePercent: "lots" } })).toEqual([]); + // Absence only means zero inside a recognizable credits document. + expect(parseGrokBillingWindows({ config: { unrelated: true } })).toEqual([]); }); }); diff --git a/apps/server/src/usage/usageLimitsGrok.ts b/apps/server/src/usage/usageLimitsGrok.ts index 90aca730d3c5..fd87bc9fa0be 100644 --- a/apps/server/src/usage/usageLimitsGrok.ts +++ b/apps/server/src/usage/usageLimitsGrok.ts @@ -180,7 +180,15 @@ export function parseGrokBillingWindows(document: unknown): UsageLimitWindow[] { string, unknown >; - if (typeof creditUsagePercent !== "number" || !Number.isFinite(creditUsagePercent)) return []; + // The backend serializes proto3 JSON, which drops zero-valued scalars: at + // 0% usage `creditUsagePercent` is absent entirely. Treat absence as zero + // when the period fields confirm this is really the credits document. + const isCreditsDocument = + (typeof currentPeriod === "object" && currentPeriod !== null) || + typeof billingPeriodEnd === "string"; + const usagePercentValue = + creditUsagePercent === undefined && isCreditsDocument ? 0 : creditUsagePercent; + if (typeof usagePercentValue !== "number" || !Number.isFinite(usagePercentValue)) return []; let periodType: string | null = null; let periodEnd: string | null = null; @@ -200,7 +208,7 @@ export function parseGrokBillingWindows(document: unknown): UsageLimitWindow[] { id: "credits", label: title.label, detail: `All products · ${title.detail}`, - utilization: clampUtilization(creditUsagePercent), + utilization: clampUtilization(usagePercentValue), resetsAt: periodEnd, }, ]; From 54ef24726de0cc6d0777925877a795e355d3a679 Mon Sep 17 00:00:00 2001 From: Ahmed Shareef Date: Fri, 28 Aug 2026 13:41:02 +0400 Subject: [PATCH 07/10] feat(usage): add OpenCode Zen usage limits - Parse OpenCode credentials and Zen rate windows - Show OpenCode limits across contracts, UI, and docs - Add defensive parser tests --- apps/server/src/usage/UsageLimitsService.ts | 120 +++++++++++++++- .../src/usage/usageLimitsOpenCode.test.ts | 119 ++++++++++++++++ apps/server/src/usage/usageLimitsOpenCode.ts | 133 ++++++++++++++++++ .../components/usage/UsageLimitsContent.tsx | 7 +- .../src/components/usage/usageProviders.ts | 24 ++-- docs/user/usage.md | 3 +- packages/contracts/src/usageLimits.ts | 2 +- 7 files changed, 391 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/usage/usageLimitsOpenCode.test.ts create mode 100644 apps/server/src/usage/usageLimitsOpenCode.ts diff --git a/apps/server/src/usage/UsageLimitsService.ts b/apps/server/src/usage/UsageLimitsService.ts index c475ed7556cd..5a882cc2886a 100644 --- a/apps/server/src/usage/UsageLimitsService.ts +++ b/apps/server/src/usage/UsageLimitsService.ts @@ -8,9 +8,10 @@ * or the macOS login keychain) against Anthropic's OAuth usage endpoint, and * Codex's ChatGPT sign-in via a short-lived `codex app-server` asked for * `account/rateLimits/read`, and Grok's OIDC sign-in (from `auth.json` under - * the Grok home) against the Grok CLI backend's billing endpoint. API-key - * auth has no rate windows; those providers answer `unsupported` in-band - * instead of failing the RPC. + * the Grok home) against the Grok CLI backend's billing endpoint, and + * OpenCode's Zen API key (from `auth.json` under its XDG data dir) against + * the Zen usage endpoint. API-key auth has no rate windows; those providers + * answer `unsupported` in-band instead of failing the RPC. * * @module UsageLimitsService */ @@ -55,6 +56,11 @@ import { parseGrokUserProfile, resolveGrokProxyBaseUrl, } from "./usageLimitsGrok.ts"; +import { + parseOpenCodeAuthState, + parseOpenCodeErrorType, + parseOpenCodeUsageWindows, +} from "./usageLimitsOpenCode.ts"; const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; const CLAUDE_PROFILE_URL = "https://api.anthropic.com/api/oauth/profile"; @@ -63,6 +69,9 @@ const CLAUDE_PROFILE_URL = "https://api.anthropic.com/api/oauth/profile"; const GROK_BILLING_PATH = "/billing?format=credits"; const GROK_USER_PATH = "/user?include=subscription"; +/** Zen's usage route lives on the console origin, not the inference API. */ +const OPENCODE_ZEN_USAGE_URL = "https://opencode.ai/zen/go/v1/usage"; + /** The OAuth endpoints require the same beta marker the Claude CLI sends. */ const CLAUDE_OAUTH_BETA_HEADER = "oauth-2025-04-20"; @@ -122,6 +131,7 @@ function makeProviderLimits(provider: UsageLimitsProviderKind) { const claudeLimits = makeProviderLimits("claude"); const codexLimits = makeProviderLimits("codex"); const grokLimits = makeProviderLimits("grok"); +const opencodeLimits = makeProviderLimits("opencode"); export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -530,16 +540,114 @@ export const make = Effect.gen(function* () { return grokLimits("available", { plan, email, windows }); }); + /** + * Finds the CLI's Zen API key, or the fact that OpenCode is signed in only + * to pass-through providers, or null when there is no sign-in at all. + * Mirrors the CLI's own precedence: an ambient key wins, then the injected + * auth document, then `auth.json` under the XDG data dir. + */ + const readOpenCodeAuthState = Effect.fn("UsageLimitsService.readOpenCodeAuthState")(function* () { + const environment = process.env; + const envKey = environment.OPENCODE_API_KEY?.trim(); + if (envKey !== undefined && envKey.length > 0) { + return { kind: "zen", key: envKey } as const; + } + const injected = environment.OPENCODE_AUTH_CONTENT; + if (injected !== undefined && injected.trim().length > 0) { + const state = parseOpenCodeAuthState(injected); + if (state !== null) return state; + } + + // OpenCode settings do not model a home dir; the CLI resolves its data + // dir through xdg-basedir on every platform, so honor the same override. + const xdgData = environment.XDG_DATA_HOME?.trim(); + const dataDir = + xdgData !== undefined && xdgData.length > 0 + ? xdgData + : path.join(NodeOS.homedir(), ".local", "share"); + const raw = yield* fileSystem + .readFileString(path.join(dataDir, "opencode", "auth.json")) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + return raw === null ? null : parseOpenCodeAuthState(raw); + }); + + const readOpenCodeLimits = Effect.fn("UsageLimitsService.readOpenCodeLimits")(function* () { + const auth = yield* readOpenCodeAuthState(); + if (auth === null) { + return opencodeLimits("unauthenticated", { + message: "OpenCode is not signed in on this environment.", + }); + } + if (auth.kind !== "zen") { + return opencodeLimits("unsupported", { + message: + "OpenCode is signed in through other providers here; those report their own limits. Zen windows only exist for an OpenCode Zen sign-in.", + }); + } + + const response = yield* httpClient + .execute( + HttpClientRequest.get(OPENCODE_ZEN_USAGE_URL).pipe( + HttpClientRequest.setHeaders({ + authorization: `Bearer ${auth.key}`, + accept: "application/json", + }), + ), + ) + .pipe( + Effect.timeoutOption(REQUEST_TIMEOUT_MS), + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isNone(response)) { + return opencodeLimits("unavailable", { + message: "OpenCode Zen's usage service could not be reached.", + }); + } + const status = response.value.status; + if (status === 401) { + return opencodeLimits("unauthenticated", { + message: + "The stored OpenCode Zen key was rejected. Sign in with opencode again, then retry.", + }); + } + if (status === 403) { + // Zen answers 403 EntitlementError for keys on pay-as-you-go credits: + // a valid sign-in, but with no subscription windows to report. + const body = yield* response.value.json.pipe(Effect.orElseSucceed(() => null)); + return parseOpenCodeErrorType(body) === "EntitlementError" + ? opencodeLimits("unsupported", { message: SUBSCRIPTION_ONLY_MESSAGE }) + : opencodeLimits("unauthenticated", { + message: + "The stored OpenCode Zen key was rejected. Sign in with opencode again, then retry.", + }); + } + if (status < 200 || status >= 300) { + return opencodeLimits("unavailable", { + message: `OpenCode Zen's usage service answered with status ${status}.`, + }); + } + + const payload = yield* response.value.json.pipe(Effect.orElseSucceed(() => null)); + const windows = parseOpenCodeUsageWindows(payload); + if (windows.length === 0) { + return opencodeLimits("unavailable", { + message: + "OpenCode Zen's usage service answered in a shape this version does not understand.", + }); + } + return opencodeLimits("available", { plan: "OpenCode Go", windows }); + }); + const readLimits = Effect.fn("UsageLimitsService.readLimits")(function* () { - const [claude, codex, grok] = yield* Effect.all( - [readClaudeLimits(), readCodexLimits(), readGrokLimits()], + const [claude, codex, grok, opencode] = yield* Effect.all( + [readClaudeLimits(), readCodexLimits(), readGrokLimits(), readOpenCodeLimits()], { concurrency: "unbounded" }, ); const readAt = yield* DateTime.now; return { contractVersion: USAGE_LIMITS_CONTRACT_VERSION, readAt: DateTime.formatIso(readAt), - providers: [claude, codex, grok], + providers: [claude, codex, grok, opencode], } satisfies UsageLimitsSummary; }); diff --git a/apps/server/src/usage/usageLimitsOpenCode.test.ts b/apps/server/src/usage/usageLimitsOpenCode.test.ts new file mode 100644 index 000000000000..d08555aed0cd --- /dev/null +++ b/apps/server/src/usage/usageLimitsOpenCode.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + parseOpenCodeAuthState, + parseOpenCodeErrorType, + parseOpenCodeUsageWindows, +} from "./usageLimitsOpenCode.ts"; + +describe("parseOpenCodeAuthState", () => { + it("picks the Zen API key from the provider-keyed map", () => { + const raw = JSON.stringify({ + opencode: { type: "api", key: "zen-key" }, + anthropic: { type: "oauth", refresh: "r", access: "a", expires: 1 }, + }); + expect(parseOpenCodeAuthState(raw)).toEqual({ kind: "zen", key: "zen-key" }); + }); + + it("reports pass-through-only sign-ins as other", () => { + const raw = JSON.stringify({ + anthropic: { type: "oauth", refresh: "r", access: "a", expires: 1 }, + }); + expect(parseOpenCodeAuthState(raw)).toEqual({ kind: "other" }); + // A non-api opencode entry cannot query the usage endpoint either. + expect( + parseOpenCodeAuthState( + JSON.stringify({ opencode: { type: "oauth", refresh: "r", access: "a", expires: 1 } }), + ), + ).toEqual({ kind: "other" }); + }); + + it("returns null for junk and empty stores", () => { + expect(parseOpenCodeAuthState("not json")).toBeNull(); + expect(parseOpenCodeAuthState("{}")).toBeNull(); + expect( + parseOpenCodeAuthState(JSON.stringify({ opencode: { type: "api", key: " " } })), + ).toBeNull(); + }); +}); + +describe("parseOpenCodeErrorType", () => { + it("reads the error marker, tolerating junk", () => { + expect( + parseOpenCodeErrorType({ + type: "error", + error: { type: "EntitlementError", message: "OpenCode Go subscription required." }, + }), + ).toBe("EntitlementError"); + expect(parseOpenCodeErrorType({ error: {} })).toBeNull(); + expect(parseOpenCodeErrorType(null)).toBeNull(); + }); +}); + +describe("parseOpenCodeUsageWindows", () => { + // Shape observed live from opencode.ai/zen/go/v1/usage (2026-08-18). + const liveResponse = { + usage: { + rolling: { status: "ok", percent: 0, resetsAt: "2026-08-18T11:52:51.022Z" }, + weekly: { status: "ok", percent: 7, resetsAt: "2026-08-24T00:00:00.022Z" }, + monthly: { status: "ok", percent: 4, resetsAt: "2026-09-14T19:31:40.022Z" }, + }, + }; + + it("maps the rolling, weekly and monthly windows", () => { + expect(parseOpenCodeUsageWindows(liveResponse)).toEqual([ + { + id: "rolling", + label: "Session limit", + detail: "Rolling 5-hour window", + utilization: 0, + resetsAt: "2026-08-18T11:52:51.022Z", + }, + { + id: "weekly", + label: "Weekly limit", + detail: "Weekly window", + utilization: 7, + resetsAt: "2026-08-24T00:00:00.022Z", + }, + { + id: "monthly", + label: "Monthly limit", + detail: "Monthly window", + utilization: 4, + resetsAt: "2026-09-14T19:31:40.022Z", + }, + ]); + }); + + it("keeps unknown windows with a humanised label and clamps utilization", () => { + const windows = parseOpenCodeUsageWindows({ + usage: { + weekly: { status: "rate-limited", percent: 103.5, resetsAt: null }, + black_rolling: { status: "ok", percent: 12, resetsAt: "2026-09-01T00:00:00Z" }, + }, + }); + expect(windows).toEqual([ + { + id: "weekly", + label: "Weekly limit", + detail: "Weekly window", + utilization: 103.5, + resetsAt: null, + }, + { + id: "black_rolling", + label: "Black rolling", + detail: null, + utilization: 12, + resetsAt: "2026-09-01T00:00:00Z", + }, + ]); + }); + + it("returns empty for malformed documents", () => { + expect(parseOpenCodeUsageWindows(null)).toEqual([]); + expect(parseOpenCodeUsageWindows({ usage: null })).toEqual([]); + expect(parseOpenCodeUsageWindows({ usage: { weekly: { percent: "lots" } } })).toEqual([]); + }); +}); diff --git a/apps/server/src/usage/usageLimitsOpenCode.ts b/apps/server/src/usage/usageLimitsOpenCode.ts new file mode 100644 index 000000000000..cbae7f550cab --- /dev/null +++ b/apps/server/src/usage/usageLimitsOpenCode.ts @@ -0,0 +1,133 @@ +/** + * Pure parsing for OpenCode Zen subscription limits. + * + * The OpenCode CLI stores credentials in `auth.json` under its XDG data dir + * (`~/.local/share/opencode`), a map keyed by provider id; the `opencode` + * entry carries the Zen API key. The figures come from the Zen console's + * `/zen/go/v1/usage` route, which answers per-window consumption for Go + * subscriptions and a 403 `EntitlementError` for pay-as-you-go credit keys. + * Neither shape is a published contract, so every parser here is defensive: + * unrecognised documents yield `null`/empty rather than an error. + * + * @module usageLimitsOpenCode + */ +import type { UsageLimitWindow } from "@t3tools/contracts"; + +export type OpenCodeAuthState = + /** A Zen API key the usage endpoint accepts. */ + | { readonly kind: "zen"; readonly key: string } + /** Signed in, but only to pass-through providers (Anthropic, OpenAI, ...). */ + | { readonly kind: "other" }; + +/** + * Reads the CLI's `auth.json` map. Only the `opencode` entry's API key can + * query the Zen usage endpoint; other entries prove the CLI is in use but + * carry pass-through credentials whose limits belong to those providers. + */ +export function parseOpenCodeAuthState(raw: string): OpenCodeAuthState | null { + let document: unknown; + try { + document = JSON.parse(raw); + } catch { + return null; + } + if (typeof document !== "object" || document === null) return null; + const record = document as Record; + + const zen = record.opencode; + if (typeof zen === "object" && zen !== null) { + const { type, key } = zen as Record; + if (type === "api" && typeof key === "string" && key.trim().length > 0) { + return { kind: "zen", key: key.trim() }; + } + } + + for (const value of Object.values(record)) { + if (isCredentialEntry(value)) return { kind: "other" }; + } + return null; +} + +/** Matches the CLI's credential shapes: oauth, wellknown, or a non-blank key. */ +function isCredentialEntry(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const { type, key, access } = value as Record; + if (type === "oauth") return typeof access === "string" && access.trim().length > 0; + return typeof key === "string" && key.trim().length > 0; +} + +/** The error marker in Zen's non-2xx bodies, e.g. `EntitlementError`. */ +export function parseOpenCodeErrorType(document: unknown): string | null { + if (typeof document !== "object" || document === null) return null; + const error = (document as Record).error; + if (typeof error !== "object" || error === null) return null; + const type = (error as Record).type; + return typeof type === "string" && type.trim().length > 0 ? type.trim() : null; +} + +/** + * The known usage windows, in display order. The rolling window is Zen's + * 5-hour session window (the gateway's limit errors call it "5 hour"). + */ +const WINDOW_TITLES: readonly { id: string; label: string; detail: string }[] = [ + { id: "rolling", label: "Session limit", detail: "Rolling 5-hour window" }, + { id: "weekly", label: "Weekly limit", detail: "Weekly window" }, + { id: "monthly", label: "Monthly limit", detail: "Monthly window" }, +]; + +/** `black_rolling` → "Black rolling". */ +function humanizeId(id: string): string { + const words = id.replaceAll(/[_-]+/g, " ").trim(); + return words.length === 0 ? id : `${words.charAt(0).toUpperCase()}${words.slice(1)}`; +} + +/** See {@link usageLimitsClaude}: clamped, not rounded. */ +function clampUtilization(value: number): number { + return Math.min(Math.max(value, 0), 999); +} + +function readInstant(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function readWindow( + value: unknown, + title: { id: string; label: string; detail: string | null }, +): UsageLimitWindow | null { + if (typeof value !== "object" || value === null) return null; + const { percent, resetsAt } = value as Record; + if (typeof percent !== "number" || !Number.isFinite(percent)) return null; + return { + id: title.id, + label: title.label, + detail: title.detail, + utilization: clampUtilization(percent), + resetsAt: readInstant(resetsAt), + }; +} + +/** + * Extracts rate windows from `/zen/go/v1/usage`. + * + * The response nests `{status, percent, resetsAt}` per window under `usage`. + * Known windows keep curated labels and order; unknown keys (a future tier's + * windows) still render with a humanised label instead of being dropped. + */ +export function parseOpenCodeUsageWindows(document: unknown): UsageLimitWindow[] { + if (typeof document !== "object" || document === null) return []; + const usage = (document as Record).usage; + if (typeof usage !== "object" || usage === null) return []; + const record = usage as Record; + + const windows: UsageLimitWindow[] = []; + for (const title of WINDOW_TITLES) { + const window = readWindow(record[title.id], title); + if (window !== null) windows.push(window); + } + for (const [id, value] of Object.entries(record)) { + if (WINDOW_TITLES.some((title) => title.id === id)) continue; + const window = readWindow(value, { id, label: humanizeId(id), detail: null }); + if (window !== null) windows.push(window); + } + return windows; +} diff --git a/apps/web/src/components/usage/UsageLimitsContent.tsx b/apps/web/src/components/usage/UsageLimitsContent.tsx index bc240516ce4c..9b8cbf351ce1 100644 --- a/apps/web/src/components/usage/UsageLimitsContent.tsx +++ b/apps/web/src/components/usage/UsageLimitsContent.tsx @@ -14,7 +14,12 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PROVIDER_PRESENTATION } from "./usageProviders"; /** Display order; providers not yet reporting render as placeholders. */ -const LIMITS_PROVIDER_ORDER: readonly UsageLimitsProviderKind[] = ["claude", "codex", "grok"]; +const LIMITS_PROVIDER_ORDER: readonly UsageLimitsProviderKind[] = [ + "claude", + "codex", + "grok", + "opencode", +]; /** * The "Limits" half of the usage page: how much of each subscription rate diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index efad95e531ad..955a2f9bc86f 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -1,6 +1,6 @@ -import type { UsageProviderKind } from "@t3tools/contracts"; +import type { UsageLimitsProviderKind, UsageProviderKind } from "@t3tools/contracts"; -import { ClaudeAI, GrokIcon, type Icon, OpenAI } from "../Icons"; +import { ClaudeAI, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; type UsageProviderPresentation = { readonly label: string; @@ -9,9 +9,9 @@ type UsageProviderPresentation = { }; /** - * Exhaustive presentation for providers supported by the usage contract. - * Declaration order is reused by every chart and table, so adding a provider - * only requires its contract support and one entry here. + * Exhaustive presentation, keyed by the wider limits union: the Limits view + * also presents providers (OpenCode) that report subscription limits without + * having transcript-based usage series. */ export const PROVIDER_PRESENTATION = { codex: { @@ -30,10 +30,18 @@ export const PROVIDER_PRESENTATION = { color: "color-mix(in oklab, var(--contrast-foreground) 72%, var(--background))", mark: GrokIcon, }, -} satisfies Record; + opencode: { + label: "OpenCode", + color: "var(--foreground)", + mark: OpenCodeIcon, + }, +} satisfies Record; -/** Stable provider reading order across charts, summaries, tables, and hover rows. */ -export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[]; +/** + * Stable provider reading order across charts, summaries, tables, and hover + * rows. Only providers with transcript-based usage series belong here. + */ +export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "grok"]; /** Providers with real activity, independent of the metric currently displayed. */ export function providersWithUsage( diff --git a/docs/user/usage.md b/docs/user/usage.md index 2f26031cc822..130016f820e9 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -15,4 +15,5 @@ headline and chart, and refreshing rescans every connected environment. The **Limits** view shows how much of each subscription plan's rate windows is currently used, with reset countdowns per window. Limit info is only available for subscription sign-ins: API-key authentication is billed per token and has no rate windows, so those providers show a notice -instead. Claude Code, Codex and Grok report limits today; other providers will follow. +instead. Claude Code, Codex, Grok and OpenCode (Zen subscriptions) report limits today; +other providers will follow. diff --git a/packages/contracts/src/usageLimits.ts b/packages/contracts/src/usageLimits.ts index c0b4a37ceffc..a5d6a7bfb41c 100644 --- a/packages/contracts/src/usageLimits.ts +++ b/packages/contracts/src/usageLimits.ts @@ -29,7 +29,7 @@ export const USAGE_LIMITS_CONTRACT_VERSION = 1 as const; * transcript-based usage aggregation, so the usage-summary contract must not * imply one exists. */ -export const UsageLimitsProviderKind = Schema.Literals(["claude", "codex", "grok"]); +export const UsageLimitsProviderKind = Schema.Literals(["claude", "codex", "grok", "opencode"]); export type UsageLimitsProviderKind = typeof UsageLimitsProviderKind.Type; /** From c92b4957948655dd77880dc947bcffdd0854672b Mon Sep 17 00:00:00 2001 From: Ahmed Shareef Date: Fri, 28 Aug 2026 13:41:02 +0400 Subject: [PATCH 08/10] fix(web): simplify the usage page header - Replace the usage breadcrumb with an accessible heading and inline date range --- apps/web/src/components/usage/UsagePage.tsx | 24 ++++++--------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 775e45979450..3b149112db90 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -24,11 +24,6 @@ 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 { - WorkspaceBreadcrumb, - WorkspaceBreadcrumbItem, - WorkspaceBreadcrumbSeparator, -} from "../WorkspaceBreadcrumb"; import { WorkspacePageContainer } from "../WorkspacePageContainer"; import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { UsageLimitsContent } from "./UsageLimitsContent"; @@ -124,20 +119,13 @@ export function UsagePage() { : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`; const topbarContent = (
- - -

Usage

-
- {view === "usage" ? ( - <> - - - {windowLabel} - - - ) : null} -
+

Usage

+ {view === "usage" ? ( + + {windowLabel} + + ) : null} {view === "usage" ? ( <>
From 876c8e2f58108a49cb3adbce122d46044037c205 Mon Sep 17 00:00:00 2001 From: Ahmed Shareef Date: Fri, 28 Aug 2026 13:59:51 +0400 Subject: [PATCH 09/10] fix(usage): harden limit readers against malformed data and env overrides - Read env through HostProcessEnvironment like the sibling UsageService - Expand and resolve GROK_HOME/GROK_AUTH_PATH overrides before use - Honor ambient CODEX_HOME in the auth pre-check to match the app server - Report a settings-read failure as unavailable instead of unsupported - Fail closed on a malformed cached Grok proxy origin - Survive overflowing Codex reset instants, invalid Claude currency codes, array-shaped OpenCode usage, and empty Grok period objects --- apps/server/src/usage/UsageLimitsService.ts | 57 ++++++++++++------- .../src/usage/usageLimitsClaude.test.ts | 15 +++++ apps/server/src/usage/usageLimitsClaude.ts | 3 +- .../server/src/usage/usageLimitsCodex.test.ts | 8 +++ apps/server/src/usage/usageLimitsCodex.ts | 2 +- apps/server/src/usage/usageLimitsGrok.test.ts | 17 ++++++ apps/server/src/usage/usageLimitsGrok.ts | 18 ++++-- .../src/usage/usageLimitsOpenCode.test.ts | 2 + apps/server/src/usage/usageLimitsOpenCode.ts | 2 +- docs/user/usage.md | 2 +- 10 files changed, 97 insertions(+), 29 deletions(-) diff --git a/apps/server/src/usage/UsageLimitsService.ts b/apps/server/src/usage/UsageLimitsService.ts index 5a882cc2886a..e780cab535ef 100644 --- a/apps/server/src/usage/UsageLimitsService.ts +++ b/apps/server/src/usage/UsageLimitsService.ts @@ -19,6 +19,7 @@ import * as NodeOS from "node:os"; import { USAGE_LIMITS_CONTRACT_VERSION, + type ClaudeSettings, type ProviderUsageLimits, type UsageLimitsProviderKind, type UsageLimitsSummary, @@ -34,10 +35,11 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as CodexClient from "effect-codex-app-server/client"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as ServerSettings from "../serverSettings.ts"; +import { expandHomePath } from "../pathExpansion.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; @@ -140,6 +142,7 @@ export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const platform = yield* HostProcessPlatform; + const hostEnvironment = yield* HostProcessEnvironment; /** * Reads the CLI's keychain entry. The CLI only writes it on macOS; elsewhere @@ -178,13 +181,10 @@ export const make = Effect.gen(function* () { * storage order: credential file under the Claude home first, then the * macOS login keychain. */ - const readClaudeCredentials = Effect.fn("UsageLimitsService.readClaudeCredentials")(function* () { - const settings = yield* settingsService.getSettings.pipe( - Effect.catchCause(() => Effect.succeed(null)), - ); - if (settings === null) return null; - - const home = yield* resolveClaudeHomePath(settings.providers.claudeAgent).pipe( + const readClaudeCredentials = Effect.fn("UsageLimitsService.readClaudeCredentials")(function* ( + claudeSettings: ClaudeSettings, + ) { + const home = yield* resolveClaudeHomePath(claudeSettings).pipe( Effect.provideService(Path.Path, path), ); // The configured home is either the user home (default install nests @@ -241,7 +241,13 @@ export const make = Effect.gen(function* () { ); const readClaudeLimits = Effect.fn("UsageLimitsService.readClaudeLimits")(function* () { - const credentials = yield* readClaudeCredentials(); + const settings = yield* settingsService.getSettings.pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + if (settings === null) { + return claudeLimits("unavailable", { message: "Server settings could not be read." }); + } + const credentials = yield* readClaudeCredentials(settings.providers.claudeAgent); if (credentials === null) { return claudeLimits("unsupported", { message: SUBSCRIPTION_ONLY_MESSAGE }); } @@ -368,7 +374,14 @@ export const make = Effect.gen(function* () { // API-key auth; a missing file is NOT proof of being signed out, because // Codex can keep credentials in the OS keyring or take a key from the // environment. The app server is the canonical auth state. - const authHome = layout.effectiveHomePath ?? layout.sharedHomePath; + // When no home is configured, the spawned app server inherits ambient + // `CODEX_HOME`, so the pre-check must read the same account's auth file. + const ambientCodexHome = hostEnvironment.CODEX_HOME?.trim(); + const authHome = + layout.effectiveHomePath ?? + (ambientCodexHome !== undefined && ambientCodexHome.length > 0 + ? path.resolve(expandHomePath(ambientCodexHome)) + : layout.sharedHomePath); const raw = yield* fileSystem .readFileString(path.join(authHome, "auth.json")) .pipe(Effect.catchCause(() => Effect.succeed(null))); @@ -448,20 +461,25 @@ export const make = Effect.gen(function* () { const readGrokLimits = Effect.fn("UsageLimitsService.readGrokLimits")(function* () { // Mirrors the CLI's own auth precedence: an ambient xAI API key wins // over the stored sign-in, and API keys have no subscription windows. - const environment = process.env; - const apiKey = environment.XAI_API_KEY ?? environment.GROK_CODE_XAI_API_KEY; + const apiKey = hostEnvironment.XAI_API_KEY ?? hostEnvironment.GROK_CODE_XAI_API_KEY; if (apiKey !== undefined && apiKey.trim().length > 0) { return grokLimits("unsupported", { message: SUBSCRIPTION_ONLY_MESSAGE }); } // Grok settings do not model a home dir; the CLI's own env overrides are // the only relocation mechanism, so honor them the way the CLI would. - const homeOverride = environment.GROK_HOME?.trim(); + // Expand and resolve like the transcript scanner in UsageService: a + // literal `~/...` or relative override must not be read against cwd. + const homeOverride = hostEnvironment.GROK_HOME?.trim(); const grokHome = homeOverride !== undefined && homeOverride.length > 0 - ? homeOverride + ? path.resolve(expandHomePath(homeOverride)) : path.join(NodeOS.homedir(), ".grok"); - const authPath = environment.GROK_AUTH_PATH?.trim() || path.join(grokHome, "auth.json"); + const authOverride = hostEnvironment.GROK_AUTH_PATH?.trim(); + const authPath = + authOverride !== undefined && authOverride.length > 0 + ? path.resolve(expandHomePath(authOverride)) + : path.join(grokHome, "auth.json"); const raw = yield* fileSystem .readFileString(authPath) .pipe(Effect.catchCause(() => Effect.succeed(null))); @@ -482,7 +500,7 @@ export const make = Effect.gen(function* () { .readFileString(path.join(grokHome, "models_cache.json")) .pipe(Effect.catchCause(() => Effect.succeed(null))); const baseUrl = resolveGrokProxyBaseUrl({ - envBaseUrl: environment.GROK_CLI_CHAT_PROXY_BASE_URL, + envBaseUrl: hostEnvironment.GROK_CLI_CHAT_PROXY_BASE_URL, modelsCacheRaw, }); if (baseUrl === null) { @@ -547,12 +565,11 @@ export const make = Effect.gen(function* () { * auth document, then `auth.json` under the XDG data dir. */ const readOpenCodeAuthState = Effect.fn("UsageLimitsService.readOpenCodeAuthState")(function* () { - const environment = process.env; - const envKey = environment.OPENCODE_API_KEY?.trim(); + const envKey = hostEnvironment.OPENCODE_API_KEY?.trim(); if (envKey !== undefined && envKey.length > 0) { return { kind: "zen", key: envKey } as const; } - const injected = environment.OPENCODE_AUTH_CONTENT; + const injected = hostEnvironment.OPENCODE_AUTH_CONTENT; if (injected !== undefined && injected.trim().length > 0) { const state = parseOpenCodeAuthState(injected); if (state !== null) return state; @@ -560,7 +577,7 @@ export const make = Effect.gen(function* () { // OpenCode settings do not model a home dir; the CLI resolves its data // dir through xdg-basedir on every platform, so honor the same override. - const xdgData = environment.XDG_DATA_HOME?.trim(); + const xdgData = hostEnvironment.XDG_DATA_HOME?.trim(); const dataDir = xdgData !== undefined && xdgData.length > 0 ? xdgData diff --git a/apps/server/src/usage/usageLimitsClaude.test.ts b/apps/server/src/usage/usageLimitsClaude.test.ts index c1e79d694200..475e26549b91 100644 --- a/apps/server/src/usage/usageLimitsClaude.test.ts +++ b/apps/server/src/usage/usageLimitsClaude.test.ts @@ -138,6 +138,21 @@ describe("parseClaudeUsageWindows", () => { }); }); + it("falls back to USD when the currency code would crash the formatter", () => { + const windows = parseClaudeUsageWindows({ + limits: [{ kind: "session", percent: 1, resets_at: null, scope: null }], + extra_usage: { + is_enabled: true, + monthly_limit: 5000, + used_credits: 1944, + utilization: 38.88, + currency: "!!!", + decimal_places: 2, + }, + }); + expect(windows.at(-1)?.detail).toBe("$19.44 of $50.00 monthly usage credits"); + }); + it("omits an untouched, disabled extra-usage budget", () => { const windows = parseClaudeUsageWindows({ limits: [{ kind: "session", percent: 1, resets_at: null, scope: null }], diff --git a/apps/server/src/usage/usageLimitsClaude.ts b/apps/server/src/usage/usageLimitsClaude.ts index a50b671aa2ed..412feeb3ec18 100644 --- a/apps/server/src/usage/usageLimitsClaude.ts +++ b/apps/server/src/usage/usageLimitsClaude.ts @@ -156,7 +156,8 @@ function parseExtraUsage(value: unknown): UsageLimitWindow | null { const scale = 10 ** decimal_places; const format = new Intl.NumberFormat("en-US", { style: "currency", - currency: typeof currency === "string" && currency.length === 3 ? currency : "USD", + // Anything but a well-formed ISO 4217 code makes the formatter throw. + currency: typeof currency === "string" && /^[A-Za-z]{3}$/.test(currency) ? currency : "USD", }); detail = `${format.format(used_credits / scale)} of ${format.format(monthly_limit / scale)} monthly usage credits`; } diff --git a/apps/server/src/usage/usageLimitsCodex.test.ts b/apps/server/src/usage/usageLimitsCodex.test.ts index e9193f46ee9a..626e1759d9ac 100644 --- a/apps/server/src/usage/usageLimitsCodex.test.ts +++ b/apps/server/src/usage/usageLimitsCodex.test.ts @@ -123,4 +123,12 @@ describe("mapCodexRateLimits", () => { mapCodexRateLimits({ rateLimits: { primary: { usedPercent: "high" } } }).windows, ).toEqual([]); }); + + it("omits a reset instant whose milliseconds overflow", () => { + const windows = mapCodexRateLimits({ + rateLimits: { primary: { usedPercent: 50, resetsAt: Number.MAX_VALUE } }, + }).windows; + expect(windows).toHaveLength(1); + expect(windows[0]?.resetsAt).toBeNull(); + }); }); diff --git a/apps/server/src/usage/usageLimitsCodex.ts b/apps/server/src/usage/usageLimitsCodex.ts index a517145438d9..7baf3be98845 100644 --- a/apps/server/src/usage/usageLimitsCodex.ts +++ b/apps/server/src/usage/usageLimitsCodex.ts @@ -104,7 +104,7 @@ function readWindow(value: unknown): RawWindow | null { ? windowDurationMins : null, resetsAt: - typeof resetsAt === "number" && Number.isFinite(resetsAt) + typeof resetsAt === "number" && Number.isFinite(resetsAt * 1000) ? DateTime.formatIso(DateTime.makeUnsafe(resetsAt * 1000)) : null, }; diff --git a/apps/server/src/usage/usageLimitsGrok.test.ts b/apps/server/src/usage/usageLimitsGrok.test.ts index 7d303d096aee..b4a53e316121 100644 --- a/apps/server/src/usage/usageLimitsGrok.test.ts +++ b/apps/server/src/usage/usageLimitsGrok.test.ts @@ -43,6 +43,21 @@ describe("resolveGrokProxyBaseUrl", () => { GROK_DEFAULT_PROXY_BASE_URL, ); }); + + it("fails closed on a present but unparseable cached origin", () => { + expect( + resolveGrokProxyBaseUrl({ + envBaseUrl: undefined, + modelsCacheRaw: JSON.stringify({ origin: "https://team-proxy.example/v1" }), + }), + ).toBeNull(); + expect( + resolveGrokProxyBaseUrl({ + envBaseUrl: undefined, + modelsCacheRaw: JSON.stringify({ origin: "not a url/models" }), + }), + ).toBeNull(); + }); }); describe("parseGrokAuthCredentials", () => { @@ -195,6 +210,8 @@ describe("parseGrokBillingWindows", () => { it("returns empty for malformed documents", () => { expect(parseGrokBillingWindows(null)).toEqual([]); expect(parseGrokBillingWindows({ config: { creditUsagePercent: "lots" } })).toEqual([]); + // An empty period object must not read as a 0%-used credits document. + expect(parseGrokBillingWindows({ config: { currentPeriod: {} } })).toEqual([]); // Absence only means zero inside a recognizable credits document. expect(parseGrokBillingWindows({ config: { unrelated: true } })).toEqual([]); }); diff --git a/apps/server/src/usage/usageLimitsGrok.ts b/apps/server/src/usage/usageLimitsGrok.ts index fd87bc9fa0be..16623df1892c 100644 --- a/apps/server/src/usage/usageLimitsGrok.ts +++ b/apps/server/src/usage/usageLimitsGrok.ts @@ -78,7 +78,11 @@ function normalizeBaseUrl(value: string | undefined | null): string | null { * cli_chat_proxy_base_url` in its config; the resolved origin it actually * used last shows up in `models_cache.json`'s `origin`. A bearer meant for a * team proxy must never travel to the public default, so an override that - * cannot be parsed fails closed (null) instead of falling back. + * cannot be parsed fails closed (null) instead of falling back — including a + * cached origin that is present but malformed. A proxy configured only in + * the CLI's config file with no models cache yet is invisible here; we + * deliberately do not re-parse the CLI's config, so that case falls back to + * the default endpoint until the CLI has cached its resolved origin. */ export function resolveGrokProxyBaseUrl(input: { readonly envBaseUrl: string | undefined; @@ -97,9 +101,11 @@ export function resolveGrokProxyBaseUrl(input: { } if (typeof document === "object" && document !== null) { const { origin } = document as Record; - if (typeof origin === "string" && origin.trim().endsWith("/models")) { - const base = normalizeBaseUrl(origin.trim().slice(0, -"/models".length)); - if (base !== null) return base; + if (typeof origin === "string" && origin.trim().length > 0) { + const trimmed = origin.trim(); + return trimmed.endsWith("/models") + ? normalizeBaseUrl(trimmed.slice(0, -"/models".length)) + : null; } } } @@ -184,7 +190,9 @@ export function parseGrokBillingWindows(document: unknown): UsageLimitWindow[] { // 0% usage `creditUsagePercent` is absent entirely. Treat absence as zero // when the period fields confirm this is really the credits document. const isCreditsDocument = - (typeof currentPeriod === "object" && currentPeriod !== null) || + (typeof currentPeriod === "object" && + currentPeriod !== null && + Object.keys(currentPeriod).length > 0) || typeof billingPeriodEnd === "string"; const usagePercentValue = creditUsagePercent === undefined && isCreditsDocument ? 0 : creditUsagePercent; diff --git a/apps/server/src/usage/usageLimitsOpenCode.test.ts b/apps/server/src/usage/usageLimitsOpenCode.test.ts index d08555aed0cd..d39ce8bc3fbf 100644 --- a/apps/server/src/usage/usageLimitsOpenCode.test.ts +++ b/apps/server/src/usage/usageLimitsOpenCode.test.ts @@ -115,5 +115,7 @@ describe("parseOpenCodeUsageWindows", () => { expect(parseOpenCodeUsageWindows(null)).toEqual([]); expect(parseOpenCodeUsageWindows({ usage: null })).toEqual([]); expect(parseOpenCodeUsageWindows({ usage: { weekly: { percent: "lots" } } })).toEqual([]); + // Arrays enumerate like records; indices must not become window ids. + expect(parseOpenCodeUsageWindows({ usage: [{ percent: 10 }] })).toEqual([]); }); }); diff --git a/apps/server/src/usage/usageLimitsOpenCode.ts b/apps/server/src/usage/usageLimitsOpenCode.ts index cbae7f550cab..7dfb491c2831 100644 --- a/apps/server/src/usage/usageLimitsOpenCode.ts +++ b/apps/server/src/usage/usageLimitsOpenCode.ts @@ -116,7 +116,7 @@ function readWindow( export function parseOpenCodeUsageWindows(document: unknown): UsageLimitWindow[] { if (typeof document !== "object" || document === null) return []; const usage = (document as Record).usage; - if (typeof usage !== "object" || usage === null) return []; + if (typeof usage !== "object" || usage === null || Array.isArray(usage)) return []; const record = usage as Record; const windows: UsageLimitWindow[] = []; diff --git a/docs/user/usage.md b/docs/user/usage.md index 130016f820e9..b79770aca1d1 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -13,7 +13,7 @@ Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. headline and chart, and refreshing rescans every connected environment. The **Limits** view shows how much of each subscription plan's rate windows is currently used, -with reset countdowns per window. Limit info is only available for subscription sign-ins: API-key +with a reset countdown when the provider supplies a reset time. Limit info is only available for subscription sign-ins: API-key authentication is billed per token and has no rate windows, so those providers show a notice instead. Claude Code, Codex, Grok and OpenCode (Zen subscriptions) report limits today; other providers will follow. From 8d6f5880a36d1536bd106cf502877e7e6b8eeea0 Mon Sep 17 00:00:00 2001 From: Ahmed Shareef Date: Fri, 28 Aug 2026 13:59:55 +0400 Subject: [PATCH 10/10] fix(web): align Limits view with usage UI conventions - Use the Button primitive for the refresh action like the usage header - Track the runtime contrast adjustment for the OpenCode series color --- apps/web/src/components/usage/UsageLimitsContent.tsx | 10 +++------- apps/web/src/components/usage/usageProviders.ts | 2 +- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/usage/UsageLimitsContent.tsx b/apps/web/src/components/usage/UsageLimitsContent.tsx index 9b8cbf351ce1..e8941eb69117 100644 --- a/apps/web/src/components/usage/UsageLimitsContent.tsx +++ b/apps/web/src/components/usage/UsageLimitsContent.tsx @@ -10,6 +10,7 @@ import { type EnvironmentUsageLimitsStatus, type ProviderLimitsStatus, } from "../../state/usage"; +import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PROVIDER_PRESENTATION } from "./usageProviders"; @@ -55,14 +56,9 @@ export function UsageLimitsContent() {

How much of each plan's rate windows is used right now.

- +
{settling ? ( diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index 955a2f9bc86f..23cb67559d65 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -32,7 +32,7 @@ export const PROVIDER_PRESENTATION = { }, opencode: { label: "OpenCode", - color: "var(--foreground)", + color: "var(--contrast-foreground)", mark: OpenCodeIcon, }, } satisfies Record;