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..e780cab535ef --- /dev/null +++ b/apps/server/src/usage/UsageLimitsService.ts @@ -0,0 +1,674 @@ +/** + * 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 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`, and Grok's OIDC sign-in (from `auth.json` under + * 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 + */ +import * as NodeOS from "node:os"; + +import { + USAGE_LIMITS_CONTRACT_VERSION, + type ClaudeSettings, + type ProviderUsageLimits, + type UsageLimitsProviderKind, + 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 * as CodexClient from "effect-codex-app-server/client"; + +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"; +import { codexAppServerArgs } from "../provider/Layers/codexLaunchArgs.ts"; +import { + claudePlanLabel, + parseClaudeOauthCredentials, + parseClaudeProfileEmail, + parseClaudeUsageWindows, +} from "./usageLimitsClaude.ts"; +import { codexPlanLabel, mapCodexRateLimits, parseCodexAuthKind } from "./usageLimitsCodex.ts"; +import { + grokPlanLabel, + parseGrokAuthCredentials, + parseGrokBillingWindows, + 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"; + +/** 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"; + +/** 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"; + +/** The credential payload the CLI stores in the macOS login keychain. */ +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."; + +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 makeProviderLimits(provider: UsageLimitsProviderKind) { + return ( + availability: ProviderUsageLimits["availability"], + 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"); +const grokLimits = makeProviderLimits("grok"); +const opencodeLimits = makeProviderLimits("opencode"); + +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; + const hostEnvironment = yield* HostProcessEnvironment; + + /** + * 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* ( + claudeSettings: ClaudeSettings, + ) { + const home = yield* resolveClaudeHomePath(claudeSettings).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 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 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 }); + } + + const plan = claudePlanLabel(credentials.subscriptionType); + 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, + email, + message: "Claude's limit service could not be reached.", + }); + } + const status = response.value.status; + if (status === 401 || status === 403) { + return claudeLimits("unauthenticated", { + plan, + 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", { + plan, + 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", { + plan, + email, + message: "Claude's limit service answered in a shape this version does not understand.", + }); + } + return claudeLimits("available", { plan, email, windows }); + }); + + /** + * 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 requestCodexAccountLimits = Effect.fn("UsageLimitsService.requestCodexAccountLimits")( + 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); + 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), + (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", { message: "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. + // 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. + // 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))); + const authKind = raw === null ? "none" : parseCodexAuthKind(raw); + if (authKind === "apiKey") { + return codexLimits("unsupported", { message: SUBSCRIPTION_ONLY_MESSAGE }); + } + + const response = yield* requestCodexAccountLimits({ + binaryPath: codexSettings.binaryPath, + homePath: layout.effectiveHomePath, + launchArgs: codexSettings.launchArgs, + }); + if (response === null) { + // 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", { 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", { + message: "Codex is not signed in on this environment.", + }); + } + if (account !== null && (account.type === "apiKey" || account.type === "amazonBedrock")) { + 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 ?? chatgptAccount?.planType ?? null); + if (windows.length === 0) { + return codexLimits("unavailable", { + plan, + email, + message: "Codex answered in a shape this version does not understand.", + }); + } + return codexLimits("available", { plan, email, windows }); + }); + + 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 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. + // 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 + ? path.resolve(expandHomePath(homeOverride)) + : path.join(NodeOS.homedir(), ".grok"); + 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))); + 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: hostEnvironment.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 }); + }); + + /** + * 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 envKey = hostEnvironment.OPENCODE_API_KEY?.trim(); + if (envKey !== undefined && envKey.length > 0) { + return { kind: "zen", key: envKey } as const; + } + const injected = hostEnvironment.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 = hostEnvironment.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, 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, opencode], + } 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..475e26549b91 --- /dev/null +++ b/apps/server/src/usage/usageLimitsClaude.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + claudePlanLabel, + parseClaudeOauthCredentials, + parseClaudeProfileEmail, + 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("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({ + 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("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 }], + 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..412feeb3ec18 --- /dev/null +++ b/apps/server/src/usage/usageLimitsClaude.ts @@ -0,0 +1,224 @@ +/** + * 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, + }; +} + +/** 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", + 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", + // 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`; + } + + 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/usage/usageLimitsCodex.test.ts b/apps/server/src/usage/usageLimitsCodex.test.ts new file mode 100644 index 000000000000..626e1759d9ac --- /dev/null +++ b/apps/server/src/usage/usageLimitsCodex.test.ts @@ -0,0 +1,134 @@ +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([]); + }); + + 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 new file mode 100644 index 000000000000..7baf3be98845 --- /dev/null +++ b/apps/server/src/usage/usageLimitsCodex.ts @@ -0,0 +1,182 @@ +/** + * 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 * 1000) + ? 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) { + // 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); + } + } + if (windows.length === 0) { + windows.push(...snapshotWindows("codex", rateLimits)); + } + planType ??= snapshotPlanType(rateLimits); + + return { windows, planType }; +} diff --git a/apps/server/src/usage/usageLimitsGrok.test.ts b/apps/server/src/usage/usageLimitsGrok.test.ts new file mode 100644 index 000000000000..b4a53e316121 --- /dev/null +++ b/apps/server/src/usage/usageLimitsGrok.test.ts @@ -0,0 +1,218 @@ +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, + ); + }); + + 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", () => { + 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("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([]); + // 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 new file mode 100644 index 000000000000..16623df1892c --- /dev/null +++ b/apps/server/src/usage/usageLimitsGrok.ts @@ -0,0 +1,241 @@ +/** + * 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 — 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; + 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().length > 0) { + const trimmed = origin.trim(); + return trimmed.endsWith("/models") + ? normalizeBaseUrl(trimmed.slice(0, -"/models".length)) + : null; + } + } + } + + 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 + >; + // 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 && + Object.keys(currentPeriod).length > 0) || + 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; + 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(usagePercentValue), + 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/server/src/usage/usageLimitsOpenCode.test.ts b/apps/server/src/usage/usageLimitsOpenCode.test.ts new file mode 100644 index 000000000000..d39ce8bc3fbf --- /dev/null +++ b/apps/server/src/usage/usageLimitsOpenCode.test.ts @@ -0,0 +1,121 @@ +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([]); + // 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 new file mode 100644 index 000000000000..7dfb491c2831 --- /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 || Array.isArray(usage)) 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/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..e8941eb69117 --- /dev/null +++ b/apps/web/src/components/usage/UsageLimitsContent.tsx @@ -0,0 +1,295 @@ +import type { UsageLimitWindow, UsageLimitsProviderKind } 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 { Button } from "../ui/button"; +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", + "opencode", +]; + +/** + * 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."} +

+ )} + + {limits.email !== null || multiEnvironment ? ( + + {[ + limits.email, + multiEnvironment ? `Reported by ${entry.environmentLabels.join(", ")}` : null, + ] + .filter((part) => part !== null) + .join(" · ")} + + ) : null} +
+ ); +} + +function LimitWindowRow({ + provider, + window, + nowMs, +}: { + readonly provider: UsageLimitsProviderKind; + 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: UsageLimitsProviderKind, 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: UsageLimitsProviderKind }) { + 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..3b149112db90 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -24,13 +24,9 @@ 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"; import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; @@ -41,6 +37,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 +55,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); @@ -111,93 +119,108 @@ export function UsagePage() { : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`; const topbarContent = (
- - -

Usage

-
- - - {windowLabel} - -
-
- { - 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} - - ))} - - -
-
- - - -
+

Usage

+ + {view === "usage" ? ( + + {windowLabel} + + ) : null} + {view === "usage" ? ( + <> +
+ { + const value = next[0]; + if (value === "cost" || value === "tokens") setMetric(value); + }} + > + {(["cost", "tokens"] as const).map((option) => ( + + {option === "cost" ? "Cost" : "Tokens"} + + ))} + + { + const value = next[0]; + if (value) selectWindow(Number(value)); + }} + > + {WINDOW_OPTIONS.map((option) => ( + + {option.label} + + ))} + + +
+
+ + + +
+ + ) : null}
); @@ -208,7 +231,9 @@ export function UsagePage() { - {settling ? ( + {view === "limits" ? ( + + ) : settling ? ( <> {environments.length > 1 ? : null} @@ -464,6 +489,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/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index efad95e531ad..23cb67559d65 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(--contrast-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/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index ba78a61d8a88..41d52b91468f 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -9,7 +9,12 @@ import { useAtomValue } from "@effect/atom-react"; import { USAGE_CONTRACT_VERSION, + USAGE_LIMITS_CONTRACT_VERSION, type EnvironmentId, + type ProviderUsageLimits, + type UsageLimitsSummary, + type UsageLimitsProviderKind, + type UsageProviderKind, type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; @@ -134,3 +139,154 @@ 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: UsageLimitsProviderKind; + /** 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. + // 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; + 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 = + 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] }); + } 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..b79770aca1d1 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -11,3 +11,9 @@ 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 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. 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..a5d6a7bfb41c --- /dev/null +++ b/packages/contracts/src/usageLimits.ts @@ -0,0 +1,102 @@ +/** + * 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"; + +/** + * 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; + +/** + * 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", "opencode"]); +export type UsageLimitsProviderKind = typeof UsageLimitsProviderKind.Type; + +/** + * 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: UsageLimitsProviderKind, + 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), + /** + * 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;