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}
-
- ))}
-
-
-
-
-
-
- {
- if (value === "cost" || value === "tokens") setMetric(value);
- }}
- >
-
- {metric === "cost" ? "Cost" : "Tokens"}
-
-
- Cost
- Tokens
-
-
- selectWindow(Number(value))}>
-
-
- {WINDOW_OPTIONS.find((option) => option.days === windowDays)?.label}
-
-
-
- {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}
+
+ ))}
+
+
+
+
+
+
+ {
+ if (value === "cost" || value === "tokens") setMetric(value);
+ }}
+ >
+
+ {metric === "cost" ? "Cost" : "Tokens"}
+
+
+ Cost
+ Tokens
+
+
+ selectWindow(Number(value))}
+ >
+
+
+ {WINDOW_OPTIONS.find((option) => option.days === windowDays)?.label}
+
+
+
+ {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
;