From 6cc1d67f077c2f4ac8e770d84a2d033d355a0128 Mon Sep 17 00:00:00 2001 From: naveed949 Date: Tue, 11 Aug 2026 12:09:46 +0500 Subject: [PATCH 01/29] feat(usage): show Codex subscription allowance --- apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/provider/Drivers/CodexDriver.ts | 13 + .../Layers/CodexAllowanceReader.test.ts | 102 +++ .../provider/Layers/CodexAllowanceReader.ts | 123 +++ .../src/provider/Layers/CodexProvider.ts | 117 +-- apps/server/src/provider/ProviderDriver.ts | 4 +- .../Services/ProviderAllowanceReader.ts | 21 + apps/server/src/server.test.ts | 74 ++ apps/server/src/server.ts | 8 +- .../SubscriptionAllowanceService.test.ts | 70 ++ .../src/usage/SubscriptionAllowanceService.ts | 111 +++ apps/server/src/ws.ts | 7 + .../src/components/usage/UsagePage.test.tsx | 26 + apps/web/src/components/usage/UsagePage.tsx | 789 ++++++++++++------ .../components/usage/usageAllowance.test.ts | 23 + .../src/components/usage/usageAllowance.ts | 29 + .../src/state/subscriptionAllowance.test.ts | 41 + apps/web/src/state/subscriptionAllowance.ts | 100 +++ packages/client-runtime/src/state/server.ts | 5 + packages/contracts/src/rpc.ts | 18 +- packages/contracts/src/usage.test.ts | 35 + packages/contracts/src/usage.ts | 60 +- 22 files changed, 1451 insertions(+), 326 deletions(-) create mode 100644 apps/server/src/provider/Layers/CodexAllowanceReader.test.ts create mode 100644 apps/server/src/provider/Layers/CodexAllowanceReader.ts create mode 100644 apps/server/src/provider/Services/ProviderAllowanceReader.ts create mode 100644 apps/server/src/usage/SubscriptionAllowanceService.test.ts create mode 100644 apps/server/src/usage/SubscriptionAllowanceService.ts create mode 100644 apps/web/src/components/usage/UsagePage.test.tsx create mode 100644 apps/web/src/components/usage/usageAllowance.test.ts create mode 100644 apps/web/src/components/usage/usageAllowance.ts create mode 100644 apps/web/src/state/subscriptionAllowance.test.ts create mode 100644 apps/web/src/state/subscriptionAllowance.ts create mode 100644 packages/contracts/src/usage.test.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 4ad28691a4f2..cc1b095a52e2 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.serverGetSubscriptionAllowance]: AuthOrchestrationReadScope, [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 15d7a1ff0216..be9996fd6dff 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -37,6 +37,8 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; +import { makeCodexAllowanceReader } from "../Layers/CodexAllowanceReader.ts"; +import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; @@ -118,6 +120,7 @@ export const CodexDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; + const { cwd } = yield* ServerConfig; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const homeLayout = yield* resolveCodexHomeLayout(config); @@ -144,6 +147,15 @@ export const CodexDriver: ProviderDriver = { enabled, homePath: homeLayout.effectiveHomePath ?? "", } satisfies CodexSettings; + const allowanceReader = makeCodexAllowanceReader({ + instanceId, + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, + launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), + cwd, + environment: processEnv, + spawner, + }); const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { binaryPath: effectiveConfig.binaryPath, env: processEnv, @@ -207,6 +219,7 @@ export const CodexDriver: ProviderDriver = { enabled, snapshot, adapter, + allowanceReader, textGeneration, } satisfies ProviderInstance; }), diff --git a/apps/server/src/provider/Layers/CodexAllowanceReader.test.ts b/apps/server/src/provider/Layers/CodexAllowanceReader.test.ts new file mode 100644 index 000000000000..2f0b6e84fadf --- /dev/null +++ b/apps/server/src/provider/Layers/CodexAllowanceReader.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as CodexSchema from "effect-codex-app-server/schema"; + +import { ProviderInstanceId } from "@t3tools/contracts"; +import { mapCodexRateLimits } from "./CodexAllowanceReader.ts"; + +const instanceId = ProviderInstanceId.make("codex"); + +describe("mapCodexRateLimits", () => { + it("preserves native windows, percentages, reset timestamps, credits, and spend controls", () => { + const allowance = mapCodexRateLimits({ + instanceId, + response: { + rateLimits: { + primary: { usedPercent: 23, windowDurationMins: 300, resetsAt: 1_786_463_400 }, + secondary: { usedPercent: 4, windowDurationMins: null, resetsAt: null }, + credits: { balance: "12.50", hasCredits: true, unlimited: false }, + individualLimit: { + limit: "100", + remainingPercent: 90, + resetsAt: 1_786_463_400, + used: "10", + }, + limitId: "codex", + limitName: "Codex", + planType: "plus", + spendControlReached: true, + }, + rateLimitsByLimitId: null, + rateLimitResetCredits: null, + } satisfies CodexSchema.V2GetAccountRateLimitsResponse, + }); + + expect(allowance).toEqual({ + provider: "codex", + instanceId, + status: "available", + windows: [ + { + scope: "primary", + usedPercent: 23, + windowDurationMins: 300, + resetsAt: "2026-08-11T15:50:00.000Z", + }, + { + scope: "secondary", + usedPercent: 4, + windowDurationMins: null, + resetsAt: null, + }, + ], + credits: { balance: "12.50", hasCredits: true, unlimited: false }, + spendingControl: { + reached: true, + limit: "100", + remainingPercent: 90, + resetsAt: "2026-08-11T15:50:00.000Z", + used: "10", + }, + }); + expect(allowance).not.toHaveProperty("planType"); + expect(allowance).not.toHaveProperty("limitName"); + }); + + it("preserves a native spend-control state when detail fields are absent", () => { + const allowance = mapCodexRateLimits({ + instanceId, + response: { + rateLimits: { spendControlReached: false }, + rateLimitsByLimitId: null, + rateLimitResetCredits: null, + } satisfies CodexSchema.V2GetAccountRateLimitsResponse, + }); + + expect(allowance).toEqual({ + provider: "codex", + instanceId, + status: "available", + windows: [], + spendingControl: { reached: false }, + }); + }); + + it("does not invent an available allowance when Codex reports no fields", () => { + const allowance = mapCodexRateLimits({ + instanceId, + response: { + rateLimits: {}, + rateLimitsByLimitId: null, + rateLimitResetCredits: null, + } satisfies CodexSchema.V2GetAccountRateLimitsResponse, + }); + + expect(allowance).toEqual({ + provider: "codex", + instanceId, + status: "unavailable", + windows: [], + message: "Codex did not provide subscription usage limits.", + }); + }); +}); diff --git a/apps/server/src/provider/Layers/CodexAllowanceReader.ts b/apps/server/src/provider/Layers/CodexAllowanceReader.ts new file mode 100644 index 000000000000..46b320a6fed8 --- /dev/null +++ b/apps/server/src/provider/Layers/CodexAllowanceReader.ts @@ -0,0 +1,123 @@ +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as CodexSchema from "effect-codex-app-server/schema"; + +import { + type ProviderInstanceId, + type SubscriptionAllowance, + type SubscriptionAllowanceWindow, +} from "@t3tools/contracts"; + +import type { ProviderAllowanceReader } from "../Services/ProviderAllowanceReader.ts"; +import { ProviderAllowanceReadError } from "../Services/ProviderAllowanceReader.ts"; +import { withCodexAppServerClient, type CodexAppServerClientInput } from "./CodexProvider.ts"; + +const CODEX_ALLOWANCE_READ_TIMEOUT = "10 seconds" as const; + +const mapNativeEpochSeconds = (value: number | null): string | null => + value === null ? null : DateTime.formatIso(DateTime.makeUnsafe(value * 1_000)); + +const mapWindow = ( + scope: SubscriptionAllowanceWindow["scope"], + window: CodexSchema.V2GetAccountRateLimitsResponse__RateLimitWindow | null | undefined, +): SubscriptionAllowanceWindow | undefined => { + if (window === null || window === undefined) return undefined; + + return { + scope, + usedPercent: window.usedPercent, + ...(window.windowDurationMins === undefined + ? {} + : { windowDurationMins: window.windowDurationMins }), + ...(window.resetsAt === undefined ? {} : { resetsAt: mapNativeEpochSeconds(window.resetsAt) }), + }; +}; + +export function mapCodexRateLimits(input: { + readonly instanceId: ProviderInstanceId; + readonly response: CodexSchema.V2GetAccountRateLimitsResponse; +}): SubscriptionAllowance { + const rateLimits = input.response.rateLimits; + const windows = [ + mapWindow("primary", rateLimits.primary), + mapWindow("secondary", rateLimits.secondary), + ].filter((window): window is SubscriptionAllowanceWindow => window !== undefined); + + const credits = + rateLimits.credits === null || rateLimits.credits === undefined + ? undefined + : { + ...(rateLimits.credits.balance === undefined + ? {} + : { balance: rateLimits.credits.balance }), + hasCredits: rateLimits.credits.hasCredits, + unlimited: rateLimits.credits.unlimited, + }; + + const individualLimit = rateLimits.individualLimit; + const hasReachedState = + rateLimits.spendControlReached !== undefined && rateLimits.spendControlReached !== null; + const spendingControl = + (individualLimit !== null && individualLimit !== undefined) || hasReachedState + ? { + ...(rateLimits.spendControlReached === undefined + ? {} + : { reached: rateLimits.spendControlReached }), + ...(individualLimit === null || individualLimit === undefined + ? {} + : { + limit: individualLimit.limit, + remainingPercent: individualLimit.remainingPercent, + resetsAt: DateTime.formatIso(DateTime.makeUnsafe(individualLimit.resetsAt * 1_000)), + used: individualLimit.used, + }), + } + : undefined; + + const hasProviderData = + windows.length > 0 || credits !== undefined || spendingControl !== undefined; + + return { + provider: "codex", + instanceId: input.instanceId, + status: hasProviderData ? "available" : "unavailable", + windows, + ...(credits === undefined ? {} : { credits }), + ...(spendingControl === undefined ? {} : { spendingControl }), + ...(hasProviderData ? {} : { message: "Codex did not provide subscription usage limits." }), + } satisfies SubscriptionAllowance; +} + +export interface CodexAllowanceReaderInput extends CodexAppServerClientInput { + readonly instanceId: ProviderInstanceId; + readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; +} + +export function makeCodexAllowanceReader( + input: CodexAllowanceReaderInput, +): ProviderAllowanceReader { + const read = withCodexAppServerClient(input, ({ client }) => + client + .request("account/rateLimits/read", undefined) + .pipe( + Effect.map((response) => mapCodexRateLimits({ instanceId: input.instanceId, response })), + ), + ).pipe( + Effect.timeout(CODEX_ALLOWANCE_READ_TIMEOUT), + Effect.mapError( + (cause) => + new ProviderAllowanceReadError({ + detail: "Codex app-server did not return subscription usage limits in time.", + cause, + }), + ), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, input.spawner), + Effect.scoped, + ); + + return { + provider: "codex", + read, + }; +} diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 5c0f76dff4e3..62a4a0976956 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -50,6 +50,19 @@ export interface CodexAppServerProviderSnapshot { readonly skills: ReadonlyArray; } +export interface CodexAppServerClientInput { + readonly binaryPath: string; + readonly homePath?: string; + readonly launchArgs?: string; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv; +} + +export interface CodexAppServerConnection { + readonly client: CodexClient.CodexAppServerClient["Service"]; + readonly initialize: CodexSchema.V1InitializeResponse; +} + const REASONING_EFFORT_LABELS: Readonly> = { none: "None", minimal: "Minimal", @@ -319,14 +332,16 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { }; } -const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { - readonly binaryPath: string; - readonly homePath?: string; - readonly launchArgs?: string; - readonly cwd: string; - readonly customModels?: ReadonlyArray; - readonly environment?: NodeJS.ProcessEnv; -}) { +/** + * Starts a short-lived Codex app-server, performs the shared handshake, and + * lets a provider-specific reader make one authenticated request. The caller + * owns the surrounding Scope so the child process and protocol client are + * always released when the read finishes. + */ +export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(function* ( + input: CodexAppServerClientInput, + use: (connection: CodexAppServerConnection) => Effect.Effect, +) { // `~` is not shell-expanded when env vars are set via `child_process.spawn`, // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip // "CODEX_HOME points to '~/.codex_work', but that path does not exist". @@ -369,50 +384,56 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun Effect.provide(clientContext), ); - const initialize = yield* client.request("initialize", { - clientInfo: { - name: "t3code_desktop", - title: "T3 Code Desktop", - version: "0.1.0", - }, - capabilities: { - experimentalApi: true, - }, - }); + const initialize = yield* client.request("initialize", buildCodexInitializeParams()); yield* client.notify("initialized", undefined); - // Extract the version string after the first '/' in userAgent, up to the next space or the end - const versionMatch = initialize.userAgent.match(/\/([^\s]+)/); - const version = versionMatch ? versionMatch[1] : undefined; - - const accountResponse = yield* client.request("account/read", {}); - if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { - return { - account: accountResponse, - version, - models: appendCustomCodexModels([], input.customModels ?? []), - skills: [], - } satisfies CodexAppServerProviderSnapshot; - } + return yield* use({ client, initialize }); +}); - const [skillsResponse, models] = yield* Effect.all( - [ - client.request("skills/list", { - cwds: [input.cwd], - }), - requestAllCodexModels(client), - ], - { concurrency: "unbounded" }, +const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly launchArgs?: string; + readonly cwd: string; + readonly customModels?: ReadonlyArray; + readonly environment?: NodeJS.ProcessEnv; +}) { + return yield* withCodexAppServerClient(input, ({ client, initialize }) => + Effect.gen(function* () { + // Extract the version string after the first '/' in userAgent, up to the next space or the end + const versionMatch = initialize.userAgent.match(/\/([^\s]+)/); + const version = versionMatch ? versionMatch[1] : undefined; + + const accountResponse = yield* client.request("account/read", {}); + if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { + return { + account: accountResponse, + version, + models: appendCustomCodexModels([], input.customModels ?? []), + skills: [], + } satisfies CodexAppServerProviderSnapshot; + } + + const [skillsResponse, models] = yield* Effect.all( + [ + client.request("skills/list", { + cwds: [input.cwd], + }), + requestAllCodexModels(client), + ], + { concurrency: "unbounded" }, + ); + + return { + account: accountResponse, + version, + models: applyPreferredCodexDefaultModel( + appendCustomCodexModels(models, input.customModels ?? []), + ), + skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), + } satisfies CodexAppServerProviderSnapshot; + }), ); - - return { - account: accountResponse, - version, - models: applyPreferredCodexDefaultModel( - appendCustomCodexModels(models, input.customModels ?? []), - ), - skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), - } satisfies CodexAppServerProviderSnapshot; }); const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => { diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index c738882c23a4..00366bd65c78 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -33,6 +33,7 @@ import type * as Scope from "effect/Scope"; import type * as TextGeneration from "../textGeneration/TextGeneration.ts"; import type { ProviderAdapterError, ProviderDriverError } from "./Errors.ts"; import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; +import type { ProviderAllowanceReader } from "./Services/ProviderAllowanceReader.ts"; import type { ServerProviderShape } from "./Services/ServerProvider.ts"; /** @@ -56,7 +57,7 @@ export interface ProviderDriverMetadata { * One materialized provider instance. Held by the registry, looked up by * `instanceId`, torn down by closing the scope it was created in. * - * The three "shape" fields are captured closures owned by this instance — + * The four "shape" fields are captured closures owned by this instance — * stopping one instance cannot affect another, and starting a second * instance of the same driver does not reach into the first instance's * state. @@ -70,6 +71,7 @@ export interface ProviderInstance { readonly enabled: boolean; readonly snapshot: ServerProviderShape; readonly adapter: ProviderAdapterShape; + readonly allowanceReader?: ProviderAllowanceReader; readonly textGeneration: TextGeneration.TextGeneration["Service"]; } diff --git a/apps/server/src/provider/Services/ProviderAllowanceReader.ts b/apps/server/src/provider/Services/ProviderAllowanceReader.ts new file mode 100644 index 000000000000..b9901a337c21 --- /dev/null +++ b/apps/server/src/provider/Services/ProviderAllowanceReader.ts @@ -0,0 +1,21 @@ +import type { SubscriptionAllowance, SubscriptionAllowanceProviderKind } from "@t3tools/contracts"; +import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export class ProviderAllowanceReadError extends Schema.TaggedErrorClass()( + "ProviderAllowanceReadError", + { + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +/** + * Optional live subscription reader owned by a materialized provider + * instance. The service that aggregates these readers is intentionally + * provider-agnostic; protocol details stay at the adapter boundary. + */ +export interface ProviderAllowanceReader { + readonly provider: SubscriptionAllowanceProviderKind; + readonly read: Effect.Effect; +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index cb64c6a4802d..547c1b50a219 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -150,6 +150,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageService from "./usage/UsageService.ts"; +import * as SubscriptionAllowanceService from "./usage/SubscriptionAllowanceService.ts"; import * as Data from "effect/Data"; import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts"; @@ -386,6 +387,9 @@ const buildAppUnderTest = (options?: { keybindings?: Partial; providerRegistry?: Partial; serverSettings?: Partial; + subscriptionAllowance?: Partial< + SubscriptionAllowanceService.SubscriptionAllowanceService["Service"] + >; externalLauncher?: Partial; vcsDriver?: Partial; vcsDriverRegistry?: Partial; @@ -827,6 +831,15 @@ const buildAppUnderTest = (options?: { const appLayer = servedRoutesLayer.pipe( Layer.provide(resourceTelemetryLayer), Layer.provide(UsageService.layerTest), + Layer.provide( + Layer.mock(SubscriptionAllowanceService.SubscriptionAllowanceService)({ + read: Effect.succeed({ + readAt: "1970-01-01T00:00:00.000Z", + allowances: [], + }), + ...options?.layers?.subscriptionAllowance, + }), + ), Layer.provide( Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ record: () => Effect.void, @@ -4011,6 +4024,67 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes authenticated websocket rpc server.getSubscriptionAllowance", () => + Effect.gen(function* () { + const snapshot = { + readAt: "2026-08-11T12:00:00.000Z", + allowances: [ + { + provider: "codex" as const, + instanceId: ProviderInstanceId.make("codex"), + status: "available" as const, + windows: [{ scope: "primary" as const, usedPercent: 23 }], + }, + ], + }; + yield* buildAppUnderTest({ + layers: { + subscriptionAllowance: { read: Effect.succeed(snapshot) }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetSubscriptionAllowance]({})), + ); + + assert.deepEqual(response, snapshot); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("requires orchestration read scope for server.getSubscriptionAllowance", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const { response: tokenResponse, body: tokenBody } = yield* exchangeAccessToken( + defaultDesktopBootstrapToken, + { scope: "access:write" }, + ); + assert.equal(tokenResponse.status, 200); + assert.isDefined(tokenBody.access_token); + + const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { + authorization: `Bearer ${tokenBody.access_token ?? ""}`, + }, + }); + const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string }; + assert.equal(wsTicketResponse.status, 200); + + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; + const rpcError = yield* Effect.flip( + Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetSubscriptionAllowance]({})), + ), + ); + + assert.equal(rpcError._tag, "EnvironmentAuthorizationError"); + if (rpcError._tag === "EnvironmentAuthorizationError") { + assert.equal(rpcError.requiredScope, "orchestration:read"); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("does not block server config when editor discovery never resolves", () => Effect.gen(function* () { const discoveryInterrupted = yield* Deferred.make(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ff21c07a861a..4fd279b74eca 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -102,6 +102,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 SubscriptionAllowanceService from "./usage/SubscriptionAllowanceService.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, @@ -160,6 +161,7 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe( ); const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive)); +const SubscriptionAllowanceLayerLive = SubscriptionAllowanceService.layer; const ResourceDiagnosticsLayerLive = Layer.mergeAll( ResourceTelemetryLayerLive, @@ -362,7 +364,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(OrchestrationLayerLive), ); -const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( +const RuntimeCoreBaseDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(ServerSettingsLayerLive), Layer.provideMerge(CheckpointingLayerLive), @@ -373,6 +375,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(Keybindings.layer), + Layer.provideMerge(SubscriptionAllowanceLayerLive), Layer.provideMerge(ProviderRegistryLive), // The instance registry is the new routing keystone — text generation, // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId` @@ -380,6 +383,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // `providerInstances` hydration merges `settings.providers.` // with explicit `providerInstances` entries on boot. Layer.provideMerge(ProviderInstanceRegistryHydrationLive), +); + +const RuntimeCoreDependenciesLive = RuntimeCoreBaseDependenciesLive.pipe( // Shared native/canonical NDJSON writers used by both the per-instance // drivers (native stream, written from inside each `Adapter`) and // `ProviderService` (canonical stream, written after event normalization). diff --git a/apps/server/src/usage/SubscriptionAllowanceService.test.ts b/apps/server/src/usage/SubscriptionAllowanceService.test.ts new file mode 100644 index 000000000000..13b25ac5cb86 --- /dev/null +++ b/apps/server/src/usage/SubscriptionAllowanceService.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { ProviderInstanceId } from "@t3tools/contracts"; + +import { + ProviderAllowanceReadError, + type ProviderAllowanceReader, +} from "../provider/Services/ProviderAllowanceReader.ts"; +import { + readSubscriptionAllowances, + type SubscriptionAllowanceProviderInstance, +} from "./SubscriptionAllowanceService.ts"; + +const readAt = "2026-08-11T12:00:00.000Z"; +const instanceId = ProviderInstanceId.make("codex"); + +const allowance = { + provider: "codex" as const, + instanceId, + status: "available" as const, + windows: [{ scope: "primary" as const, usedPercent: 20 }], +}; + +const reader = (read: ProviderAllowanceReader["read"]): ProviderAllowanceReader => ({ + provider: "codex", + read, +}); + +const instance = ( + input: Partial, +): SubscriptionAllowanceProviderInstance => ({ + instanceId, + enabled: true, + ...input, +}); + +describe("readSubscriptionAllowances", () => { + it.effect("omits disabled instances and contains an unavailable receipt for a failed read", () => + Effect.gen(function* () { + const snapshot = yield* readSubscriptionAllowances( + [ + instance({ allowanceReader: reader(Effect.succeed(allowance)) }), + instance({ enabled: false, allowanceReader: reader(Effect.succeed(allowance)) }), + instance({ + instanceId: ProviderInstanceId.make("codex-failing"), + allowanceReader: reader( + Effect.fail(new ProviderAllowanceReadError({ detail: "provider failure" })), + ), + }), + ], + readAt, + ); + + expect(snapshot).toEqual({ + readAt, + allowances: [ + allowance, + { + provider: "codex", + instanceId: ProviderInstanceId.make("codex-failing"), + status: "unavailable", + windows: [], + message: "Codex subscription usage is unavailable.", + }, + ], + }); + }), + ); +}); diff --git a/apps/server/src/usage/SubscriptionAllowanceService.ts b/apps/server/src/usage/SubscriptionAllowanceService.ts new file mode 100644 index 000000000000..4b0e652f8ee9 --- /dev/null +++ b/apps/server/src/usage/SubscriptionAllowanceService.ts @@ -0,0 +1,111 @@ +import { + type ProviderInstanceId, + type SubscriptionAllowance, + type SubscriptionAllowanceSnapshot, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import type { ProviderAllowanceReader } from "../provider/Services/ProviderAllowanceReader.ts"; +import { ProviderInstanceRegistry } from "../provider/Services/ProviderInstanceRegistry.ts"; + +export interface SubscriptionAllowanceProviderInstance { + readonly instanceId: ProviderInstanceId; + readonly enabled: boolean; + readonly allowanceReader?: ProviderAllowanceReader; +} + +type ReadableSubscriptionAllowanceProviderInstance = SubscriptionAllowanceProviderInstance & { + readonly allowanceReader: ProviderAllowanceReader; +}; + +const unavailableAllowance = (input: { + readonly instanceId: ProviderInstanceId; + readonly provider: SubscriptionAllowance["provider"]; + readonly message: string; +}): SubscriptionAllowance => ({ + provider: input.provider, + instanceId: input.instanceId, + status: "unavailable", + windows: [], + message: input.message, +}); + +/** + * Reads the enabled materialized provider readers in settings order. A broken + * provider read becomes an explicit unavailable allowance so one provider + * cannot turn a multi-provider snapshot into a misleading empty response. + */ +export const readSubscriptionAllowances = Effect.fn("readSubscriptionAllowances")(function* ( + instances: ReadonlyArray, + readAt: string, +) { + const allowances = yield* Effect.forEach( + instances.filter( + (instance): instance is ReadableSubscriptionAllowanceProviderInstance => + instance.enabled && instance.allowanceReader !== undefined, + ), + (instance) => { + const reader = instance.allowanceReader; + return reader.read.pipe( + Effect.catchCause(() => + Effect.logWarning("Provider allowance read failed", { + provider: reader.provider, + instanceId: instance.instanceId, + }).pipe( + Effect.andThen( + Effect.succeed( + unavailableAllowance({ + provider: reader.provider, + instanceId: instance.instanceId, + message: + reader.provider === "codex" + ? "Codex subscription usage is unavailable." + : "Provider subscription usage is unavailable.", + }), + ), + ), + ), + ), + ); + }, + ); + + return { + readAt, + allowances, + } satisfies SubscriptionAllowanceSnapshot; +}); + +export class SubscriptionAllowanceService extends Context.Service< + SubscriptionAllowanceService, + { + readonly read: Effect.Effect; + } +>()("t3/usage/SubscriptionAllowanceService") {} + +export const make = Effect.gen(function* () { + const registry = yield* ProviderInstanceRegistry; + + const read = Effect.gen(function* () { + const readAt = DateTime.formatIso(yield* DateTime.now); + const instances = yield* registry.listInstances; + return yield* readSubscriptionAllowances(instances, readAt); + }); + + return SubscriptionAllowanceService.of({ read }); +}); + +export const layer = Layer.effect(SubscriptionAllowanceService, make); + +export const layerTest = Layer.succeed( + SubscriptionAllowanceService, + SubscriptionAllowanceService.of({ + read: Effect.succeed({ + readAt: "1970-01-01T00:00:00.000Z", + allowances: [], + }), + }), +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5cbe64cd413a..e9df445515dd 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -106,6 +106,7 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageService from "./usage/UsageService.ts"; +import * as SubscriptionAllowanceService from "./usage/SubscriptionAllowanceService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; @@ -414,6 +415,8 @@ const makeWsRpcLayer = ( const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const usage = yield* UsageService.UsageService; + const subscriptionAllowance = + yield* SubscriptionAllowanceService.SubscriptionAllowanceService; const relayClient = yield* RelayClient.RelayClient; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ @@ -1565,6 +1568,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetUsageSummary, usage.readSummary(input), { "rpc.aggregate": "server", }), + [WS_METHODS.serverGetSubscriptionAllowance]: (_input) => + observeRpcEffect(WS_METHODS.serverGetSubscriptionAllowance, subscriptionAllowance.read, { + "rpc.aggregate": "server", + }), [WS_METHODS.serverRetryResourceTelemetry]: (_input) => observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, { "rpc.aggregate": "server", diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx new file mode 100644 index 000000000000..b02bf13d3011 --- /dev/null +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -0,0 +1,26 @@ +import { Children, type ReactElement, type ReactNode } from "react"; +import { describe, expect, it } from "vite-plus/test"; + +import { UsageViewTabs, type UsageView } from "./UsagePage"; + +describe("UsageViewTabs", () => { + it("exposes an accessible Subscription/Historical segmented control", () => { + const selected: UsageView[] = []; + const output = UsageViewTabs({ + value: "subscription", + onChange: (view) => selected.push(view), + }) as ReactElement<{ readonly children: ReactNode; readonly role: string }>; + const buttons = Children.toArray(output.props.children) as ReactElement<{ + readonly children: string; + readonly "aria-pressed": boolean; + readonly onClick: () => void; + }>[]; + + expect(output.props.role).toBe("group"); + expect(buttons.map((button) => button.props.children)).toEqual(["Subscription", "Historical"]); + expect(buttons.map((button) => button.props["aria-pressed"])).toEqual([true, false]); + + buttons[1]!.props.onClick(); + expect(selected).toEqual(["historical"]); + }); +}); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index b550d673f6e0..25c2ae383f2a 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -1,9 +1,11 @@ -import type { UsageProviderKind } from "@t3tools/contracts"; +import type { SubscriptionAllowance, UsageProviderKind } from "@t3tools/contracts"; import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"; +import type { ReactNode } from "react"; import { useMemo, useState } from "react"; import { isElectron } from "../../env"; import { cn } from "../../lib/utils"; +import { useSubscriptionAllowance } from "../../state/subscriptionAllowance"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; import { enumerateDays, @@ -19,6 +21,12 @@ import { SidebarInset } from "../ui/sidebar"; import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; +import { + formatAllowanceDuration, + formatAllowanceResetAt, + formatAllowanceWindowScope, + progressWidthForAllowance, +} from "./usageAllowance"; import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; const WINDOW_OPTIONS = [ @@ -27,7 +35,31 @@ const WINDOW_OPTIONS = [ { days: 90, label: "90 days" }, ] as const; +export type UsageView = "subscription" | "historical"; + +export const USAGE_VIEW_OPTIONS: ReadonlyArray<{ + readonly value: UsageView; + readonly label: string; +}> = [ + { value: "subscription", label: "Subscription" }, + { value: "historical", label: "Historical" }, +]; + export function UsagePage() { + const [view, setView] = useState("historical"); + + return view === "historical" ? ( + + ) : ( + + ); +} + +function HistoricalUsagePage({ + onViewChange, +}: { + readonly onViewChange: (view: UsageView) => void; +}) { const [windowDays, setWindowDays] = useState(30); const [metric, setMetric] = useState("cost"); const [breakdown, setBreakdown] = useState<"model" | "day">("model"); @@ -63,211 +95,125 @@ export function UsagePage() { const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; return ( - -
- {!isElectron && ( -
- - Usage - -
- )} - - {isElectron && ( -
- - Usage - -
- )} - - -
-
-

- {formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)} -

-
-
- {WINDOW_OPTIONS.map((option) => ( - - ))} -
+ +
+ +
+

+ {formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)} +

+
+
+ {WINDOW_OPTIONS.map((option) => ( -
+ ))}
+ +
+
- {settling ? ( - <> - {environments.length > 1 ? : null} - - - ) : ( - <> - + {settling ? ( + <> + {environments.length > 1 ? : null} + + + ) : ( + <> + - {/* Cost first: the financial answer, then the provider split. */} -
- {/* The summary follows the chart toggle, so the headline and the + {/* Cost first: the financial answer, then the provider split. */} +
+ {/* The summary follows the chart toggle, so the headline and the series are always reading the same units. */} -
-
- - {metric === "cost" ? "Raw token cost" : "Processed tokens"} - - - {metric === "cost" - ? `${formatUsd(merged.costUsd)}*` - : formatTokens(merged.totalTokens)} - +
+
+ + {metric === "cost" ? "Raw token cost" : "Processed tokens"} + + + {metric === "cost" + ? `${formatUsd(merged.costUsd)}*` + : formatTokens(merged.totalTokens)} + + + {metric === "cost" + ? "* if billed at full API rate" + : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`} + +
+ + {orderedProviders.map((provider) => { + const share = metric === "cost" ? provider.costShare : provider.tokenShare; + return ( +
+
+ + + {PROVIDER_LABEL[provider.provider]} + + + {metric === "cost" + ? formatUsd(provider.costUsd) + : formatTokens(provider.totalTokens)} + +
+
+
+
{metric === "cost" - ? "* if billed at full API rate" - : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`} + ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` + : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`}
+ ); + })} +
- {orderedProviders.map((provider) => { - const share = metric === "cost" ? provider.costShare : provider.tokenShare; - return ( -
-
- - - {PROVIDER_LABEL[provider.provider]} - - - {metric === "cost" - ? formatUsd(provider.costUsd) - : formatTokens(provider.totalTokens)} - -
-
-
-
- - {metric === "cost" - ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` - : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} - -
- ); - })} -
- -
-
-

- Daily {metric === "tokens" ? "processed tokens" : "cost"} -

-
-
- {(["cost", "tokens"] as const).map((option) => ( - - ))} -
- -
-
- -
-
- -
- - - - - 0 - ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost` - : "vs full input rates" - } - /> -
- -
-
-

Breakdown

+
+
+

+ Daily {metric === "tokens" ? "processed tokens" : "cost"} +

+
- {(["model", "day"] as const).map((option) => ( + {(["cost", "tokens"] as const).map((option) => ( ))}
+
+
+ +
+
+ +
+ + + + + 0 + ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost` + : "vs full input rates" + } + /> +
+ +
+
+

Breakdown

+
+ {(["model", "day"] as const).map((option) => ( + + ))} +
+
- {breakdown === "model" ? ( - - - - - - - + {breakdown === "model" ? ( +
ModelCostShareTokens
+ + + + + + + + + + {merged.models.length === 0 ? ( + + + + ) : ( + merged.models.map((model) => ( + + + + + - - - {merged.models.length === 0 ? ( - - - - ) : ( - merged.models.map((model) => ( - - - - - - - )) - )} - -
ModelCostShareTokens
+ No activity in this window. +
+ + + {model.model} + + + {formatUsd(model.costUsd)} + + {formatPercent(model.costShare)} + + {formatTokens(model.totalTokens)} +
- No activity in this window. -
- - - {model.model} - - - {formatUsd(model.costUsd)} - - {formatPercent(model.costShare)} - - {formatTokens(model.totalTokens)} -
- ) : ( - - - - + )) + )} + +
Day
+ ) : ( + + + + + {PROVIDER_ORDER.map((provider) => ( + + ))} + + + + + + {recentDays.length === 0 ? ( + + + + ) : ( + recentDays.map((day) => ( + + {PROVIDER_ORDER.map((provider) => ( - + ))} - - + + - - - {recentDays.length === 0 ? ( - - - - ) : ( - recentDays.map((day) => ( - - - {PROVIDER_ORDER.map((provider) => ( - - ))} - - - - )) - )} - -
Day + {PROVIDER_LABEL[provider]} + TotalTokens
+ No activity in this window. +
{formatDayShort(day.day)} - {PROVIDER_LABEL[provider]} - + {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)} + TotalTokens + {formatUsd(day.costUsd)} + + {formatTokens(day.totalTokens)} +
- No activity in this window. -
{formatDayShort(day.day)} - {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)} - - {formatUsd(day.costUsd)} - - {formatTokens(day.totalTokens)} -
- )} -
- + )) + )} + + + )} +
+ + )} +
+ + ); +} + +function UsagePageFrame({ children }: { readonly children: ReactNode }) { + return ( + +
+ {!isElectron && ( +
+ + Usage + +
+ )} + + {isElectron && ( +
+ + Usage +
- + )} + + {children}
); } +export function UsageViewTabs({ + value, + onChange, +}: { + readonly value: UsageView; + readonly onChange: (view: UsageView) => void; +}) { + return ( +
+ {USAGE_VIEW_OPTIONS.map((option) => ( + + ))} +
+ ); +} + +function SubscriptionUsagePage({ + onViewChange, +}: { + readonly onViewChange: (view: UsageView) => void; +}) { + const { allowances, environments, isPending, isPartial, refresh } = useSubscriptionAllowance(); + const failedEnvironments = environments.filter((environment) => environment.error !== null); + const settling = isPending || isPartial; + + return ( + +
+ +
+
+

Subscription allowance

+

+ Current provider-reported limits and reset windows. +

+
+ +
+ + {settling ? ( +
+ Reading provider allowance… +
+ ) : allowances.length === 0 ? ( +
+ {failedEnvironments.map((environment) => ( + + {environment.label} could not report subscription usage. + + ))} + No enabled provider reports subscription allowance data. +
+ ) : ( + <> + {isPartial ? ( +

+ Some environments are still reporting. +

+ ) : null} +
+ {allowances.map((entry) => ( + + ))} +
+ + )} +
+
+ ); +} + +function SubscriptionAllowanceCard({ + environmentLabel, + allowance, +}: { + readonly environmentLabel: string; + readonly allowance: SubscriptionAllowance; +}) { + return ( +
+
+

+ + {PROVIDER_LABEL[allowance.provider]} +

+ {environmentLabel} +
+ + {allowance.status === "unavailable" ? ( +

+ {allowance.message ?? "Subscription usage is unavailable."} +

+ ) : ( + <> +
+ {allowance.windows.map((window) => { + const duration = formatAllowanceDuration(window.windowDurationMins); + const reset = + window.resetsAt === undefined || window.resetsAt === null + ? null + : formatAllowanceResetAt(window.resetsAt); + return ( +
+
+ + {formatAllowanceWindowScope(window.scope)} + + {window.usedPercent}% used +
+
+
+
+ {duration !== null || reset !== null ? ( + + {[duration, reset === null ? null : `Resets ${reset}`] + .filter(Boolean) + .join(" · ")} + + ) : null} +
+ ); + })} +
+ + {allowance.credits !== undefined && allowance.credits !== null ? ( +
+ Credits + {allowance.credits.balance !== undefined && allowance.credits.balance !== null ? ( + Balance {allowance.credits.balance} + ) : null} + + {allowance.credits.unlimited + ? "Unlimited" + : allowance.credits.hasCredits + ? "Available" + : "No credits"} + +
+ ) : null} + + {allowance.spendingControl !== undefined && allowance.spendingControl !== null ? ( +
+ Spending control + {allowance.spendingControl.reached !== undefined && + allowance.spendingControl.reached !== null ? ( + {allowance.spendingControl.reached ? "Reached" : "Not reached"} + ) : null} + {allowance.spendingControl.used !== undefined && + allowance.spendingControl.used !== null && + allowance.spendingControl.limit !== undefined && + allowance.spendingControl.limit !== null ? ( + + Used {allowance.spendingControl.used} / {allowance.spendingControl.limit} + + ) : null} + {allowance.spendingControl.remainingPercent !== undefined && + allowance.spendingControl.remainingPercent !== null ? ( + {allowance.spendingControl.remainingPercent}% remaining + ) : null} + {allowance.spendingControl.resetsAt !== undefined && + allowance.spendingControl.resetsAt !== null ? ( + Resets {formatAllowanceResetAt(allowance.spendingControl.resetsAt)} + ) : null} +
+ ) : null} + + )} +
+ ); +} + /** Brand mark for the harness a row belongs to. */ function ProviderMark({ provider, diff --git a/apps/web/src/components/usage/usageAllowance.test.ts b/apps/web/src/components/usage/usageAllowance.test.ts new file mode 100644 index 000000000000..1bde2fef45fb --- /dev/null +++ b/apps/web/src/components/usage/usageAllowance.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + formatAllowanceDuration, + formatAllowanceWindowScope, + progressWidthForAllowance, +} from "./usageAllowance"; + +describe("usage allowance presentation", () => { + it("uses the provider scope and duration without deriving missing values", () => { + expect(formatAllowanceWindowScope("primary")).toBe("Primary limit"); + expect(formatAllowanceWindowScope("secondary")).toBe("Secondary limit"); + expect(formatAllowanceDuration(300)).toBe("5 hours"); + expect(formatAllowanceDuration(null)).toBeNull(); + expect(formatAllowanceDuration(undefined)).toBeNull(); + }); + + it("clamps only the visual progress bar, preserving native percentage text", () => { + expect(progressWidthForAllowance(-1)).toBe(0); + expect(progressWidthForAllowance(42)).toBe(42); + expect(progressWidthForAllowance(101)).toBe(100); + }); +}); diff --git a/apps/web/src/components/usage/usageAllowance.ts b/apps/web/src/components/usage/usageAllowance.ts new file mode 100644 index 000000000000..65fbc29b431f --- /dev/null +++ b/apps/web/src/components/usage/usageAllowance.ts @@ -0,0 +1,29 @@ +import type { SubscriptionAllowanceWindowScope } from "@t3tools/contracts"; + +export function formatAllowanceWindowScope(scope: SubscriptionAllowanceWindowScope): string { + return scope === "primary" ? "Primary limit" : "Secondary limit"; +} + +export function formatAllowanceDuration(minutes: number | null | undefined): string | null { + if (minutes === null || minutes === undefined) return null; + if (minutes % (24 * 60) === 0) { + const days = minutes / (24 * 60); + return `${days} ${days === 1 ? "day" : "days"}`; + } + if (minutes % 60 === 0) { + const hours = minutes / 60; + return `${hours} ${hours === 1 ? "hour" : "hours"}`; + } + return `${minutes} minutes`; +} + +export function progressWidthForAllowance(usedPercent: number): number { + return Math.min(100, Math.max(0, usedPercent)); +} + +export function formatAllowanceResetAt(resetsAt: string): string { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(resetsAt)); +} diff --git a/apps/web/src/state/subscriptionAllowance.test.ts b/apps/web/src/state/subscriptionAllowance.test.ts new file mode 100644 index 000000000000..ac2d70040696 --- /dev/null +++ b/apps/web/src/state/subscriptionAllowance.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { ProviderInstanceId, type EnvironmentId } from "@t3tools/contracts"; + +import { flattenSubscriptionAllowances } from "./subscriptionAllowance"; + +describe("flattenSubscriptionAllowances", () => { + it("keeps environment identity while exposing provider snapshots to the view", () => { + const allowance = { + provider: "codex" as const, + instanceId: ProviderInstanceId.make("codex"), + status: "available" as const, + windows: [{ scope: "primary" as const, usedPercent: 20 }], + }; + + expect( + flattenSubscriptionAllowances([ + { + environmentId: "environment-1" as EnvironmentId, + label: "Laptop", + isPending: false, + error: null, + snapshot: { readAt: "2026-08-11T12:00:00.000Z", allowances: [allowance] }, + }, + { + environmentId: "environment-2" as EnvironmentId, + label: "Desktop", + isPending: false, + error: "This environment could not report subscription usage.", + snapshot: null, + }, + ]), + ).toEqual([ + { + environmentId: "environment-1", + environmentLabel: "Laptop", + allowance, + }, + ]); + }); +}); diff --git a/apps/web/src/state/subscriptionAllowance.ts b/apps/web/src/state/subscriptionAllowance.ts new file mode 100644 index 000000000000..26d4821b7423 --- /dev/null +++ b/apps/web/src/state/subscriptionAllowance.ts @@ -0,0 +1,100 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + type EnvironmentId, + type SubscriptionAllowance, + type SubscriptionAllowanceSnapshot, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useMemo } from "react"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentPresentations } from "./presentation"; +import { serverEnvironment } from "./server"; + +export interface EnvironmentSubscriptionAllowanceStatus { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly isPending: boolean; + readonly error: string | null; + readonly snapshot: SubscriptionAllowanceSnapshot | null; +} + +export interface EnvironmentSubscriptionAllowance { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly allowance: SubscriptionAllowance; +} + +const subscriptionAllowanceByEnvironmentAtom = Atom.make( + (get): readonly EnvironmentSubscriptionAllowanceStatus[] => { + const presentations = get(environmentPresentations.presentationsAtom); + const statuses: EnvironmentSubscriptionAllowanceStatus[] = []; + + for (const [environmentId, presentation] of presentations) { + const result = get(serverEnvironment.subscriptionAllowance({ environmentId, input: {} })); + statuses.push({ + environmentId, + label: presentation.entry.target.label, + isPending: result.waiting, + error: + result._tag === "Failure" + ? "This environment could not report subscription usage." + : null, + snapshot: Option.getOrNull(AsyncResult.value(result)), + }); + } + + return statuses; + }, +).pipe(Atom.withLabel("web-usage:subscription-allowance")); + +export function flattenSubscriptionAllowances( + environments: readonly EnvironmentSubscriptionAllowanceStatus[], +): readonly EnvironmentSubscriptionAllowance[] { + return environments.flatMap( + (environment) => + environment.snapshot?.allowances.map((allowance) => ({ + environmentId: environment.environmentId, + environmentLabel: environment.label, + allowance, + })) ?? [], + ); +} + +export interface SubscriptionAllowanceView { + readonly allowances: readonly EnvironmentSubscriptionAllowance[]; + readonly environments: readonly EnvironmentSubscriptionAllowanceStatus[]; + readonly isPending: boolean; + readonly isPartial: boolean; + readonly refresh: () => void; +} + +export function useSubscriptionAllowance(): SubscriptionAllowanceView { + const environments = useAtomValue(subscriptionAllowanceByEnvironmentAtom); + const allowances = useMemo(() => flattenSubscriptionAllowances(environments), [environments]); + + const refresh = useCallback(() => { + for (const environment of environments) { + appAtomRegistry.refresh( + serverEnvironment.subscriptionAllowance({ + environmentId: environment.environmentId, + input: {}, + }), + ); + } + }, [environments]); + + const answeredCount = environments.filter((environment) => environment.snapshot !== null).length; + const stillReporting = environments.filter( + (environment) => environment.snapshot === null && environment.error === null, + ).length; + + return { + allowances, + environments, + isPending: answeredCount === 0 && stillReporting > 0, + isPartial: answeredCount > 0 && stillReporting > 0, + refresh, + }; +} diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index f579453c27fc..3ad60f7d9a33 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -714,6 +714,11 @@ export function createServerEnvironmentAtoms( tag: WS_METHODS.serverGetUsageSummary, staleTimeMs: 60_000, }), + subscriptionAllowance: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:subscription-allowance", + tag: WS_METHODS.serverGetSubscriptionAllowance, + staleTimeMs: 60_000, + }), configProjection, welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:welcome", diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 59255639995e..637a22b574f4 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -152,7 +152,12 @@ import { ResourceTelemetryRetryResult, ResourceTelemetrySnapshot, } from "./resourceTelemetry.ts"; -import { UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; +import { + SubscriptionAllowanceSnapshot, + UsageReadError, + UsageSummary, + UsageSummaryInput, +} from "./usage.ts"; import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; import { SourceControlCloneRepositoryInput, @@ -246,6 +251,7 @@ export const WS_METHODS = { serverReportHostPowerState: "server.reportHostPowerState", serverGetBackgroundPolicy: "server.getBackgroundPolicy", serverGetUsageSummary: "server.getUsageSummary", + serverGetSubscriptionAllowance: "server.getSubscriptionAllowance", // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", @@ -389,6 +395,15 @@ export const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSumm error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]), }); +export const WsServerGetSubscriptionAllowanceRpc = Rpc.make( + WS_METHODS.serverGetSubscriptionAllowance, + { + payload: Schema.Struct({}), + success: SubscriptionAllowanceSnapshot, + error: EnvironmentAuthorizationError, + }, +); + export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { payload: ServerSignalProcessInput, success: ServerSignalProcessResult, @@ -828,6 +843,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetResourceTelemetryHistoryRpc, WsServerRetryResourceTelemetryRpc, WsServerGetUsageSummaryRpc, + WsServerGetSubscriptionAllowanceRpc, WsServerSignalProcessRpc, WsServerReportClientActivityRpc, WsServerReportHostPowerStateRpc, diff --git a/packages/contracts/src/usage.test.ts b/packages/contracts/src/usage.test.ts new file mode 100644 index 000000000000..92e46b04f24c --- /dev/null +++ b/packages/contracts/src/usage.test.ts @@ -0,0 +1,35 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "@effect/vitest"; + +import { SubscriptionAllowanceSnapshot, USAGE_CONTRACT_VERSION } from "./usage.ts"; + +describe("subscription allowance contract", () => { + it("accepts provider-reported fields without requiring inferred values", () => { + const snapshot = Schema.decodeUnknownSync(SubscriptionAllowanceSnapshot)({ + readAt: "2026-08-11T12:00:00.000Z", + allowances: [ + { + provider: "codex", + instanceId: "codex", + status: "available", + windows: [ + { + scope: "primary", + usedPercent: 23, + windowDurationMins: 300, + resetsAt: "2026-08-11T17:00:00.000Z", + }, + ], + }, + ], + }); + + expect(snapshot.allowances[0]).not.toHaveProperty("credits"); + expect(snapshot.allowances[0]).not.toHaveProperty("spendingControl"); + }); + + it("keeps historical usage contract versioning independent", () => { + expect(USAGE_CONTRACT_VERSION).toBe(3); + expect(SubscriptionAllowanceSnapshot).toBeDefined(); + }); +}); diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 1aa639fe4a00..8f28e335e7a5 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -14,7 +14,8 @@ */ import * as Schema from "effect/Schema"; -import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { IsoDateTime, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; /** * Bumped whenever the shape of {@link UsageSummary} changes incompatibly. The @@ -192,3 +193,60 @@ export class UsageReadError extends Schema.TaggedErrorClass()("U return `Usage read failed (${this.reason}): ${this.detail}`; } } + +/** + * Provider-native subscription allowance reporting. This is deliberately + * separate from {@link UsageSummary}: transcript history and live allowance + * snapshots have different sources, freshness, and failure semantics. + */ +export const SubscriptionAllowanceProviderKind = Schema.Literals(["claude", "codex"]); +export type SubscriptionAllowanceProviderKind = typeof SubscriptionAllowanceProviderKind.Type; + +export const SubscriptionAllowanceStatus = Schema.Literals(["available", "unavailable"]); +export type SubscriptionAllowanceStatus = typeof SubscriptionAllowanceStatus.Type; + +export const SubscriptionAllowanceWindowScope = Schema.Literals(["primary", "secondary"]); +export type SubscriptionAllowanceWindowScope = typeof SubscriptionAllowanceWindowScope.Type; + +export const SubscriptionAllowanceWindow = Schema.Struct({ + scope: SubscriptionAllowanceWindowScope, + usedPercent: Schema.Number, + windowDurationMins: Schema.optionalKey(Schema.Union([NonNegativeInt, Schema.Null])), + resetsAt: Schema.optionalKey(Schema.Union([IsoDateTime, Schema.Null])), +}); +export type SubscriptionAllowanceWindow = typeof SubscriptionAllowanceWindow.Type; + +export const SubscriptionAllowanceCredits = Schema.Struct({ + balance: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + hasCredits: Schema.Boolean, + unlimited: Schema.Boolean, +}); +export type SubscriptionAllowanceCredits = typeof SubscriptionAllowanceCredits.Type; + +export const SubscriptionAllowanceSpendingControl = Schema.Struct({ + reached: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), + limit: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + remainingPercent: Schema.optionalKey(Schema.Union([Schema.Number, Schema.Null])), + resetsAt: Schema.optionalKey(Schema.Union([IsoDateTime, Schema.Null])), + used: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}); +export type SubscriptionAllowanceSpendingControl = typeof SubscriptionAllowanceSpendingControl.Type; + +export const SubscriptionAllowance = Schema.Struct({ + provider: SubscriptionAllowanceProviderKind, + instanceId: ProviderInstanceId, + status: SubscriptionAllowanceStatus, + windows: Schema.Array(SubscriptionAllowanceWindow), + credits: Schema.optionalKey(Schema.Union([SubscriptionAllowanceCredits, Schema.Null])), + spendingControl: Schema.optionalKey( + Schema.Union([SubscriptionAllowanceSpendingControl, Schema.Null]), + ), + message: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type SubscriptionAllowance = typeof SubscriptionAllowance.Type; + +export const SubscriptionAllowanceSnapshot = Schema.Struct({ + readAt: IsoDateTime, + allowances: Schema.Array(SubscriptionAllowance), +}); +export type SubscriptionAllowanceSnapshot = typeof SubscriptionAllowanceSnapshot.Type; From c710135792237418c30dab5bd532dc8dbb588300 Mon Sep 17 00:00:00 2001 From: naveed949 Date: Tue, 11 Aug 2026 13:05:37 +0500 Subject: [PATCH 02/29] feat(usage): add Claude subscription allowance --- .../src/provider/Drivers/ClaudeDriver.ts | 9 + .../Layers/ClaudeAllowanceReader.test.ts | 309 ++++++++++++++++++ .../provider/Layers/ClaudeAllowanceReader.ts | 234 +++++++++++++ .../Services/ProviderAllowanceReader.ts | 3 + .../SubscriptionAllowanceService.test.ts | 35 +- .../src/usage/SubscriptionAllowanceService.ts | 11 +- apps/web/src/components/usage/UsagePage.tsx | 39 ++- .../components/usage/usageAllowance.test.ts | 3 + .../src/components/usage/usageAllowance.ts | 19 +- packages/contracts/src/usage.test.ts | 25 +- packages/contracts/src/usage.ts | 21 +- 11 files changed, 691 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/provider/Layers/ClaudeAllowanceReader.test.ts create mode 100644 apps/server/src/provider/Layers/ClaudeAllowanceReader.ts diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e099d52e5189..24432daf7f5c 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -29,6 +29,7 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; +import { makeClaudeAllowanceReader } from "../Layers/ClaudeAllowanceReader.ts"; import { checkClaudeProviderStatus, makePendingClaudeProvider, @@ -131,6 +132,13 @@ export const ClaudeDriver: ProviderDriver = { instanceId, }); const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings; + const allowanceReader = yield* makeClaudeAllowanceReader({ + instanceId, + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, + environment: processEnv, + cwd, + }); const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { binaryPath: effectiveConfig.binaryPath, env: processEnv, @@ -215,6 +223,7 @@ export const ClaudeDriver: ProviderDriver = { enabled, snapshot, adapter, + allowanceReader, textGeneration, } satisfies ProviderInstance; }), diff --git a/apps/server/src/provider/Layers/ClaudeAllowanceReader.test.ts b/apps/server/src/provider/Layers/ClaudeAllowanceReader.test.ts new file mode 100644 index 000000000000..16fd85f948ae --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeAllowanceReader.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { SDKControlGetUsageResponse, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { ProviderInstanceId } from "@t3tools/contracts"; + +import { + CLAUDE_SUBSCRIPTION_ALLOWANCE_UNAVAILABLE_MESSAGE, + makeClaudeAllowanceReader, + mapClaudeUsage, +} from "./ClaudeAllowanceReader.ts"; + +const instanceId = ProviderInstanceId.make("claude"); + +const unavailableResponse = { + session: { + total_cost_usd: 0, + total_api_duration_ms: 0, + total_duration_ms: 0, + total_lines_added: 0, + total_lines_removed: 0, + model_usage: {}, + }, + subscription_type: null, + rate_limits_available: false, + rate_limits: null, + behaviors: null, +} satisfies SDKControlGetUsageResponse; + +describe("mapClaudeUsage", () => { + it("preserves native windows, nullable utilization, resets, and extra usage", () => { + const allowance = mapClaudeUsage({ + instanceId, + response: { + ...unavailableResponse, + subscription_type: "max", + rate_limits_available: true, + rate_limits: { + five_hour: { utilization: 42.5, resets_at: "2026-08-11T17:00:00.000Z" }, + seven_day: { utilization: null, resets_at: null }, + seven_day_oauth_apps: null, + seven_day_opus: { utilization: 57, resets_at: "2026-08-18T17:00:00.000Z" }, + seven_day_sonnet: { utilization: 12, resets_at: null }, + extra_usage: { + is_enabled: true, + monthly_limit: 50, + used_credits: 7.5, + utilization: 15, + currency: "USD", + }, + }, + }, + }); + + expect(allowance).toEqual({ + provider: "claude", + instanceId, + status: "available", + windows: [ + { + scope: "five_hour", + usedPercent: 42.5, + resetsAt: "2026-08-11T17:00:00.000Z", + }, + { + scope: "seven_day", + usedPercent: null, + resetsAt: null, + }, + { + scope: "seven_day_opus", + usedPercent: 57, + resetsAt: "2026-08-18T17:00:00.000Z", + }, + { + scope: "seven_day_sonnet", + usedPercent: 12, + resetsAt: null, + }, + ], + extraUsage: { + isEnabled: true, + monthlyLimit: 50, + usedCredits: 7.5, + utilization: 15, + currency: "USD", + }, + }); + }); + + it("uses the stable unavailable placeholder when Claude reports no limits", () => { + expect(mapClaudeUsage({ instanceId, response: unavailableResponse })).toEqual({ + provider: "claude", + instanceId, + status: "unavailable", + windows: [], + message: CLAUDE_SUBSCRIPTION_ALLOWANCE_UNAVAILABLE_MESSAGE, + }); + }); +}); + +describe("makeClaudeAllowanceReader", () => { + it.layer(NodeServices.layer)("Claude allowance reader", (it) => { + it.effect("initializes before usage, never yields a user message, and closes the query", () => + Effect.gen(function* () { + const order: string[] = []; + let promptResult: Promise> | undefined; + let options: { readonly settingSources?: ReadonlyArray } | undefined; + + const reader = yield* makeClaudeAllowanceReader({ + instanceId, + binaryPath: "/usr/bin/claude", + homePath: "", + environment: { ENABLE_CLAUDEAI_MCP_SERVERS: "true" }, + cwd: "/workspace", + createQuery: (input) => { + order.push("query"); + options = input.options; + promptResult = input.prompt[Symbol.asyncIterator]().next(); + return { + initializationResult: async () => { + order.push("initialize"); + return {}; + }, + usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => { + order.push("usage"); + return { + ...unavailableResponse, + rate_limits_available: true, + rate_limits: { + five_hour: { utilization: 10, resets_at: null }, + }, + } satisfies SDKControlGetUsageResponse; + }, + close: () => order.push("close"), + }; + }, + }); + + const allowance = yield* reader.read; + const endedPrompt = yield* Effect.promise(() => promptResult!); + + expect(allowance.status).toBe("available"); + expect(order).toEqual(["query", "initialize", "usage", "close"]); + expect(endedPrompt).toEqual({ done: true, value: undefined }); + expect(options?.settingSources).toEqual(["user", "project", "local"]); + }), + ); + + it.effect("reports an unavailable allowance when the SDK method is missing", () => + Effect.gen(function* () { + let closed = false; + const reader = yield* makeClaudeAllowanceReader({ + instanceId, + binaryPath: "/usr/bin/claude", + homePath: "", + createQuery: () => ({ + initializationResult: async () => ({}), + close: () => { + closed = true; + }, + }), + }); + + expect(yield* reader.read).toEqual({ + provider: "claude", + instanceId, + status: "unavailable", + windows: [], + message: CLAUDE_SUBSCRIPTION_ALLOWANCE_UNAVAILABLE_MESSAGE, + }); + expect(closed).toBe(true); + }), + ); + + it.effect("closes the query when usage acquisition fails", () => + Effect.gen(function* () { + let closed = false; + const reader = yield* makeClaudeAllowanceReader({ + instanceId, + binaryPath: "/usr/bin/claude", + homePath: "", + createQuery: () => ({ + initializationResult: async () => ({}), + usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => { + throw new Error("provider rejected request"); + }, + close: () => { + closed = true; + }, + }), + }); + + const result = yield* Effect.exit(reader.read); + + expect(Exit.isFailure(result)).toBe(true); + expect(closed).toBe(true); + }), + ); + + it.effect("bounds a hanging usage request and closes the query", () => + Effect.gen(function* () { + let closed = false; + const reader = yield* makeClaudeAllowanceReader({ + instanceId, + binaryPath: "/usr/bin/claude", + homePath: "", + timeout: "10 millis", + createQuery: () => ({ + initializationResult: async () => ({}), + usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => + new Promise(() => {}), + close: () => { + closed = true; + }, + }), + }); + + const result = yield* Effect.exit(reader.read); + + expect(Exit.isFailure(result)).toBe(true); + expect(closed).toBe(true); + }), + ); + + it.effect("uses the no-turn control wire with a fake Claude CLI", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-allowance-reader-", + }); + const executablePath = path.join(tempDir, "fake-claude.mjs"); + const invocationPath = path.join(tempDir, "invocation.json"); + + yield* fileSystem.writeFileString( + executablePath, + [ + "#!/usr/bin/env node", + 'import { writeFileSync } from "node:fs";', + 'import { createInterface } from "node:readline";', + "const messages = [];", + "const writeInvocation = () => writeFileSync(process.env.T3_INVOCATION_PATH, JSON.stringify({ messages }));", + "const lines = createInterface({ input: process.stdin });", + 'lines.on("line", (line) => {', + " const message = JSON.parse(line);", + " messages.push(message);", + ' if (message.type !== "control_request") return;', + ' if (message.request?.subtype === "initialize") {', + " process.stdout.write(JSON.stringify({", + ' type: "control_response", response: { subtype: "success", request_id: message.request_id, response: {', + ' commands: [], agents: [], output_style: "default", available_output_styles: ["default"], models: [], account: {},', + " } },", + ' }) + "\\n");', + " return;", + " }", + ' if (message.request?.subtype === "get_usage") {', + " writeInvocation();", + " process.stdout.write(JSON.stringify({", + ' type: "control_response", response: { subtype: "success", request_id: message.request_id, response: {', + " session: { total_cost_usd: 0, total_api_duration_ms: 0, total_duration_ms: 0, total_lines_added: 0, total_lines_removed: 0, model_usage: {} },", + ' subscription_type: "max", rate_limits_available: true,', + " rate_limits: { five_hour: { utilization: 25, resets_at: null } }, behaviors: null,", + " } },", + ' }) + "\\n");', + " }", + "});", + "setInterval(() => {}, 1_000);", + "", + ].join("\n"), + ); + yield* fileSystem.chmod(executablePath, 0o755); + + const reader = yield* makeClaudeAllowanceReader({ + instanceId, + binaryPath: executablePath, + homePath: "", + environment: { ...process.env, T3_INVOCATION_PATH: invocationPath }, + cwd: tempDir, + }); + + expect(yield* reader.read).toMatchObject({ + provider: "claude", + instanceId, + status: "available", + windows: [{ scope: "five_hour", usedPercent: 25, resetsAt: null }], + }); + + // @effect-diagnostics-next-line preferSchemaOverJson:off + const invocation = JSON.parse(yield* fileSystem.readFileString(invocationPath)) as { + readonly messages: ReadonlyArray<{ + readonly type?: string; + readonly request?: { readonly subtype?: string }; + }>; + }; + expect( + invocation.messages + .filter((message) => message.type === "control_request") + .map((message) => message.request?.subtype), + ).toEqual(["initialize", "get_usage"]); + expect(invocation.messages.some((message) => message.type === "user")).toBe(false); + }).pipe(Effect.scoped), + ); + }); +}); diff --git a/apps/server/src/provider/Layers/ClaudeAllowanceReader.ts b/apps/server/src/provider/Layers/ClaudeAllowanceReader.ts new file mode 100644 index 000000000000..377ab78ffc13 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeAllowanceReader.ts @@ -0,0 +1,234 @@ +import { + query as claudeQuery, + type SDKControlGetUsageResponse, + type SDKUserMessage, + type Options as ClaudeQueryOptions, +} from "@anthropic-ai/claude-agent-sdk"; +import type { + ClaudeSettings, + ProviderInstanceId, + SubscriptionAllowance, + SubscriptionAllowanceExtraUsage, + SubscriptionAllowanceWindow, +} from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { buildClaudeCapabilitiesProbeQueryOptions } from "./ClaudeProvider.ts"; +import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; +import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import type { ProviderAllowanceReader } from "../Services/ProviderAllowanceReader.ts"; +import { + CLAUDE_SUBSCRIPTION_ALLOWANCE_UNAVAILABLE_MESSAGE, + ProviderAllowanceReadError, +} from "../Services/ProviderAllowanceReader.ts"; + +const CLAUDE_ALLOWANCE_READ_TIMEOUT = "25 seconds" as const; + +type ClaudeRateLimitWindow = { + readonly utilization: number | null; + readonly resets_at: string | null; +}; + +type ClaudeExtraUsage = { + readonly is_enabled: boolean; + readonly monthly_limit: number | null; + readonly used_credits: number | null; + readonly utilization: number | null; + readonly currency?: string | null; +}; + +/** The small part of the SDK Query surface needed by the allowance reader. */ +export interface ClaudeAllowanceQuery { + readonly initializationResult: () => Promise; + readonly usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET?: () => Promise; + readonly close: () => void; +} + +export interface ClaudeAllowanceReaderInput { + readonly instanceId: ProviderInstanceId; + readonly binaryPath: ClaudeSettings["binaryPath"]; + readonly homePath: ClaudeSettings["homePath"]; + readonly environment?: NodeJS.ProcessEnv; + readonly cwd?: string; + readonly timeout?: Duration.Input; + readonly createQuery?: (input: { + readonly prompt: AsyncIterable; + readonly options: ClaudeQueryOptions; + }) => ClaudeAllowanceQuery; +} + +const unavailableAllowance = (instanceId: ProviderInstanceId): SubscriptionAllowance => ({ + provider: "claude", + instanceId, + status: "unavailable", + windows: [], + message: CLAUDE_SUBSCRIPTION_ALLOWANCE_UNAVAILABLE_MESSAGE, +}); + +const mapWindow = ( + scope: SubscriptionAllowanceWindow["scope"], + window: ClaudeRateLimitWindow | null | undefined, +): SubscriptionAllowanceWindow | undefined => { + if (window === null || window === undefined) return undefined; + + return { + scope, + usedPercent: window.utilization, + resetsAt: window.resets_at, + }; +}; + +const mapExtraUsage = ( + extraUsage: ClaudeExtraUsage | null | undefined, +): SubscriptionAllowanceExtraUsage | undefined => { + if (extraUsage === null || extraUsage === undefined) return undefined; + + return { + isEnabled: extraUsage.is_enabled, + monthlyLimit: extraUsage.monthly_limit, + usedCredits: extraUsage.used_credits, + utilization: extraUsage.utilization, + ...(extraUsage.currency === undefined ? {} : { currency: extraUsage.currency }), + }; +}; + +export function mapClaudeUsage(input: { + readonly instanceId: ProviderInstanceId; + readonly response: SDKControlGetUsageResponse; +}): SubscriptionAllowance { + const rateLimits = input.response.rate_limits; + if (!input.response.rate_limits_available || !rateLimits) { + return unavailableAllowance(input.instanceId); + } + + const windows = [ + mapWindow("five_hour", rateLimits.five_hour), + mapWindow("seven_day", rateLimits.seven_day), + mapWindow("seven_day_oauth_apps", rateLimits.seven_day_oauth_apps), + mapWindow("seven_day_opus", rateLimits.seven_day_opus), + mapWindow("seven_day_sonnet", rateLimits.seven_day_sonnet), + ].filter((window): window is SubscriptionAllowanceWindow => window !== undefined); + const extraUsage = mapExtraUsage(rateLimits.extra_usage); + + if (windows.length === 0 && extraUsage === undefined) { + return unavailableAllowance(input.instanceId); + } + + return { + provider: "claude", + instanceId: input.instanceId, + status: "available", + windows, + ...(extraUsage === undefined ? {} : { extraUsage }), + } satisfies SubscriptionAllowance; +} + +const neverYieldingPrompt = (signal: AbortSignal): AsyncIterable => + // oxlint-disable-next-line require-yield + (async function* (): AsyncGenerator { + await new Promise((resolve) => { + if (signal.aborted) { + resolve(); + return; + } + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + })(); + +const makeReadError = (cause: unknown): ProviderAllowanceReadError => + new ProviderAllowanceReadError({ + detail: "Claude Agent SDK did not return subscription usage limits.", + cause, + }); + +const isProviderAllowanceReadError = Schema.is(ProviderAllowanceReadError); + +const withTimeout =
(promise: Promise, timeout: Duration.Input): Promise => { + const timeoutMs = Duration.toMillis(timeout); + if (!Number.isFinite(timeoutMs)) return promise; + + let timer: ReturnType | undefined; + return new Promise((resolve, reject) => { + // The SDK promise is external to Effect and may ignore interruption, so the + // adapter needs a real deadline before its cleanup runs. + // @effect-diagnostics-next-line globalTimers:off + timer = setTimeout( + () => reject(new Error("Claude Agent SDK allowance read timed out.")), + timeoutMs, + ); + promise.then(resolve, reject); + }).finally(() => { + if (timer !== undefined) clearTimeout(timer); + }); +}; + +export const makeClaudeAllowanceReader = Effect.fn("makeClaudeAllowanceReader")(function* ( + input: ClaudeAllowanceReaderInput, +) { + const claudeEnvironment = yield* makeClaudeEnvironment( + { homePath: input.homePath }, + input.environment, + ); + const executablePath = yield* resolveClaudeSdkExecutablePath(input.binaryPath, claudeEnvironment); + const createQuery = + input.createQuery ?? + ((queryInput: { + readonly prompt: AsyncIterable; + readonly options: ClaudeQueryOptions; + }) => claudeQuery(queryInput)); + + const read = Effect.gen(function* () { + const abortController = new AbortController(); + let query: ClaudeAllowanceQuery | undefined; + + return yield* Effect.tryPromise({ + try: () => + withTimeout( + (async () => { + query = createQuery({ + prompt: neverYieldingPrompt(abortController.signal), + options: buildClaudeCapabilitiesProbeQueryOptions({ + executablePath, + abortController, + environment: claudeEnvironment, + cwd: input.cwd, + }), + }); + + await query.initializationResult(); + const readUsage = query.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET; + if (readUsage === undefined) { + return unavailableAllowance(input.instanceId); + } + + return mapClaudeUsage({ + instanceId: input.instanceId, + response: await readUsage.call(query), + }); + })(), + input.timeout ?? CLAUDE_ALLOWANCE_READ_TIMEOUT, + ), + catch: makeReadError, + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (!abortController.signal.aborted) abortController.abort(); + query?.close(); + }), + ), + Effect.mapError((cause) => + isProviderAllowanceReadError(cause) ? cause : makeReadError(cause), + ), + ); + }); + + return { + provider: "claude", + read, + } satisfies ProviderAllowanceReader; +}); + +export { CLAUDE_ALLOWANCE_READ_TIMEOUT }; +export { CLAUDE_SUBSCRIPTION_ALLOWANCE_UNAVAILABLE_MESSAGE }; diff --git a/apps/server/src/provider/Services/ProviderAllowanceReader.ts b/apps/server/src/provider/Services/ProviderAllowanceReader.ts index b9901a337c21..f1ab8269ed23 100644 --- a/apps/server/src/provider/Services/ProviderAllowanceReader.ts +++ b/apps/server/src/provider/Services/ProviderAllowanceReader.ts @@ -2,6 +2,9 @@ import type { SubscriptionAllowance, SubscriptionAllowanceProviderKind } from "@ import type * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +export const CLAUDE_SUBSCRIPTION_ALLOWANCE_UNAVAILABLE_MESSAGE = + "Claude subscription usage is unavailable. Claude did not provide usage limits."; + export class ProviderAllowanceReadError extends Schema.TaggedErrorClass()( "ProviderAllowanceReadError", { diff --git a/apps/server/src/usage/SubscriptionAllowanceService.test.ts b/apps/server/src/usage/SubscriptionAllowanceService.test.ts index 13b25ac5cb86..53d1daf7ca33 100644 --- a/apps/server/src/usage/SubscriptionAllowanceService.test.ts +++ b/apps/server/src/usage/SubscriptionAllowanceService.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import { ProviderInstanceId } from "@t3tools/contracts"; import { + CLAUDE_SUBSCRIPTION_ALLOWANCE_UNAVAILABLE_MESSAGE, ProviderAllowanceReadError, type ProviderAllowanceReader, } from "../provider/Services/ProviderAllowanceReader.ts"; @@ -22,8 +23,11 @@ const allowance = { windows: [{ scope: "primary" as const, usedPercent: 20 }], }; -const reader = (read: ProviderAllowanceReader["read"]): ProviderAllowanceReader => ({ - provider: "codex", +const reader = ( + read: ProviderAllowanceReader["read"], + provider: ProviderAllowanceReader["provider"] = "codex", +): ProviderAllowanceReader => ({ + provider, read, }); @@ -67,4 +71,31 @@ describe("readSubscriptionAllowances", () => { }); }), ); + + it.effect("keeps the Claude unavailable presentation stable when acquisition fails", () => + Effect.gen(function* () { + const snapshot = yield* readSubscriptionAllowances( + [ + instance({ + instanceId: ProviderInstanceId.make("claude"), + allowanceReader: reader( + Effect.fail(new ProviderAllowanceReadError({ detail: "provider failure" })), + "claude", + ), + }), + ], + readAt, + ); + + expect(snapshot.allowances).toEqual([ + { + provider: "claude", + instanceId: ProviderInstanceId.make("claude"), + status: "unavailable", + windows: [], + message: CLAUDE_SUBSCRIPTION_ALLOWANCE_UNAVAILABLE_MESSAGE, + }, + ]); + }), + ); }); diff --git a/apps/server/src/usage/SubscriptionAllowanceService.ts b/apps/server/src/usage/SubscriptionAllowanceService.ts index 4b0e652f8ee9..a441ae156428 100644 --- a/apps/server/src/usage/SubscriptionAllowanceService.ts +++ b/apps/server/src/usage/SubscriptionAllowanceService.ts @@ -8,7 +8,10 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { ProviderAllowanceReader } from "../provider/Services/ProviderAllowanceReader.ts"; +import { + CLAUDE_SUBSCRIPTION_ALLOWANCE_UNAVAILABLE_MESSAGE, + type ProviderAllowanceReader, +} from "../provider/Services/ProviderAllowanceReader.ts"; import { ProviderInstanceRegistry } from "../provider/Services/ProviderInstanceRegistry.ts"; export interface SubscriptionAllowanceProviderInstance { @@ -61,9 +64,9 @@ export const readSubscriptionAllowances = Effect.fn("readSubscriptionAllowances" provider: reader.provider, instanceId: instance.instanceId, message: - reader.provider === "codex" - ? "Codex subscription usage is unavailable." - : "Provider subscription usage is unavailable.", + reader.provider === "claude" + ? CLAUDE_SUBSCRIPTION_ALLOWANCE_UNAVAILABLE_MESSAGE + : "Codex subscription usage is unavailable.", }), ), ), diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 25c2ae383f2a..451b78fb719f 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -544,20 +544,25 @@ function SubscriptionAllowanceCard({ window.resetsAt === undefined || window.resetsAt === null ? null : formatAllowanceResetAt(window.resetsAt); + const hasUsage = window.usedPercent !== undefined && window.usedPercent !== null; return (
{formatAllowanceWindowScope(window.scope)} - {window.usedPercent}% used -
-
-
+ + {hasUsage ? `${window.usedPercent}% used` : "Not reported"} +
+ {hasUsage ? ( +
+
+
+ ) : null} {duration !== null || reset !== null ? ( {[duration, reset === null ? null : `Resets ${reset}`] @@ -611,6 +616,26 @@ function SubscriptionAllowanceCard({ ) : null}
) : null} + + {allowance.extraUsage !== undefined && allowance.extraUsage !== null ? ( +
+ Extra usage + {allowance.extraUsage.isEnabled ? "Enabled" : "Disabled"} + {allowance.extraUsage.monthlyLimit !== null ? ( + Monthly limit {allowance.extraUsage.monthlyLimit} + ) : null} + {allowance.extraUsage.usedCredits !== null ? ( + Used credits {allowance.extraUsage.usedCredits} + ) : null} + {allowance.extraUsage.utilization !== null ? ( + {allowance.extraUsage.utilization}% used + ) : null} + {allowance.extraUsage.currency !== undefined && + allowance.extraUsage.currency !== null ? ( + {allowance.extraUsage.currency} + ) : null} +
+ ) : null} )} diff --git a/apps/web/src/components/usage/usageAllowance.test.ts b/apps/web/src/components/usage/usageAllowance.test.ts index 1bde2fef45fb..d276e7beb502 100644 --- a/apps/web/src/components/usage/usageAllowance.test.ts +++ b/apps/web/src/components/usage/usageAllowance.test.ts @@ -10,6 +10,9 @@ describe("usage allowance presentation", () => { it("uses the provider scope and duration without deriving missing values", () => { expect(formatAllowanceWindowScope("primary")).toBe("Primary limit"); expect(formatAllowanceWindowScope("secondary")).toBe("Secondary limit"); + expect(formatAllowanceWindowScope("five_hour")).toBe("5-hour limit"); + expect(formatAllowanceWindowScope("seven_day_opus")).toBe("7-day Opus limit"); + expect(formatAllowanceWindowScope("provider_native_window")).toBe("provider_native_window"); expect(formatAllowanceDuration(300)).toBe("5 hours"); expect(formatAllowanceDuration(null)).toBeNull(); expect(formatAllowanceDuration(undefined)).toBeNull(); diff --git a/apps/web/src/components/usage/usageAllowance.ts b/apps/web/src/components/usage/usageAllowance.ts index 65fbc29b431f..2573d301fefb 100644 --- a/apps/web/src/components/usage/usageAllowance.ts +++ b/apps/web/src/components/usage/usageAllowance.ts @@ -1,7 +1,24 @@ import type { SubscriptionAllowanceWindowScope } from "@t3tools/contracts"; export function formatAllowanceWindowScope(scope: SubscriptionAllowanceWindowScope): string { - return scope === "primary" ? "Primary limit" : "Secondary limit"; + switch (scope) { + case "primary": + return "Primary limit"; + case "secondary": + return "Secondary limit"; + case "five_hour": + return "5-hour limit"; + case "seven_day": + return "7-day limit"; + case "seven_day_oauth_apps": + return "7-day OAuth apps limit"; + case "seven_day_opus": + return "7-day Opus limit"; + case "seven_day_sonnet": + return "7-day Sonnet limit"; + default: + return scope; + } } export function formatAllowanceDuration(minutes: number | null | undefined): string | null { diff --git a/packages/contracts/src/usage.test.ts b/packages/contracts/src/usage.test.ts index 92e46b04f24c..9db3ffb042ef 100644 --- a/packages/contracts/src/usage.test.ts +++ b/packages/contracts/src/usage.test.ts @@ -3,9 +3,11 @@ import { describe, expect, it } from "@effect/vitest"; import { SubscriptionAllowanceSnapshot, USAGE_CONTRACT_VERSION } from "./usage.ts"; +const decodeSnapshot = Schema.decodeUnknownSync(SubscriptionAllowanceSnapshot); + describe("subscription allowance contract", () => { it("accepts provider-reported fields without requiring inferred values", () => { - const snapshot = Schema.decodeUnknownSync(SubscriptionAllowanceSnapshot)({ + const snapshot = decodeSnapshot({ readAt: "2026-08-11T12:00:00.000Z", allowances: [ { @@ -21,11 +23,32 @@ describe("subscription allowance contract", () => { }, ], }, + { + provider: "claude", + instanceId: "claude", + status: "available", + windows: [ + { + scope: "seven_day_opus", + usedPercent: null, + resetsAt: null, + }, + ], + extraUsage: { + isEnabled: true, + monthlyLimit: null, + usedCredits: 2.5, + utilization: null, + currency: "USD", + }, + }, ], }); expect(snapshot.allowances[0]).not.toHaveProperty("credits"); expect(snapshot.allowances[0]).not.toHaveProperty("spendingControl"); + expect(snapshot.allowances[1]?.windows[0]?.scope).toBe("seven_day_opus"); + expect(snapshot.allowances[1]?.extraUsage?.usedCredits).toBe(2.5); }); it("keeps historical usage contract versioning independent", () => { diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 8f28e335e7a5..b14777ce554d 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -205,17 +205,33 @@ export type SubscriptionAllowanceProviderKind = typeof SubscriptionAllowanceProv export const SubscriptionAllowanceStatus = Schema.Literals(["available", "unavailable"]); export type SubscriptionAllowanceStatus = typeof SubscriptionAllowanceStatus.Type; -export const SubscriptionAllowanceWindowScope = Schema.Literals(["primary", "secondary"]); +/** + * Provider-native allowance bucket identifiers. Codex currently uses the + * generic primary/secondary names; Claude supplies names such as + * `five_hour` and `seven_day_opus`. Keeping the identifier opaque prevents + * the shared contract from collapsing provider-specific windows. + */ +export const SubscriptionAllowanceWindowScope = TrimmedNonEmptyString; export type SubscriptionAllowanceWindowScope = typeof SubscriptionAllowanceWindowScope.Type; export const SubscriptionAllowanceWindow = Schema.Struct({ scope: SubscriptionAllowanceWindowScope, - usedPercent: Schema.Number, + usedPercent: Schema.optionalKey(Schema.Union([Schema.Number, Schema.Null])), windowDurationMins: Schema.optionalKey(Schema.Union([NonNegativeInt, Schema.Null])), resetsAt: Schema.optionalKey(Schema.Union([IsoDateTime, Schema.Null])), }); export type SubscriptionAllowanceWindow = typeof SubscriptionAllowanceWindow.Type; +/** Claude's provider-native extra-usage spending fields, when supplied. */ +export const SubscriptionAllowanceExtraUsage = Schema.Struct({ + isEnabled: Schema.Boolean, + monthlyLimit: Schema.Union([Schema.Number, Schema.Null]), + usedCredits: Schema.Union([Schema.Number, Schema.Null]), + utilization: Schema.Union([Schema.Number, Schema.Null]), + currency: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}); +export type SubscriptionAllowanceExtraUsage = typeof SubscriptionAllowanceExtraUsage.Type; + export const SubscriptionAllowanceCredits = Schema.Struct({ balance: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), hasCredits: Schema.Boolean, @@ -241,6 +257,7 @@ export const SubscriptionAllowance = Schema.Struct({ spendingControl: Schema.optionalKey( Schema.Union([SubscriptionAllowanceSpendingControl, Schema.Null]), ), + extraUsage: Schema.optionalKey(Schema.Union([SubscriptionAllowanceExtraUsage, Schema.Null])), message: Schema.optionalKey(TrimmedNonEmptyString), }); export type SubscriptionAllowance = typeof SubscriptionAllowance.Type; From ec8f2eddb008d6f08fd4eb6d1acf10276af922ef Mon Sep 17 00:00:00 2001 From: naveed949 Date: Tue, 11 Aug 2026 14:32:08 +0500 Subject: [PATCH 03/29] feat(usage): keep subscription allowance fresh --- apps/server/src/auth/RpcAuthorization.ts | 2 + .../Layers/ClaudeAllowanceReader.test.ts | 46 +- .../provider/Layers/ClaudeAllowanceReader.ts | 52 ++ .../Layers/CodexAllowanceReader.test.ts | 58 ++- .../provider/Layers/CodexAllowanceReader.ts | 80 ++++ .../src/provider/Layers/ProviderService.ts | 17 +- .../Services/ProviderAllowanceReader.ts | 11 +- .../src/provider/Services/ProviderService.ts | 7 + apps/server/src/server.test.ts | 67 +++ .../SubscriptionAllowanceService.test.ts | 168 +++++++ .../src/usage/SubscriptionAllowanceService.ts | 443 +++++++++++++++++- apps/server/src/ws.ts | 18 + apps/web/src/components/usage/UsagePage.tsx | 20 +- .../src/components/usage/usageAllowance.ts | 10 + apps/web/src/state/subscriptionAllowance.ts | 23 +- packages/client-runtime/src/rpc/client.ts | 1 + packages/client-runtime/src/state/server.ts | 14 +- packages/contracts/src/rpc.ts | 23 + packages/contracts/src/usage.ts | 6 + 19 files changed, 1035 insertions(+), 31 deletions(-) diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index cc1b095a52e2..17ded264be42 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -47,6 +47,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, [WS_METHODS.serverGetSubscriptionAllowance]: AuthOrchestrationReadScope, + [WS_METHODS.serverRefreshSubscriptionAllowance]: AuthOrchestrationReadScope, [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope, @@ -66,6 +67,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, + [WS_METHODS.subscribeSubscriptionAllowance]: AuthOrchestrationReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, [WS_METHODS.vcsPull]: AuthOrchestrationOperateScope, [WS_METHODS.gitRunStackedAction]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/provider/Layers/ClaudeAllowanceReader.test.ts b/apps/server/src/provider/Layers/ClaudeAllowanceReader.test.ts index 16fd85f948ae..beeed8683a42 100644 --- a/apps/server/src/provider/Layers/ClaudeAllowanceReader.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAllowanceReader.test.ts @@ -6,11 +6,18 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { ProviderInstanceId } from "@t3tools/contracts"; +import { + EventId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; import { CLAUDE_SUBSCRIPTION_ALLOWANCE_UNAVAILABLE_MESSAGE, makeClaudeAllowanceReader, + mapClaudeRateLimitEvent, mapClaudeUsage, } from "./ClaudeAllowanceReader.ts"; @@ -103,6 +110,43 @@ describe("mapClaudeUsage", () => { }); }); +describe("mapClaudeRateLimitEvent", () => { + it("maps the Claude SDK sparse rate-limit event", () => { + const event = { + type: "account.rate-limits.updated", + eventId: EventId.make("claude-rate-limit-event"), + provider: ProviderDriverKind.make("claude"), + providerInstanceId: instanceId, + threadId: ThreadId.make("thread-rate-limit"), + createdAt: "2026-08-11T12:00:00.000Z", + payload: { + rateLimits: { + type: "rate_limit_event", + rate_limit_info: { + status: "allowed", + rateLimitType: "five_hour", + utilization: 34, + resetsAt: Date.parse("2026-08-11T17:00:00.000Z"), + }, + }, + }, + } satisfies Extract; + + expect(mapClaudeRateLimitEvent({ instanceId, event })).toEqual({ + provider: "claude", + instanceId, + status: "available", + windows: [ + { + scope: "five_hour", + usedPercent: 34, + resetsAt: "2026-08-11T17:00:00.000Z", + }, + ], + }); + }); +}); + describe("makeClaudeAllowanceReader", () => { it.layer(NodeServices.layer)("Claude allowance reader", (it) => { it.effect("initializes before usage, never yields a user message, and closes the query", () => diff --git a/apps/server/src/provider/Layers/ClaudeAllowanceReader.ts b/apps/server/src/provider/Layers/ClaudeAllowanceReader.ts index 377ab78ffc13..423ac9171a4f 100644 --- a/apps/server/src/provider/Layers/ClaudeAllowanceReader.ts +++ b/apps/server/src/provider/Layers/ClaudeAllowanceReader.ts @@ -7,10 +7,12 @@ import { import type { ClaudeSettings, ProviderInstanceId, + ProviderRuntimeEvent, SubscriptionAllowance, SubscriptionAllowanceExtraUsage, SubscriptionAllowanceWindow, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -94,6 +96,55 @@ const mapExtraUsage = ( }; }; +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null; + +const mapEpochSecondsOrMillis = (value: unknown): string | undefined => { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + const epochMillis = value > 10_000_000_000 ? value : value * 1_000; + return DateTime.formatIso(DateTime.makeUnsafe(epochMillis)); +}; + +/** Maps Claude's sparse `rate_limit_event` into the common sparse allowance shape. */ +export function mapClaudeRateLimitEvent(input: { + readonly instanceId: ProviderInstanceId; + readonly event: ProviderRuntimeEvent; +}): SubscriptionAllowance | undefined { + const { event } = input; + if (event.provider !== "claude" || event.type !== "account.rate-limits.updated") { + return undefined; + } + const payload = event.payload.rateLimits; + if (!isRecord(payload)) return undefined; + const info = isRecord(payload.rate_limit_info) ? payload.rate_limit_info : payload; + const rateLimitType = info.rateLimitType; + const scope = + rateLimitType === "five_hour" || + rateLimitType === "seven_day" || + rateLimitType === "seven_day_opus" || + rateLimitType === "seven_day_sonnet" || + rateLimitType === "overage" + ? rateLimitType + : undefined; + if (scope === undefined) return undefined; + + const usedPercent = typeof info.utilization === "number" ? info.utilization : undefined; + const resetValue = scope === "overage" ? (info.overageResetsAt ?? info.resetsAt) : info.resetsAt; + const resetsAt = mapEpochSecondsOrMillis(resetValue); + return { + provider: "claude", + instanceId: input.instanceId, + status: "available", + windows: [ + { + scope, + ...(usedPercent === undefined ? {} : { usedPercent }), + ...(resetsAt === undefined ? {} : { resetsAt }), + }, + ], + } satisfies SubscriptionAllowance; +} + export function mapClaudeUsage(input: { readonly instanceId: ProviderInstanceId; readonly response: SDKControlGetUsageResponse; @@ -227,6 +278,7 @@ export const makeClaudeAllowanceReader = Effect.fn("makeClaudeAllowanceReader")( return { provider: "claude", read, + update: (event) => mapClaudeRateLimitEvent({ instanceId: input.instanceId, event }), } satisfies ProviderAllowanceReader; }); diff --git a/apps/server/src/provider/Layers/CodexAllowanceReader.test.ts b/apps/server/src/provider/Layers/CodexAllowanceReader.test.ts index 2f0b6e84fadf..dfaf77db91f2 100644 --- a/apps/server/src/provider/Layers/CodexAllowanceReader.test.ts +++ b/apps/server/src/provider/Layers/CodexAllowanceReader.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it } from "@effect/vitest"; import * as CodexSchema from "effect-codex-app-server/schema"; -import { ProviderInstanceId } from "@t3tools/contracts"; -import { mapCodexRateLimits } from "./CodexAllowanceReader.ts"; +import { + EventId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; +import { mapCodexRateLimits, mapCodexRateLimitsUpdate } from "./CodexAllowanceReader.ts"; const instanceId = ProviderInstanceId.make("codex"); @@ -99,4 +105,52 @@ describe("mapCodexRateLimits", () => { message: "Codex did not provide subscription usage limits.", }); }); + + it("maps sparse native rate-limit events for server-side folding", () => { + const event = { + type: "account.rate-limits.updated", + eventId: EventId.make("codex-rate-limit-event"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + threadId: ThreadId.make("thread-rate-limit"), + createdAt: "2026-08-11T12:00:00.000Z", + payload: { + rateLimits: { + primary: { usedPercent: 37 }, + }, + }, + } satisfies Extract; + + expect(mapCodexRateLimitsUpdate({ instanceId, event })).toEqual({ + provider: "codex", + instanceId, + status: "available", + windows: [{ scope: "primary", usedPercent: 37 }], + }); + }); + + it("does not treat unavailable nullable rolling fields as clears", () => { + const event = { + type: "account.rate-limits.updated", + eventId: EventId.make("codex-nullable-rate-limit-event"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + threadId: ThreadId.make("thread-nullable-rate-limit"), + createdAt: "2026-08-11T12:00:00.000Z", + payload: { + rateLimits: { + primary: { usedPercent: 37, windowDurationMins: null, resetsAt: null }, + credits: { balance: null, hasCredits: true, unlimited: false }, + }, + }, + } satisfies Extract; + + expect(mapCodexRateLimitsUpdate({ instanceId, event })).toEqual({ + provider: "codex", + instanceId, + status: "available", + windows: [{ scope: "primary", usedPercent: 37 }], + credits: { hasCredits: true, unlimited: false }, + }); + }); }); diff --git a/apps/server/src/provider/Layers/CodexAllowanceReader.ts b/apps/server/src/provider/Layers/CodexAllowanceReader.ts index 46b320a6fed8..709dd4559bc4 100644 --- a/apps/server/src/provider/Layers/CodexAllowanceReader.ts +++ b/apps/server/src/provider/Layers/CodexAllowanceReader.ts @@ -5,9 +5,11 @@ import * as CodexSchema from "effect-codex-app-server/schema"; import { type ProviderInstanceId, + type ProviderRuntimeEvent, type SubscriptionAllowance, type SubscriptionAllowanceWindow, } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import type { ProviderAllowanceReader } from "../Services/ProviderAllowanceReader.ts"; import { ProviderAllowanceReadError } from "../Services/ProviderAllowanceReader.ts"; @@ -34,6 +36,24 @@ const mapWindow = ( }; }; +const mapSparseWindow = ( + scope: SubscriptionAllowanceWindow["scope"], + window: CodexSchema.V2AccountRateLimitsUpdatedNotification__RateLimitWindow | null | undefined, +): SubscriptionAllowanceWindow | undefined => { + if (window === null || window === undefined) return undefined; + + return { + scope, + usedPercent: window.usedPercent, + ...(window.windowDurationMins === undefined || window.windowDurationMins === null + ? {} + : { windowDurationMins: window.windowDurationMins }), + ...(window.resetsAt === undefined || window.resetsAt === null + ? {} + : { resetsAt: mapNativeEpochSeconds(window.resetsAt) }), + }; +}; + export function mapCodexRateLimits(input: { readonly instanceId: ProviderInstanceId; readonly response: CodexSchema.V2GetAccountRateLimitsResponse; @@ -89,6 +109,65 @@ export function mapCodexRateLimits(input: { } satisfies SubscriptionAllowance; } +const isCodexRateLimitSnapshot = Schema.is( + CodexSchema.V2GetAccountRateLimitsResponse__RateLimitSnapshot, +); + +export function mapCodexRateLimitsUpdate(input: { + readonly instanceId: ProviderInstanceId; + readonly event: ProviderRuntimeEvent; +}): SubscriptionAllowance | undefined { + const { event } = input; + if (event.provider !== "codex" || event.type !== "account.rate-limits.updated") { + return undefined; + } + const rateLimits = event.payload.rateLimits; + if (!isCodexRateLimitSnapshot(rateLimits)) return undefined; + + const windows = [ + mapSparseWindow("primary", rateLimits.primary), + mapSparseWindow("secondary", rateLimits.secondary), + ].filter((window): window is SubscriptionAllowanceWindow => window !== undefined); + const credits = + rateLimits.credits === null || rateLimits.credits === undefined + ? undefined + : { + ...(rateLimits.credits.balance === undefined || rateLimits.credits.balance === null + ? {} + : { balance: rateLimits.credits.balance }), + hasCredits: rateLimits.credits.hasCredits, + unlimited: rateLimits.credits.unlimited, + }; + const hasReachedState = + rateLimits.spendControlReached !== undefined && rateLimits.spendControlReached !== null; + const individualLimit = rateLimits.individualLimit; + const spendingControl = + individualLimit !== undefined && individualLimit !== null + ? { + ...(hasReachedState ? { reached: rateLimits.spendControlReached } : {}), + limit: individualLimit.limit, + remainingPercent: individualLimit.remainingPercent, + resetsAt: DateTime.formatIso(DateTime.makeUnsafe(individualLimit.resetsAt * 1_000)), + used: individualLimit.used, + } + : hasReachedState + ? { reached: rateLimits.spendControlReached } + : undefined; + + if (windows.length === 0 && credits === undefined && spendingControl === undefined) { + return undefined; + } + + return { + provider: "codex", + instanceId: input.instanceId, + status: "available", + windows, + ...(credits === undefined ? {} : { credits }), + ...(spendingControl === undefined ? {} : { spendingControl }), + } satisfies SubscriptionAllowance; +} + export interface CodexAllowanceReaderInput extends CodexAppServerClientInput { readonly instanceId: ProviderInstanceId; readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; @@ -119,5 +198,6 @@ export function makeCodexAllowanceReader( return { provider: "codex", read, + update: (event) => mapCodexRateLimitsUpdate({ instanceId: input.instanceId, event }), }; } diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2ac00873df99..9e8fc0becbd6 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -230,7 +230,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( Effect.tap(() => Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId))), ); - const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => + type CanonicalProviderRuntimeEvent = ProviderRuntimeEvent & { + readonly [ProviderService.PROVIDER_RUNTIME_EVENT_SOURCE]?: unknown; + }; + + const publishRuntimeEvent = (event: CanonicalProviderRuntimeEvent): Effect.Effect => Effect.succeed(event).pipe( Effect.tap((canonicalEvent) => canonicalEventLogger @@ -288,6 +292,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( source: { readonly instanceId: ProviderInstanceId; readonly provider: ProviderDriverKind; + readonly adapter: ProviderAdapterShape; }, event: ProviderRuntimeEvent, ): Effect.Effect => @@ -296,7 +301,14 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( increment(providerRuntimeEventsTotal, { provider: canonicalEvent.provider, eventType: canonicalEvent.type, - }).pipe(Effect.andThen(publishRuntimeEvent(canonicalEvent))), + }).pipe( + Effect.andThen( + publishRuntimeEvent({ + ...canonicalEvent, + [ProviderService.PROVIDER_RUNTIME_EVENT_SOURCE]: source.adapter, + }), + ), + ), ), ); @@ -339,6 +351,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( { instanceId: id, provider: adapter.provider, + adapter, }, event, ), diff --git a/apps/server/src/provider/Services/ProviderAllowanceReader.ts b/apps/server/src/provider/Services/ProviderAllowanceReader.ts index f1ab8269ed23..8162c33326f5 100644 --- a/apps/server/src/provider/Services/ProviderAllowanceReader.ts +++ b/apps/server/src/provider/Services/ProviderAllowanceReader.ts @@ -1,4 +1,8 @@ -import type { SubscriptionAllowance, SubscriptionAllowanceProviderKind } from "@t3tools/contracts"; +import type { + ProviderRuntimeEvent, + SubscriptionAllowance, + SubscriptionAllowanceProviderKind, +} from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -21,4 +25,9 @@ export class ProviderAllowanceReadError extends Schema.TaggedErrorClass; + /** + * Maps a provider-native runtime update into a sparse allowance observation. + * The lifecycle owns folding it into the last complete record. + */ + readonly update?: (event: ProviderRuntimeEvent) => SubscriptionAllowance | undefined; } diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 4d4cb4fa01a7..5deaafea50a1 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -32,6 +32,13 @@ import type { ProviderServiceError } from "../Errors.ts"; import type { ProviderAdapterCapabilities } from "./ProviderAdapter.ts"; import type { ProviderInstanceRoutingInfo } from "./ProviderAdapterRegistry.ts"; +/** + * Internal-only source marker for canonical runtime events. It is a symbol so + * it never enters the wire shape or persisted JSON, while consumers that need + * generation safety can reject events from a replaced adapter instance. + */ +export const PROVIDER_RUNTIME_EVENT_SOURCE = Symbol("t3/provider-runtime-event-source"); + /** * ProviderServiceShape - Service API for provider session and turn orchestration. */ diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 547c1b50a219..04f77efaeac6 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4085,6 +4085,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes authenticated websocket rpc server.refreshSubscriptionAllowance", () => + Effect.gen(function* () { + const snapshot = { + readAt: "2026-08-11T12:00:00.000Z", + allowances: [ + { + provider: "codex" as const, + instanceId: ProviderInstanceId.make("codex"), + status: "available" as const, + freshness: "fresh" as const, + updatedAt: "2026-08-11T12:00:00.000Z", + windows: [{ scope: "primary" as const, usedPercent: 24 }], + }, + ], + }; + yield* buildAppUnderTest({ + layers: { + subscriptionAllowance: { refresh: Effect.succeed(snapshot) }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverRefreshSubscriptionAllowance]({}), + ), + ); + + assert.deepEqual(response, snapshot); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("does not block server config when editor discovery never resolves", () => Effect.gen(function* () { const discoveryInterrupted = yield* Deferred.make(); @@ -4674,6 +4706,41 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket subscription allowance snapshots through the subscription", () => + Effect.gen(function* () { + const snapshot = { + readAt: "2026-08-11T12:00:00.000Z", + allowances: [ + { + provider: "codex" as const, + instanceId: ProviderInstanceId.make("codex"), + status: "available" as const, + freshness: "fresh" as const, + updatedAt: "2026-08-11T12:00:00.000Z", + windows: [{ scope: "primary" as const, usedPercent: 24 }], + }, + ], + }; + yield* buildAppUnderTest({ + layers: { + subscriptionAllowance: { + subscribe: Effect.succeed({ latest: snapshot, changes: Stream.empty }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeSubscriptionAllowance]({}).pipe(Stream.runHead), + ), + ); + + assertTrue(Option.isSome(response)); + assert.deepEqual(response.value, snapshot); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => Effect.gen(function* () { const nextProviders = [ diff --git a/apps/server/src/usage/SubscriptionAllowanceService.test.ts b/apps/server/src/usage/SubscriptionAllowanceService.test.ts index 53d1daf7ca33..96daea5b8667 100644 --- a/apps/server/src/usage/SubscriptionAllowanceService.test.ts +++ b/apps/server/src/usage/SubscriptionAllowanceService.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ProviderInstanceId } from "@t3tools/contracts"; @@ -8,7 +15,12 @@ import { ProviderAllowanceReadError, type ProviderAllowanceReader, } from "../provider/Services/ProviderAllowanceReader.ts"; +import { ProviderInstanceRegistry } from "../provider/Services/ProviderInstanceRegistry.ts"; +import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import { + foldSubscriptionAllowance, + make, + markSubscriptionAllowanceSnapshotStale, readSubscriptionAllowances, type SubscriptionAllowanceProviderInstance, } from "./SubscriptionAllowanceService.ts"; @@ -39,6 +51,19 @@ const instance = ( ...input, }); +const registryLayerFor = (providerInstance: SubscriptionAllowanceProviderInstance) => + Layer.succeed(ProviderInstanceRegistry, { + getInstance: () => Effect.succeed(undefined), + listInstances: Effect.succeed([ + providerInstance as unknown as ProviderInstance, + ] as ReadonlyArray), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }); + describe("readSubscriptionAllowances", () => { it.effect("omits disabled instances and contains an unavailable receipt for a failed read", () => Effect.gen(function* () { @@ -99,3 +124,146 @@ describe("readSubscriptionAllowances", () => { }), ); }); + +describe("subscription allowance lifecycle helpers", () => { + it("folds sparse windows without dropping a previously complete record", () => { + const previous = { + ...allowance, + windows: [ + { scope: "primary" as const, usedPercent: 20, windowDurationMins: 300 }, + { scope: "secondary" as const, usedPercent: 5, windowDurationMins: 1_440 }, + ], + credits: { balance: "10", hasCredits: true, unlimited: false }, + }; + const update = { + provider: "codex" as const, + instanceId, + status: "available" as const, + windows: [{ scope: "primary" as const, usedPercent: 31 }], + credits: null, + }; + + expect(foldSubscriptionAllowance(previous, update)).toEqual({ + provider: "codex", + instanceId, + status: "available", + windows: [ + { scope: "primary", usedPercent: 31, windowDurationMins: 300 }, + { scope: "secondary", usedPercent: 5, windowDurationMins: 1_440 }, + ], + credits: null, + }); + }); + + it("marks a usable record stale after its provider reset without changing utilization", () => { + const snapshot = { + readAt, + allowances: [ + { + ...allowance, + windows: [{ scope: "primary" as const, usedPercent: 42, resetsAt: readAt }], + freshness: "fresh" as const, + updatedAt: readAt, + }, + ], + }; + + expect(markSubscriptionAllowanceSnapshotStale(snapshot, Date.parse(readAt))).toEqual({ + ...snapshot, + allowances: [{ ...snapshot.allowances[0], freshness: "stale" }], + }); + }); + + it("keeps a replacement observation fresh after the previous reset", () => { + const snapshot = { + readAt: "2026-08-11T18:00:00.000Z", + allowances: [ + { + ...allowance, + windows: [{ scope: "primary" as const, usedPercent: 7, resetsAt: readAt }], + freshness: "fresh" as const, + updatedAt: "2026-08-11T18:00:00.000Z", + }, + ], + }; + + expect(markSubscriptionAllowanceSnapshotStale(snapshot, Date.parse(snapshot.readAt))).toBe( + snapshot, + ); + }); +}); + +describe("SubscriptionAllowanceService", () => { + it.effect("starts one demand-scoped acquisition and shares concurrent refreshes", () => + Effect.gen(function* () { + const readStarted = yield* Deferred.make(); + const releaseRead = yield* Deferred.make(); + let readCount = 0; + const providerInstance = instance({ + allowanceReader: reader( + Effect.gen(function* () { + readCount += 1; + yield* Deferred.succeed(readStarted, undefined); + yield* Deferred.await(releaseRead); + return allowance; + }), + ), + }); + const service = yield* make.pipe(Effect.provide(registryLayerFor(providerInstance))); + + yield* Effect.scoped( + Effect.gen(function* () { + yield* service.subscribe; + yield* Deferred.await(readStarted); + expect(readCount).toBe(1); + + const concurrentRefreshes = yield* Effect.all([service.refresh, service.refresh], { + concurrency: "unbounded", + }).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.succeed(releaseRead, undefined); + const [snapshot] = yield* Fiber.join(concurrentRefreshes); + + expect(readCount).toBe(1); + expect(snapshot.allowances[0]).toMatchObject(allowance); + expect(snapshot.allowances[0]?.freshness).toBe("fresh"); + }), + ); + }), + ); + + it.effect("refreshes on the controllable interval and stops after demand teardown", () => + Effect.gen(function* () { + const automaticRefresh = yield* Deferred.make(); + let readCount = 0; + let baseline = Number.POSITIVE_INFINITY; + const providerInstance = instance({ + allowanceReader: reader( + Effect.sync(() => { + readCount += 1; + return allowance; + }).pipe( + Effect.tap(() => + readCount > baseline ? Deferred.succeed(automaticRefresh, undefined) : Effect.void, + ), + ), + ), + }); + const service = yield* make.pipe(Effect.provide(registryLayerFor(providerInstance))); + + yield* Effect.scoped( + Effect.gen(function* () { + yield* service.subscribe; + yield* service.refresh; + baseline = readCount; + yield* TestClock.adjust(Duration.minutes(5)); + yield* Deferred.await(automaticRefresh); + expect(readCount).toBe(baseline + 1); + }), + ); + + const afterTeardown = readCount; + yield* TestClock.adjust(Duration.minutes(5)); + expect(readCount).toBe(afterTeardown); + }).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/apps/server/src/usage/SubscriptionAllowanceService.ts b/apps/server/src/usage/SubscriptionAllowanceService.ts index a441ae156428..6e93ebc72759 100644 --- a/apps/server/src/usage/SubscriptionAllowanceService.ts +++ b/apps/server/src/usage/SubscriptionAllowanceService.ts @@ -1,18 +1,37 @@ import { type ProviderInstanceId, + type ProviderRuntimeEvent, type SubscriptionAllowance, type SubscriptionAllowanceSnapshot, } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; import { CLAUDE_SUBSCRIPTION_ALLOWANCE_UNAVAILABLE_MESSAGE, type ProviderAllowanceReader, } from "../provider/Services/ProviderAllowanceReader.ts"; +import { + PROVIDER_RUNTIME_EVENT_SOURCE, + ProviderService, +} from "../provider/Services/ProviderService.ts"; import { ProviderInstanceRegistry } from "../provider/Services/ProviderInstanceRegistry.ts"; +import type { ProviderInstance } from "../provider/ProviderDriver.ts"; +import { subscribeBeforeSnapshot } from "../utils/subscribeBeforeSnapshot.ts"; + +export const SUBSCRIPTION_ALLOWANCE_REFRESH_INTERVAL_MS = 5 * 60_000; export interface SubscriptionAllowanceProviderInstance { readonly instanceId: ProviderInstanceId; @@ -24,6 +43,15 @@ type ReadableSubscriptionAllowanceProviderInstance = SubscriptionAllowanceProvid readonly allowanceReader: ProviderAllowanceReader; }; +interface AllowanceServiceState { + readonly snapshot: SubscriptionAllowanceSnapshot; + readonly instances: ReadonlyMap; + readonly demandCount: number; + readonly liveScope: Option.Option; +} + +type RefreshFlight = Deferred.Deferred>; + const unavailableAllowance = (input: { readonly instanceId: ProviderInstanceId; readonly provider: SubscriptionAllowance["provider"]; @@ -36,6 +64,81 @@ const unavailableAllowance = (input: { message: input.message, }); +const unavailableMessage = (provider: ProviderAllowanceReader["provider"]): string => + provider === "claude" + ? CLAUDE_SUBSCRIPTION_ALLOWANCE_UNAVAILABLE_MESSAGE + : "Codex subscription usage is unavailable."; + +const isUsableAllowance = (allowance: SubscriptionAllowance | undefined): boolean => + allowance?.status === "available"; + +const markFresh = (allowance: SubscriptionAllowance, updatedAt: string): SubscriptionAllowance => ({ + ...allowance, + freshness: "fresh", + updatedAt, +}); + +const markStale = (allowance: SubscriptionAllowance): SubscriptionAllowance => ({ + ...allowance, + freshness: "stale", +}); + +const hasPassedReset = ( + allowance: SubscriptionAllowance, + snapshotReadAt: string, + nowMs: number, +): boolean => + allowance.status === "available" && + allowance.windows.some((window) => { + if (window.resetsAt === undefined || window.resetsAt === null) return false; + const resetMs = Date.parse(window.resetsAt); + if (!Number.isFinite(resetMs) || resetMs > nowMs) return false; + + const observedMs = Date.parse(allowance.updatedAt ?? snapshotReadAt); + return !Number.isFinite(observedMs) || observedMs <= resetMs; + }); + +export function markSubscriptionAllowanceSnapshotStale( + snapshot: SubscriptionAllowanceSnapshot, + nowMs: number, +): SubscriptionAllowanceSnapshot { + const allowances = snapshot.allowances.map((allowance) => + hasPassedReset(allowance, snapshot.readAt, nowMs) ? markStale(allowance) : allowance, + ); + return allowances.every((allowance, index) => allowance === snapshot.allowances[index]) + ? snapshot + : { ...snapshot, allowances }; +} + +/** + * Fold one provider-native sparse observation into the last complete record. + * Provider fields omitted from the sparse update remain untouched; explicit + * nulls still clear the previous provider value. + */ +export function foldSubscriptionAllowance( + previous: SubscriptionAllowance | undefined, + update: SubscriptionAllowance, +): SubscriptionAllowance { + if (!isUsableAllowance(update) || previous === undefined || !isUsableAllowance(previous)) { + return update; + } + + const windowsByScope = new Map(previous.windows.map((window) => [window.scope, window] as const)); + for (const window of update.windows) { + windowsByScope.set(window.scope, { + ...windowsByScope.get(window.scope), + ...window, + }); + } + const windows = Array.from(windowsByScope.values()); + + return { + ...previous, + ...update, + windows, + }; +} + /** * Reads the enabled materialized provider readers in settings order. A broken * provider read becomes an explicit unavailable allowance so one provider @@ -63,10 +166,7 @@ export const readSubscriptionAllowances = Effect.fn("readSubscriptionAllowances" unavailableAllowance({ provider: reader.provider, instanceId: instance.instanceId, - message: - reader.provider === "claude" - ? CLAUDE_SUBSCRIPTION_ALLOWANCE_UNAVAILABLE_MESSAGE - : "Codex subscription usage is unavailable.", + message: unavailableMessage(reader.provider), }), ), ), @@ -85,30 +185,351 @@ export const readSubscriptionAllowances = Effect.fn("readSubscriptionAllowances" export class SubscriptionAllowanceService extends Context.Service< SubscriptionAllowanceService, { + /** One-shot compatibility read. It also updates the server-owned cache. */ readonly read: Effect.Effect; + readonly latest: Effect.Effect; + readonly changes: Stream.Stream; + readonly subscribe: Effect.Effect< + { + readonly latest: SubscriptionAllowanceSnapshot; + readonly changes: Stream.Stream; + }, + never, + Scope.Scope + >; + /** Manual refresh bypasses freshness but shares any active acquisition. */ + readonly refresh: Effect.Effect; } >()("t3/usage/SubscriptionAllowanceService") {} export const make = Effect.gen(function* () { const registry = yield* ProviderInstanceRegistry; + const providerService = yield* Effect.serviceOption(ProviderService); + const stateMutex = yield* Semaphore.make(1); + const liveMutex = yield* Semaphore.make(1); + const changes = yield* PubSub.sliding(8); + const initialReadAt = DateTime.formatIso(yield* DateTime.now); + const initialInstances = yield* registry.listInstances; + const state = yield* Ref.make({ + snapshot: { readAt: initialReadAt, allowances: [] }, + instances: new Map( + initialInstances.map((instance) => [instance.instanceId, instance] as const), + ), + demandCount: 0, + liveScope: Option.none(), + }); + const refreshFlight = yield* Ref.make>(Option.none()); + + const publishUnlocked = (snapshot: SubscriptionAllowanceSnapshot) => + Effect.gen(function* () { + const current = yield* Ref.get(state); + const nextSnapshot = markSubscriptionAllowanceSnapshotStale( + snapshot, + yield* Clock.currentTimeMillis, + ); + yield* Ref.set(state, { ...current, snapshot: nextSnapshot }); + if (current.demandCount > 0) { + yield* PubSub.publish(changes, nextSnapshot); + } + return nextSnapshot; + }); + const publish = (snapshot: SubscriptionAllowanceSnapshot) => + stateMutex.withPermits(1)(publishUnlocked(snapshot)); - const read = Effect.gen(function* () { + const readCurrentSnapshot = Effect.fn("SubscriptionAllowanceService.readCurrentSnapshot")( + function* ( + instances: ReadonlyArray, + previous: SubscriptionAllowanceSnapshot, + readAt: string, + ) { + const previousByInstance = new Map( + previous.allowances.map((allowance) => [allowance.instanceId, allowance] as const), + ); + const readableInstances = instances.filter( + (instance): instance is ReadableSubscriptionAllowanceProviderInstance => + instance.enabled && instance.allowanceReader !== undefined, + ); + + const allowances = yield* Effect.forEach(readableInstances, (instance) => + instance.allowanceReader.read.pipe( + Effect.result, + Effect.map((result) => { + if (Result.isSuccess(result)) { + return markFresh(result.success, readAt); + } + + const previousAllowance = previousByInstance.get(instance.instanceId); + return previousAllowance !== undefined && isUsableAllowance(previousAllowance) + ? markStale(previousAllowance) + : markFresh( + unavailableAllowance({ + provider: instance.allowanceReader.provider, + instanceId: instance.instanceId, + message: unavailableMessage(instance.allowanceReader.provider), + }), + readAt, + ); + }), + Effect.tap((allowance) => + allowance.freshness === "stale" + ? Effect.logWarning("Provider allowance refresh failed; retaining stale snapshot", { + provider: allowance.provider, + instanceId: allowance.instanceId, + }) + : Effect.void, + ), + ), + ); + + return { readAt, allowances } satisfies SubscriptionAllowanceSnapshot; + }, + ); + + const refreshUnshared = Effect.gen(function* () { + const instances = yield* registry.listInstances; + const previous = yield* Ref.get(state); const readAt = DateTime.formatIso(yield* DateTime.now); + const candidate = yield* readCurrentSnapshot(instances, previous.snapshot, readAt); + const currentInstances = yield* registry.listInstances; + const capturedById = new Map( + instances.map((instance) => [instance.instanceId, instance] as const), + ); + const currentState = yield* Ref.get(state); + const allowancesById = new Map( + candidate.allowances.map((allowance) => [allowance.instanceId, allowance]), + ); + const acceptedAllowances = currentInstances.flatMap((instance) => { + const candidateInstance = capturedById.get(instance.instanceId); + if ( + candidateInstance === instance && + currentState.instances.get(instance.instanceId) === instance + ) { + const allowance = allowancesById.get(instance.instanceId); + return allowance === undefined ? [] : [allowance]; + } + + // A provider instance was replaced while the read was in flight. Do not + // let the old generation publish into the new one; its registry change + // will trigger a new demand-scoped refresh. + const previousInstance = currentState.instances.get(instance.instanceId); + if (previousInstance === instance) { + const allowance = currentState.snapshot.allowances.find( + (candidateAllowance) => candidateAllowance.instanceId === instance.instanceId, + ); + return allowance === undefined ? [] : [allowance]; + } + return []; + }); + return yield* publish({ readAt, allowances: acceptedAllowances }); + }); + + const refresh: Effect.Effect = Effect.uninterruptibleMask( + (restore) => + Effect.gen(function* () { + const flight = yield* Deferred.make>(); + const existing = yield* Ref.modify(refreshFlight, (current) => + Option.match(current, { + onNone: () => [Option.none(), Option.some(flight)] as const, + onSome: (active) => [Option.some(active), current] as const, + }), + ); + if (Option.isSome(existing)) { + return yield* restore( + existing.value.pipe( + Deferred.await, + Effect.flatMap((exit) => + Exit.match(exit, { + onFailure: (cause) => Effect.failCause(cause), + onSuccess: Effect.succeed, + }), + ), + ), + ); + } + + const result = yield* Effect.exit(restore(refreshUnshared)); + yield* Deferred.succeed(flight, result); + yield* Ref.set(refreshFlight, Option.none()); + return yield* Exit.match(result, { + onFailure: (cause) => Effect.failCause(cause), + onSuccess: Effect.succeed, + }); + }), + ); + + const handleProviderUpdate = (event: ProviderRuntimeEvent) => + stateMutex.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(state); + const instanceId = event.providerInstanceId; + if (instanceId === undefined) return; + const instance = current.instances.get(instanceId); + const eventSource = ( + event as ProviderRuntimeEvent & { + readonly [PROVIDER_RUNTIME_EVENT_SOURCE]?: unknown; + } + )[PROVIDER_RUNTIME_EVENT_SOURCE]; + if (eventSource !== undefined && eventSource !== instance?.adapter) return; + const update = instance?.allowanceReader?.update?.(event); + if (update === undefined) return; + const previous = current.snapshot.allowances.find( + (allowance) => allowance.instanceId === instanceId, + ); + if (previous === undefined && update.status === "available") { + // A sparse event cannot become the first public record. The next + // complete acquisition remains the source of truth for first demand. + return; + } + const updatedAt = event.createdAt; + const folded = markFresh(foldSubscriptionAllowance(previous, update), updatedAt); + const allowances = current.snapshot.allowances.some( + (allowance) => allowance.instanceId === instanceId, + ) + ? current.snapshot.allowances.map((allowance) => + allowance.instanceId === instanceId ? folded : allowance, + ) + : [...current.snapshot.allowances, folded]; + yield* publishUnlocked({ readAt: updatedAt, allowances }); + }), + ); + + const syncInstances = Effect.gen(function* () { const instances = yield* registry.listInstances; - return yield* readSubscriptionAllowances(instances, readAt); + const nextById = new Map(instances.map((instance) => [instance.instanceId, instance] as const)); + const refreshScope = yield* stateMutex.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(state); + const changed = Array.from(nextById).some( + ([instanceId, instance]) => current.instances.get(instanceId) !== instance, + ); + if (!changed && nextById.size === current.instances.size) + return Option.none(); + + const allowances = current.snapshot.allowances.filter( + (allowance) => + current.instances.get(allowance.instanceId) === nextById.get(allowance.instanceId), + ); + yield* Ref.set(state, { + ...current, + instances: nextById, + snapshot: { ...current.snapshot, allowances }, + }); + return current.demandCount > 0 ? current.liveScope : Option.none(); + }), + ); + if (Option.isSome(refreshScope)) { + yield* refresh.pipe(Effect.ignore, Effect.forkIn(refreshScope.value)); + } + }); + + const acquireDemand = liveMutex.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(state); + if (current.demandCount > 0) { + yield* Ref.set(state, { ...current, demandCount: current.demandCount + 1 }); + return; + } + + const liveScope = yield* Scope.make(); + yield* Ref.set(state, { + ...current, + demandCount: 1, + liveScope: Option.some(liveScope), + }); + + if (Option.isSome(providerService)) { + yield* providerService.value.streamEvents.pipe( + Stream.filter((event) => event.type === "account.rate-limits.updated"), + Stream.runForEach(handleProviderUpdate), + Effect.catchCause((cause) => + Effect.logWarning("Subscription allowance provider update stream stopped", { cause }), + ), + Effect.forkIn(liveScope), + ); + } + const registryChanges = yield* registry.subscribeChanges.pipe( + Effect.provideService(Scope.Scope, liveScope), + ); + yield* Stream.runForEach(Stream.fromSubscription(registryChanges), () => syncInstances).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Subscription allowance registry stream stopped", { cause }), + ), + Effect.forkIn(liveScope), + ); + yield* Effect.forever( + Effect.sleep(`${SUBSCRIPTION_ALLOWANCE_REFRESH_INTERVAL_MS} millis`).pipe( + Effect.andThen(refresh.pipe(Effect.ignore)), + ), + ).pipe(Effect.forkIn(liveScope)); + yield* refresh.pipe(Effect.ignore, Effect.forkIn(liveScope, { startImmediately: true })); + }), + ); + + const releaseDemand = liveMutex.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(state); + if (current.demandCount > 1) { + yield* Ref.set(state, { ...current, demandCount: current.demandCount - 1 }); + return; + } + yield* Ref.set(state, { ...current, demandCount: 0, liveScope: Option.none() }); + if (Option.isSome(current.liveScope)) { + yield* Scope.close(current.liveScope.value, Exit.void).pipe(Effect.ignore); + } + }), + ); + + const read = Effect.scoped( + Effect.gen(function* () { + yield* Effect.acquireRelease(acquireDemand, () => releaseDemand); + return yield* refresh; + }), + ); + + const latestUnlocked = Effect.gen(function* () { + const current = yield* Ref.get(state); + const nextSnapshot = markSubscriptionAllowanceSnapshotStale( + current.snapshot, + yield* Clock.currentTimeMillis, + ); + if (nextSnapshot !== current.snapshot) { + yield* Ref.set(state, { ...current, snapshot: nextSnapshot }); + } + return nextSnapshot; }); + const latest = stateMutex.withPermits(1)(latestUnlocked); + const subscribe = subscribeBeforeSnapshot( + changes, + Effect.gen(function* () { + yield* Effect.acquireRelease(acquireDemand, () => releaseDemand); + return yield* latestUnlocked; + }), + stateMutex, + ); - return SubscriptionAllowanceService.of({ read }); + return SubscriptionAllowanceService.of({ + read, + latest, + changes: Stream.fromPubSub(changes), + subscribe, + refresh, + }); }); export const layer = Layer.effect(SubscriptionAllowanceService, make); +const emptySnapshot: SubscriptionAllowanceSnapshot = { + readAt: "1970-01-01T00:00:00.000Z", + allowances: [], +}; + export const layerTest = Layer.succeed( SubscriptionAllowanceService, SubscriptionAllowanceService.of({ - read: Effect.succeed({ - readAt: "1970-01-01T00:00:00.000Z", - allowances: [], - }), + read: Effect.succeed(emptySnapshot), + latest: Effect.succeed(emptySnapshot), + changes: Stream.empty, + subscribe: Effect.succeed({ latest: emptySnapshot, changes: Stream.empty }), + refresh: Effect.succeed(emptySnapshot), }), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e9df445515dd..6fa480a49d06 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1572,6 +1572,14 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetSubscriptionAllowance, subscriptionAllowance.read, { "rpc.aggregate": "server", }), + [WS_METHODS.serverRefreshSubscriptionAllowance]: (_input) => + observeRpcEffect( + WS_METHODS.serverRefreshSubscriptionAllowance, + subscriptionAllowance.refresh, + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.serverRetryResourceTelemetry]: (_input) => observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, { "rpc.aggregate": "server", @@ -2198,6 +2206,16 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "server" }, ), + [WS_METHODS.subscribeSubscriptionAllowance]: (_input) => + observeRpcStream( + WS_METHODS.subscribeSubscriptionAllowance, + Stream.unwrap( + Effect.map(subscriptionAllowance.subscribe, ({ latest, changes }) => + Stream.concat(Stream.make(latest), changes), + ), + ), + { "rpc.aggregate": "server" }, + ), }); }), ); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 451b78fb719f..a733f9dfdcab 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -24,6 +24,7 @@ import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./U import { formatAllowanceDuration, formatAllowanceResetAt, + formatAllowanceUpdatedAt, formatAllowanceWindowScope, progressWidthForAllowance, } from "./usageAllowance"; @@ -453,7 +454,8 @@ function SubscriptionUsagePage({ }: { readonly onViewChange: (view: UsageView) => void; }) { - const { allowances, environments, isPending, isPartial, refresh } = useSubscriptionAllowance(); + const { allowances, environments, isPending, isPartial, isRefreshing, refresh } = + useSubscriptionAllowance(); const failedEnvironments = environments.filter((environment) => environment.error !== null); const settling = isPending || isPartial; @@ -472,7 +474,9 @@ function SubscriptionUsagePage({ type="button" onClick={refresh} aria-label="Refresh subscription usage" - className="cursor-pointer rounded-md border border-border p-2 text-muted-foreground hover:text-foreground" + aria-busy={isRefreshing} + disabled={isRefreshing} + className="cursor-pointer rounded-md border border-border p-2 text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50" > @@ -528,7 +532,17 @@ function SubscriptionAllowanceCard({ {PROVIDER_LABEL[allowance.provider]} - {environmentLabel} +
+ {environmentLabel} + + {allowance.freshness === "stale" + ? "Stale" + : (() => { + const updatedAt = formatAllowanceUpdatedAt(allowance.updatedAt); + return updatedAt === null ? "Updated time unavailable" : `Updated ${updatedAt}`; + })()} + +
{allowance.status === "unavailable" ? ( diff --git a/apps/web/src/components/usage/usageAllowance.ts b/apps/web/src/components/usage/usageAllowance.ts index 2573d301fefb..0801ff4f4f62 100644 --- a/apps/web/src/components/usage/usageAllowance.ts +++ b/apps/web/src/components/usage/usageAllowance.ts @@ -44,3 +44,13 @@ export function formatAllowanceResetAt(resetsAt: string): string { timeStyle: "short", }).format(new Date(resetsAt)); } + +export function formatAllowanceUpdatedAt(updatedAt: string | null | undefined): string | null { + if (updatedAt === null || updatedAt === undefined) return null; + const date = new Date(updatedAt); + if (Number.isNaN(date.getTime())) return null; + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} diff --git a/apps/web/src/state/subscriptionAllowance.ts b/apps/web/src/state/subscriptionAllowance.ts index 26d4821b7423..d9ada47d5bba 100644 --- a/apps/web/src/state/subscriptionAllowance.ts +++ b/apps/web/src/state/subscriptionAllowance.ts @@ -1,3 +1,4 @@ +import { useAtomCommand } from "../state/use-atom-command"; import { useAtomValue } from "@effect/atom-react"; import { type EnvironmentId, @@ -6,9 +7,8 @@ import { } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; -import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentPresentations } from "./presentation"; import { serverEnvironment } from "./server"; @@ -67,23 +67,29 @@ export interface SubscriptionAllowanceView { readonly environments: readonly EnvironmentSubscriptionAllowanceStatus[]; readonly isPending: boolean; readonly isPartial: boolean; + readonly isRefreshing: boolean; readonly refresh: () => void; } export function useSubscriptionAllowance(): SubscriptionAllowanceView { const environments = useAtomValue(subscriptionAllowanceByEnvironmentAtom); const allowances = useMemo(() => flattenSubscriptionAllowances(environments), [environments]); + const refreshAllowance = useAtomCommand(serverEnvironment.refreshSubscriptionAllowance, { + reportFailure: false, + }); + const [isRefreshing, setIsRefreshing] = useState(false); const refresh = useCallback(() => { - for (const environment of environments) { - appAtomRegistry.refresh( - serverEnvironment.subscriptionAllowance({ + setIsRefreshing(true); + void Promise.all( + environments.map((environment) => + refreshAllowance({ environmentId: environment.environmentId, input: {}, }), - ); - } - }, [environments]); + ), + ).finally(() => setIsRefreshing(false)); + }, [environments, refreshAllowance]); const answeredCount = environments.filter((environment) => environment.snapshot !== null).length; const stillReporting = environments.filter( @@ -95,6 +101,7 @@ export function useSubscriptionAllowance(): SubscriptionAllowanceView { environments, isPending: answeredCount === 0 && stillReporting > 0, isPartial: answeredCount > 0 && stillReporting > 0, + isRefreshing, refresh, }; } diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index bfe57a6c0dd5..a608debdecdc 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -50,6 +50,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribePreviewEvents | typeof WS_METHODS.subscribeDiscoveredLocalServers | typeof WS_METHODS.subscribeResourceTelemetry + | typeof WS_METHODS.subscribeSubscriptionAllowance | typeof WS_METHODS.previewAutomationConnect | typeof WS_METHODS.subscribeVcsStatus | typeof WS_METHODS.terminalAttach; diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 3ad60f7d9a33..6fcc27973792 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -714,10 +714,10 @@ export function createServerEnvironmentAtoms( tag: WS_METHODS.serverGetUsageSummary, staleTimeMs: 60_000, }), - subscriptionAllowance: createEnvironmentRpcQueryAtomFamily(runtime, { + subscriptionAllowance: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:subscription-allowance", - tag: WS_METHODS.serverGetSubscriptionAllowance, - staleTimeMs: 60_000, + tag: WS_METHODS.subscribeSubscriptionAllowance, + idleTtlMs: 0, }), configProjection, welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { @@ -736,6 +736,14 @@ export function createServerEnvironmentAtoms( key: ({ environmentId }) => environmentId, }, }), + refreshSubscriptionAllowance: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:refresh-subscription-allowance", + tag: WS_METHODS.serverRefreshSubscriptionAllowance, + concurrency: { + mode: "singleFlight", + key: ({ environmentId }) => environmentId, + }, + }), updateProvider: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:update-provider", tag: WS_METHODS.serverUpdateProvider, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 637a22b574f4..4a49c3ed81a5 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -252,6 +252,7 @@ export const WS_METHODS = { serverGetBackgroundPolicy: "server.getBackgroundPolicy", serverGetUsageSummary: "server.getUsageSummary", serverGetSubscriptionAllowance: "server.getSubscriptionAllowance", + serverRefreshSubscriptionAllowance: "server.refreshSubscriptionAllowance", // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", @@ -273,6 +274,7 @@ export const WS_METHODS = { subscribeAuthAccess: "subscribeAuthAccess", subscribeBackgroundPolicy: "subscribeBackgroundPolicy", subscribeResourceTelemetry: "subscribeResourceTelemetry", + subscribeSubscriptionAllowance: "subscribeSubscriptionAllowance", } as const; export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { @@ -404,6 +406,15 @@ export const WsServerGetSubscriptionAllowanceRpc = Rpc.make( }, ); +export const WsServerRefreshSubscriptionAllowanceRpc = Rpc.make( + WS_METHODS.serverRefreshSubscriptionAllowance, + { + payload: Schema.Struct({}), + success: SubscriptionAllowanceSnapshot, + error: EnvironmentAuthorizationError, + }, +); + export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { payload: ServerSignalProcessInput, success: ServerSignalProcessResult, @@ -825,6 +836,16 @@ export const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeReso stream: true, }); +export const WsSubscribeSubscriptionAllowanceRpc = Rpc.make( + WS_METHODS.subscribeSubscriptionAllowance, + { + payload: Schema.Struct({}), + success: SubscriptionAllowanceSnapshot, + error: EnvironmentAuthorizationError, + stream: true, + }, +); + export const WsRpcGroup = RpcGroup.make( WsServerProbeRpc, WsServerGetConfigRpc, @@ -844,6 +865,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerRetryResourceTelemetryRpc, WsServerGetUsageSummaryRpc, WsServerGetSubscriptionAllowanceRpc, + WsServerRefreshSubscriptionAllowanceRpc, WsServerSignalProcessRpc, WsServerReportClientActivityRpc, WsServerReportHostPowerStateRpc, @@ -901,6 +923,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeAuthAccessRpc, WsSubscribeBackgroundPolicyRpc, WsSubscribeResourceTelemetryRpc, + WsSubscribeSubscriptionAllowanceRpc, WsOrchestrationDispatchCommandRpc, WsOrchestrationGetWorkflowScriptRpc, WsOrchestrationGetTurnDiffRpc, diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index b14777ce554d..55946c976843 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -205,6 +205,10 @@ export type SubscriptionAllowanceProviderKind = typeof SubscriptionAllowanceProv export const SubscriptionAllowanceStatus = Schema.Literals(["available", "unavailable"]); export type SubscriptionAllowanceStatus = typeof SubscriptionAllowanceStatus.Type; +/** Whether the provider observation is current or only retained after a failed refresh. */ +export const SubscriptionAllowanceFreshness = Schema.Literals(["fresh", "stale"]); +export type SubscriptionAllowanceFreshness = typeof SubscriptionAllowanceFreshness.Type; + /** * Provider-native allowance bucket identifiers. Codex currently uses the * generic primary/secondary names; Claude supplies names such as @@ -252,6 +256,8 @@ export const SubscriptionAllowance = Schema.Struct({ provider: SubscriptionAllowanceProviderKind, instanceId: ProviderInstanceId, status: SubscriptionAllowanceStatus, + freshness: Schema.optional(SubscriptionAllowanceFreshness), + updatedAt: Schema.optional(IsoDateTime), windows: Schema.Array(SubscriptionAllowanceWindow), credits: Schema.optionalKey(Schema.Union([SubscriptionAllowanceCredits, Schema.Null])), spendingControl: Schema.optionalKey( From 3844f8ad4151d6add815ad67814ed4828d26a7b6 Mon Sep 17 00:00:00 2001 From: naveed949 Date: Tue, 11 Aug 2026 15:14:30 +0500 Subject: [PATCH 04/29] feat(usage): reconcile allowance across environments Group only exact provider identities, retain source provenance, and expose connection-aware allowance projections to the web view. Implemented by GPT-5 Codex. --- apps/web/src/components/usage/UsagePage.tsx | 121 +++++-- apps/web/src/state/subscriptionAllowance.ts | 67 ++-- packages/client-runtime/package.json | 4 + .../src/state/subscriptionAllowance.test.ts | 250 +++++++++++++++ .../src/state/subscriptionAllowance.ts | 295 ++++++++++++++++++ packages/contracts/src/usage.test.ts | 7 + packages/contracts/src/usage.ts | 20 ++ 7 files changed, 703 insertions(+), 61 deletions(-) create mode 100644 packages/client-runtime/src/state/subscriptionAllowance.test.ts create mode 100644 packages/client-runtime/src/state/subscriptionAllowance.ts diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index a733f9dfdcab..1f2bb0a9cef5 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -1,4 +1,6 @@ -import type { SubscriptionAllowance, UsageProviderKind } from "@t3tools/contracts"; +import type { SubscriptionAllowanceGroup } from "@t3tools/client-runtime/state/subscription-allowance"; +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { UsageProviderKind } from "@t3tools/contracts"; import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"; import type { ReactNode } from "react"; import { useMemo, useState } from "react"; @@ -454,9 +456,27 @@ function SubscriptionUsagePage({ }: { readonly onViewChange: (view: UsageView) => void; }) { - const { allowances, environments, isPending, isPartial, isRefreshing, refresh } = + const { groups, environments, isPending, isPartial, isRefreshing, refresh } = useSubscriptionAllowance(); - const failedEnvironments = environments.filter((environment) => environment.error !== null); + const environmentNotices = environments.flatMap((environment) => { + if (environment.error !== null) { + return [ + { + environmentId: environment.environmentId, + message: `${environment.label} could not report subscription usage.`, + }, + ]; + } + if (environment.snapshot === null && environment.connectionPhase !== "connected") { + return [ + { + environmentId: environment.environmentId, + message: `${environment.label} is ${formatAllowanceConnectionPhase(environment.connectionPhase).toLowerCase()}; subscription usage will return when it reconnects.`, + }, + ]; + } + return []; + }); const settling = isPending || isPartial; return ( @@ -486,29 +506,33 @@ function SubscriptionUsagePage({
Reading provider allowance…
- ) : allowances.length === 0 ? ( + ) : groups.length === 0 ? (
- {failedEnvironments.map((environment) => ( - - {environment.label} could not report subscription usage. - - ))} - No enabled provider reports subscription allowance data. + {environmentNotices.length > 0 ? ( + environmentNotices.map((notice) => ( + {notice.message} + )) + ) : ( + No enabled provider reports subscription allowance data. + )}
) : ( <> + {environmentNotices.length > 0 ? ( +
+ {environmentNotices.map((notice) => ( + {notice.message} + ))} +
+ ) : null} {isPartial ? (

Some environments are still reporting.

) : null}
- {allowances.map((entry) => ( - + {groups.map((group) => ( + ))}
@@ -518,22 +542,30 @@ function SubscriptionUsagePage({ ); } -function SubscriptionAllowanceCard({ - environmentLabel, - allowance, -}: { - readonly environmentLabel: string; - readonly allowance: SubscriptionAllowance; -}) { +function SubscriptionAllowanceCard({ group }: { readonly group: SubscriptionAllowanceGroup }) { + const displayedSource = group.effectiveSource ?? group.sources[0]!; + const { allowance } = displayedSource; + const sourceConnectionLabel = formatAllowanceConnectionPhase(displayedSource.connectionPhase); + const sourceLabel = [ + displayedSource.environmentLabel, + allowance.instanceId, + sourceConnectionLabel, + ] + .filter((part) => part !== null) + .join(" · "); + return (

{PROVIDER_LABEL[allowance.provider]} + {group.accountLabel !== null ? ( + {group.accountLabel} + ) : null}

- {environmentLabel} + {sourceLabel} {allowance.freshness === "stale" ? "Stale" @@ -652,10 +684,51 @@ function SubscriptionAllowanceCard({ ) : null} )} + {group.hasMultipleReadings ? ( +

+ Multiple readings are available; showing one whole provider source. +

+ ) : null} + {group.sources.length > 1 ? ( +
+ Sources + {group.sources.map((source, index) => ( + + {source.environmentLabel} · {source.allowance.instanceId} + {formatAllowanceConnectionPhase(source.connectionPhase) === null + ? "" + : ` · ${formatAllowanceConnectionPhase(source.connectionPhase)?.toLowerCase()}`} + {source.allowance.status === "unavailable" + ? " · unavailable" + : source.allowance.freshness === "stale" + ? " · stale" + : " · current"} + {group.effectiveSource === source ? " · shown" : ""} + + ))} +
+ ) : null}
); } +function formatAllowanceConnectionPhase(phase: EnvironmentConnectionPhase): string { + switch (phase) { + case "available": + return "Available"; + case "offline": + return "Offline"; + case "connecting": + return "Connecting"; + case "reconnecting": + return "Reconnecting"; + case "connected": + return "Connected"; + case "error": + return "Connection failed"; + } +} + /** Brand mark for the harness a row belongs to. */ function ProviderMark({ provider, diff --git a/apps/web/src/state/subscriptionAllowance.ts b/apps/web/src/state/subscriptionAllowance.ts index d9ada47d5bba..f62e603d17a5 100644 --- a/apps/web/src/state/subscriptionAllowance.ts +++ b/apps/web/src/state/subscriptionAllowance.ts @@ -1,10 +1,12 @@ import { useAtomCommand } from "../state/use-atom-command"; import { useAtomValue } from "@effect/atom-react"; +import { type EnvironmentId, type SubscriptionAllowance } from "@t3tools/contracts"; import { - type EnvironmentId, - type SubscriptionAllowance, - type SubscriptionAllowanceSnapshot, -} from "@t3tools/contracts"; + reconcileSubscriptionAllowances, + type EnvironmentSubscriptionAllowanceStatus, + type SubscriptionAllowanceProjection, + type SubscriptionAllowanceSource, +} from "@t3tools/client-runtime/state/subscription-allowance"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo, useState } from "react"; @@ -12,20 +14,6 @@ import { useCallback, useMemo, useState } from "react"; import { environmentPresentations } from "./presentation"; import { serverEnvironment } from "./server"; -export interface EnvironmentSubscriptionAllowanceStatus { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly isPending: boolean; - readonly error: string | null; - readonly snapshot: SubscriptionAllowanceSnapshot | null; -} - -export interface EnvironmentSubscriptionAllowance { - readonly environmentId: EnvironmentId; - readonly environmentLabel: string; - readonly allowance: SubscriptionAllowance; -} - const subscriptionAllowanceByEnvironmentAtom = Atom.make( (get): readonly EnvironmentSubscriptionAllowanceStatus[] => { const presentations = get(environmentPresentations.presentationsAtom); @@ -36,6 +24,7 @@ const subscriptionAllowanceByEnvironmentAtom = Atom.make( statuses.push({ environmentId, label: presentation.entry.target.label, + connectionPhase: presentation.connection.phase, isPending: result.waiting, error: result._tag === "Failure" @@ -49,8 +38,22 @@ const subscriptionAllowanceByEnvironmentAtom = Atom.make( }, ).pipe(Atom.withLabel("web-usage:subscription-allowance")); +/** Backward-compatible web helper; reconciliation uses the shared source model below. */ +export interface EnvironmentSubscriptionAllowance { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly allowance: SubscriptionAllowance; +} + export function flattenSubscriptionAllowances( - environments: readonly EnvironmentSubscriptionAllowanceStatus[], + environments: readonly (Pick< + EnvironmentSubscriptionAllowanceStatus, + "environmentId" | "snapshot" + > & { + readonly label: string; + readonly isPending?: boolean; + readonly error?: string | null; + })[], ): readonly EnvironmentSubscriptionAllowance[] { return environments.flatMap( (environment) => @@ -62,18 +65,14 @@ export function flattenSubscriptionAllowances( ); } -export interface SubscriptionAllowanceView { - readonly allowances: readonly EnvironmentSubscriptionAllowance[]; - readonly environments: readonly EnvironmentSubscriptionAllowanceStatus[]; - readonly isPending: boolean; - readonly isPartial: boolean; +export interface SubscriptionAllowanceView extends SubscriptionAllowanceProjection { readonly isRefreshing: boolean; readonly refresh: () => void; } export function useSubscriptionAllowance(): SubscriptionAllowanceView { const environments = useAtomValue(subscriptionAllowanceByEnvironmentAtom); - const allowances = useMemo(() => flattenSubscriptionAllowances(environments), [environments]); + const projection = useMemo(() => reconcileSubscriptionAllowances(environments), [environments]); const refreshAllowance = useAtomCommand(serverEnvironment.refreshSubscriptionAllowance, { reportFailure: false, }); @@ -82,26 +81,20 @@ export function useSubscriptionAllowance(): SubscriptionAllowanceView { const refresh = useCallback(() => { setIsRefreshing(true); void Promise.all( - environments.map((environment) => + projection.refreshEnvironmentIds.map((environmentId) => refreshAllowance({ - environmentId: environment.environmentId, + environmentId, input: {}, }), ), ).finally(() => setIsRefreshing(false)); - }, [environments, refreshAllowance]); - - const answeredCount = environments.filter((environment) => environment.snapshot !== null).length; - const stillReporting = environments.filter( - (environment) => environment.snapshot === null && environment.error === null, - ).length; + }, [projection.refreshEnvironmentIds, refreshAllowance]); return { - allowances, - environments, - isPending: answeredCount === 0 && stillReporting > 0, - isPartial: answeredCount > 0 && stillReporting > 0, + ...projection, isRefreshing, refresh, }; } + +export type { EnvironmentSubscriptionAllowanceStatus, SubscriptionAllowanceSource }; diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d1daa871652a..00111efc5e10 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -103,6 +103,10 @@ "types": "./src/state/server.ts", "default": "./src/state/server.ts" }, + "./state/subscription-allowance": { + "types": "./src/state/subscriptionAllowance.ts", + "default": "./src/state/subscriptionAllowance.ts" + }, "./state/session": { "types": "./src/state/session.ts", "default": "./src/state/session.ts" diff --git a/packages/client-runtime/src/state/subscriptionAllowance.test.ts b/packages/client-runtime/src/state/subscriptionAllowance.test.ts new file mode 100644 index 000000000000..e67281bd784c --- /dev/null +++ b/packages/client-runtime/src/state/subscriptionAllowance.test.ts @@ -0,0 +1,250 @@ +import { EnvironmentId, ProviderInstanceId, type SubscriptionAllowance } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + reconcileSubscriptionAllowances, + type EnvironmentSubscriptionAllowanceStatus, +} from "./subscriptionAllowance.ts"; + +const readAt = "2026-08-11T12:00:00.000Z"; + +function allowance( + instanceId: string, + overrides: Partial = {}, +): SubscriptionAllowance { + return { + provider: "codex" as const, + instanceId: ProviderInstanceId.make(instanceId), + status: "available" as const, + windows: [{ scope: "primary", usedPercent: 20 }], + ...overrides, + }; +} + +function environment( + environmentId: string, + allowances: readonly ReturnType[] | null, + overrides: Partial = {}, +): EnvironmentSubscriptionAllowanceStatus { + return { + environmentId: EnvironmentId.make(environmentId), + label: environmentId, + connectionPhase: "connected", + isPending: allowances === null, + error: null, + snapshot: + allowances === null + ? null + : { + readAt, + allowances, + }, + ...overrides, + }; +} + +describe("reconcileSubscriptionAllowances", () => { + it("keeps matching descriptors separate when no verified provider identity exists", () => { + const result = reconcileSubscriptionAllowances([ + environment("environment-b", [ + allowance("codex-work", { maskedAccountLabel: "n•••@example.com" }), + ]), + environment("environment-a", [ + allowance("codex-personal", { maskedAccountLabel: "n•••@example.com" }), + ]), + ]); + + expect(result.groups).toHaveLength(2); + expect(result.groups.flatMap((group) => group.sources)).toHaveLength(2); + expect(result.groups.map((group) => group.sources[0]?.environmentId)).toEqual([ + EnvironmentId.make("environment-a"), + EnvironmentId.make("environment-b"), + ]); + }); + + it("groups only exact provider identities and selects one whole effective source", () => { + const staleComplete = allowance("codex-a", { + verifiedAccountId: "account-1", + completeness: "complete", + freshness: "stale", + windows: [ + { scope: "primary", usedPercent: 12, windowDurationMins: 300 }, + { scope: "secondary", usedPercent: 4, windowDurationMins: 1_440 }, + ], + }); + const freshPartial = allowance("codex-b", { + verifiedAccountId: "account-1", + completeness: "partial", + freshness: "fresh", + windows: [{ scope: "primary", usedPercent: 31 }], + }); + + const result = reconcileSubscriptionAllowances([ + environment("environment-b", [freshPartial]), + environment("environment-a", [staleComplete]), + ]); + + expect(result.groups).toHaveLength(1); + expect(result.groups[0]?.key).toBe("allowance-group:0"); + expect(result.groups[0]?.key).not.toContain("account-1"); + expect(result.groups[0]?.sources).toHaveLength(2); + expect(result.groups[0]?.effectiveSource?.allowance).toBe(freshPartial); + expect(result.groups[0]?.effectiveSource?.allowance.windows).toEqual([ + { scope: "primary", usedPercent: 31 }, + ]); + expect(result.groups[0]?.hasMultipleReadings).toBe(true); + }); + + it("uses deterministic source ranking without comparing cross-environment timestamps", () => { + const cached = allowance("codex-cached", { + verifiedAccountId: "account-2", + deliverySource: "cache", + updatedAt: "2026-08-11T23:00:00.000Z", + }); + const live = allowance("codex-live", { + verifiedAccountId: "account-2", + deliverySource: "live", + updatedAt: "2026-08-11T01:00:00.000Z", + }); + + const result = reconcileSubscriptionAllowances([ + environment("environment-b", [cached]), + environment("environment-a", [live]), + ]); + + expect(result.groups[0]?.effectiveSource?.allowance).toBe(live); + }); + + it("does not call equal provider readings conflicting when only source metadata differs", () => { + const first = allowance("codex-a", { + verifiedAccountId: "account-4", + maskedAccountLabel: "n•••@example.com", + updatedAt: "2026-08-11T01:00:00.000Z", + }); + const second = allowance("codex-b", { + verifiedAccountId: "account-4", + maskedAccountLabel: "n•••@example.com", + updatedAt: "2026-08-11T02:00:00.000Z", + }); + + const result = reconcileSubscriptionAllowances([ + environment("environment-b", [second]), + environment("environment-a", [first]), + ]); + + expect(result.groups[0]?.hasMultipleReadings).toBe(false); + expect(result.groups[0]?.accountLabel).toBe("n•••@example.com"); + }); + + it("disambiguates equal masked labels belonging to different verified accounts", () => { + const result = reconcileSubscriptionAllowances([ + environment("environment-b", [ + allowance("codex-b", { + verifiedAccountId: "account-b", + maskedAccountLabel: "n•••@example.com", + }), + ]), + environment("environment-a", [ + allowance("codex-a", { + verifiedAccountId: "account-a", + maskedAccountLabel: "n•••@example.com", + }), + ]), + ]); + + expect(result.groups.map((group) => group.accountLabel)).toEqual([ + "n•••@example.com · environment-a · codex-a", + "n•••@example.com · environment-b · codex-b", + ]); + }); + + it("keeps connection state separate and refreshes only connected environments", () => { + const result = reconcileSubscriptionAllowances([ + environment("offline", null, { + connectionPhase: "offline", + isPending: false, + }), + environment("connected", null), + environment("failed", null, { + connectionPhase: "connected", + isPending: false, + error: "could not read", + }), + ]); + + expect(result.isPending).toBe(true); + expect(result.isPartial).toBe(false); + expect(result.refreshEnvironmentIds).toEqual([ + EnvironmentId.make("connected"), + EnvironmentId.make("failed"), + ]); + expect( + result.environments.find((entry) => entry.environmentId === EnvironmentId.make("offline")) + ?.connectionPhase, + ).toBe("offline"); + }); + + it("does not wait on disconnected environments after a connected snapshot arrives", () => { + const result = reconcileSubscriptionAllowances([ + environment("offline", null, { + connectionPhase: "reconnecting", + isPending: false, + }), + environment("connected", [allowance("codex")]), + ]); + + expect(result.isPending).toBe(false); + expect(result.isPartial).toBe(false); + expect(result.refreshEnvironmentIds).toEqual([EnvironmentId.make("connected")]); + }); + + it("retains unavailable sources without selecting or blending them", () => { + const unavailable = allowance("codex-unavailable", { + status: "unavailable", + windows: [], + verifiedAccountId: "account-3", + message: "Codex subscription usage is unavailable.", + }); + const available = allowance("codex-available", { + verifiedAccountId: "account-3", + windows: [{ scope: "primary", usedPercent: 45 }], + }); + + const result = reconcileSubscriptionAllowances([ + environment("environment-b", [unavailable]), + environment("environment-a", [available]), + ]); + + expect(result.groups).toHaveLength(1); + expect(result.groups[0]?.sources.map((source) => source.allowance)).toEqual([ + available, + unavailable, + ]); + expect(result.groups[0]?.effectiveSource?.allowance).toBe(available); + }); + + it("retains duplicate source records in their provider group", () => { + const first = allowance("codex-duplicate", { + verifiedAccountId: "account-duplicate", + windows: [{ scope: "primary", usedPercent: 20 }], + }); + const second = allowance("codex-duplicate", { + verifiedAccountId: "account-duplicate", + windows: [{ scope: "primary", usedPercent: 25 }], + }); + + const result = reconcileSubscriptionAllowances([environment("environment", [first, second])]); + + expect(result.sources).toHaveLength(2); + expect(result.groups).toHaveLength(1); + expect(result.groups[0]?.sources).toEqual([ + expect.objectContaining({ allowance: first }), + expect.objectContaining({ allowance: second }), + ]); + + const reversed = reconcileSubscriptionAllowances([environment("environment", [second, first])]); + expect(reversed.groups[0]?.effectiveSource?.allowance).toEqual( + result.groups[0]?.effectiveSource?.allowance, + ); + }); +}); diff --git a/packages/client-runtime/src/state/subscriptionAllowance.ts b/packages/client-runtime/src/state/subscriptionAllowance.ts new file mode 100644 index 000000000000..d398bdf5c816 --- /dev/null +++ b/packages/client-runtime/src/state/subscriptionAllowance.ts @@ -0,0 +1,295 @@ +import type { + EnvironmentId, + SubscriptionAllowance, + SubscriptionAllowanceSnapshot, +} from "@t3tools/contracts"; + +import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; + +/** + * The allowance state owned by one connected environment. + * + * Connection state deliberately sits beside, rather than inside, the provider + * record. A transport outage is not evidence that a provider account became + * unavailable. + */ +export interface EnvironmentSubscriptionAllowanceStatus { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly connectionPhase: EnvironmentConnectionPhase; + readonly isPending: boolean; + readonly error: string | null; + readonly snapshot: SubscriptionAllowanceSnapshot | null; +} + +export interface SubscriptionAllowanceSource { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly connectionPhase: EnvironmentConnectionPhase; + readonly allowance: SubscriptionAllowance; +} + +export interface SubscriptionAllowanceGroup { + /** Opaque local key. Verified account ids are used only to derive this key. */ + readonly key: string; + readonly provider: SubscriptionAllowance["provider"]; + /** A masked provider descriptor, only when all verified sources agree. */ + readonly accountLabel: string | null; + readonly status: SubscriptionAllowance["status"]; + readonly sources: readonly SubscriptionAllowanceSource[]; + /** One whole provider observation; never a field-by-field merge. */ + readonly effectiveSource: SubscriptionAllowanceSource | null; + readonly hasMultipleReadings: boolean; +} + +export interface SubscriptionAllowanceProjection { + readonly environments: readonly EnvironmentSubscriptionAllowanceStatus[]; + readonly sources: readonly SubscriptionAllowanceSource[]; + readonly groups: readonly SubscriptionAllowanceGroup[]; + readonly isPending: boolean; + readonly isPartial: boolean; + readonly refreshEnvironmentIds: readonly EnvironmentId[]; +} + +const PROVIDER_ORDER: Readonly> = { + codex: 0, + claude: 1, +}; + +function compareStrings(left: string, right: string): number { + return left === right ? 0 : left < right ? -1 : 1; +} + +function compareSources(left: SubscriptionAllowanceSource, right: SubscriptionAllowanceSource) { + const providerOrder = + PROVIDER_ORDER[left.allowance.provider] - PROVIDER_ORDER[right.allowance.provider]; + if (providerOrder !== 0) return providerOrder; + + const environmentOrder = compareStrings(left.environmentId, right.environmentId); + if (environmentOrder !== 0) return environmentOrder; + const instanceOrder = compareStrings(left.allowance.instanceId, right.allowance.instanceId); + if (instanceOrder !== 0) return instanceOrder; + + // Provider registries normally guarantee one record per instance. Keep the + // projection deterministic even if a malformed or replayed snapshot carries + // duplicate records with different readings. + return compareStrings( + stableJson({ + allowance: left.allowance, + connectionPhase: left.connectionPhase, + environmentLabel: left.environmentLabel, + }), + stableJson({ + allowance: right.allowance, + connectionPhase: right.connectionPhase, + environmentLabel: right.environmentLabel, + }), + ); +} + +function compareEnvironments( + left: EnvironmentSubscriptionAllowanceStatus, + right: EnvironmentSubscriptionAllowanceStatus, +): number { + return compareStrings(left.environmentId, right.environmentId); +} + +function isConnected(status: EnvironmentSubscriptionAllowanceStatus): boolean { + return status.connectionPhase === "connected"; +} + +function hasProviderData(allowance: SubscriptionAllowance): boolean { + return ( + allowance.windows.length > 0 || + (allowance.credits !== undefined && allowance.credits !== null) || + (allowance.spendingControl !== undefined && allowance.spendingControl !== null) || + (allowance.extraUsage !== undefined && allowance.extraUsage !== null) + ); +} + +function isComplete(allowance: SubscriptionAllowance): boolean { + return ( + allowance.completeness === "complete" || + (allowance.completeness === undefined && hasProviderData(allowance)) + ); +} + +function isFresh(allowance: SubscriptionAllowance): boolean { + // Older additive servers omit freshness. Their available snapshot is still + // usable, but it must not outrank an explicitly stale observation. + return allowance.freshness !== "stale"; +} + +function isLiveDelivery(allowance: SubscriptionAllowance): boolean { + // Missing delivery metadata is the live path used by the current server. + return allowance.deliverySource !== "cache"; +} + +function sourceRank(source: SubscriptionAllowanceSource): readonly number[] { + const allowance = source.allowance; + return [ + isFresh(allowance) ? 1 : 0, + isComplete(allowance) ? 1 : 0, + isLiveDelivery(allowance) ? 1 : 0, + source.connectionPhase === "connected" ? 1 : 0, + ]; +} + +function compareEffectiveSources( + left: SubscriptionAllowanceSource, + right: SubscriptionAllowanceSource, +): number { + const leftRank = sourceRank(left); + const rightRank = sourceRank(right); + for (let index = 0; index < leftRank.length; index += 1) { + const rankDelta = (rightRank[index] ?? 0) - (leftRank[index] ?? 0); + if (rankDelta !== 0) return rankDelta; + } + return compareSources(left, right); +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value === undefined) return "undefined"; + if (value === null || typeof value !== "object") return JSON.stringify(value); + return `{${Object.entries(value) + .toSorted(([left], [right]) => compareStrings(left, right)) + .map(([key, child]) => `${JSON.stringify(key)}:${stableJson(child)}`) + .join(",")}}`; +} + +function readingFingerprint(allowance: SubscriptionAllowance): string { + const { + instanceId: _instanceId, + freshness: _freshness, + updatedAt: _updatedAt, + observationSource: _observationSource, + deliverySource: _deliverySource, + verifiedAccountId: _verifiedAccountId, + maskedAccountLabel: _maskedAccountLabel, + ...reading + } = allowance; + return stableJson(reading); +} + +function identityKey(source: SubscriptionAllowanceSource): string { + const verifiedAccountId = source.allowance.verifiedAccountId?.trim(); + return verifiedAccountId === undefined || verifiedAccountId.length === 0 + ? `source:${source.allowance.provider}:${source.environmentId}:${source.allowance.instanceId}` + : `verified:${source.allowance.provider}:${verifiedAccountId}`; +} + +function accountLabel(sources: readonly SubscriptionAllowanceSource[]): string | null { + if (sources[0]?.allowance.verifiedAccountId === undefined) return null; + const labels = sources.map((source) => source.allowance.maskedAccountLabel?.trim() ?? null); + if (labels.some((label) => label === null || label.length === 0)) return null; + const uniqueLabels = new Set(labels); + const label = labels[0] ?? null; + return uniqueLabels.size === 1 && label !== null ? label : null; +} + +function hasMultipleReadings(sources: readonly SubscriptionAllowanceSource[]): boolean { + const readings = sources + .filter((source) => source.allowance.status === "available") + .map((source) => readingFingerprint(source.allowance)); + return new Set(readings).size > 1; +} + +function makeGroups( + sources: readonly SubscriptionAllowanceSource[], +): readonly SubscriptionAllowanceGroup[] { + const grouped = new Map(); + for (const source of sources) { + const key = identityKey(source); + const existing = grouped.get(key); + if (existing === undefined) grouped.set(key, [source]); + else existing.push(source); + } + + const groups = [...grouped.entries()].map(([key, groupedSources]) => { + const sortedSources = groupedSources.toSorted(compareSources); + const effectiveSource = + sortedSources + .filter((source) => source.allowance.status === "available") + .toSorted(compareEffectiveSources)[0] ?? null; + return { + key, + provider: sortedSources[0]!.allowance.provider, + accountLabel: accountLabel(sortedSources), + status: effectiveSource?.allowance.status ?? "unavailable", + sources: sortedSources, + effectiveSource, + hasMultipleReadings: hasMultipleReadings(sortedSources), + } satisfies SubscriptionAllowanceGroup; + }); + const accountLabelCounts = new Map(); + for (const group of groups) { + if (group.accountLabel !== null) { + const labelKey = `${group.provider}:${group.accountLabel}`; + accountLabelCounts.set(labelKey, (accountLabelCounts.get(labelKey) ?? 0) + 1); + } + } + + return groups + .map((group) => { + if ( + group.accountLabel === null || + accountLabelCounts.get(`${group.provider}:${group.accountLabel}`) === 1 + ) { + return group; + } + const source = group.effectiveSource ?? group.sources[0]!; + return { + ...group, + accountLabel: `${group.accountLabel} · ${source.environmentLabel} · ${source.allowance.instanceId}`, + }; + }) + .toSorted((left, right) => { + const providerOrder = PROVIDER_ORDER[left.provider] - PROVIDER_ORDER[right.provider]; + return providerOrder === 0 ? compareStrings(left.key, right.key) : providerOrder; + }) + .map((group, index) => ({ + ...group, + // React and consumers receive a local ordinal, never the provider's + // equality-only identity. + key: `allowance-group:${index}`, + })); +} + +function projectSources( + environments: readonly EnvironmentSubscriptionAllowanceStatus[], +): readonly SubscriptionAllowanceSource[] { + return environments + .flatMap( + (environment) => + environment.snapshot?.allowances.map((allowance) => ({ + environmentId: environment.environmentId, + environmentLabel: environment.label, + connectionPhase: environment.connectionPhase, + allowance, + })) ?? [], + ) + .toSorted(compareSources); +} + +export function reconcileSubscriptionAllowances( + input: readonly EnvironmentSubscriptionAllowanceStatus[], +): SubscriptionAllowanceProjection { + const environments = input.toSorted(compareEnvironments); + const connected = environments.filter(isConnected); + const answeredCount = connected.filter((environment) => environment.snapshot !== null).length; + const stillReporting = connected.filter( + (environment) => + environment.snapshot === null && environment.error === null && environment.isPending, + ).length; + const sources = projectSources(environments); + + return { + environments, + sources, + groups: makeGroups(sources), + isPending: answeredCount === 0 && stillReporting > 0, + isPartial: answeredCount > 0 && stillReporting > 0, + refreshEnvironmentIds: connected.map((environment) => environment.environmentId), + }; +} diff --git a/packages/contracts/src/usage.test.ts b/packages/contracts/src/usage.test.ts index 9db3ffb042ef..16f865add481 100644 --- a/packages/contracts/src/usage.test.ts +++ b/packages/contracts/src/usage.test.ts @@ -14,6 +14,11 @@ describe("subscription allowance contract", () => { provider: "codex", instanceId: "codex", status: "available", + completeness: "complete", + observationSource: "snapshot", + deliverySource: "live", + verifiedAccountId: "provider-account-1", + maskedAccountLabel: "n•••@example.com", windows: [ { scope: "primary", @@ -47,6 +52,8 @@ describe("subscription allowance contract", () => { expect(snapshot.allowances[0]).not.toHaveProperty("credits"); expect(snapshot.allowances[0]).not.toHaveProperty("spendingControl"); + expect(snapshot.allowances[0]?.verifiedAccountId).toBe("provider-account-1"); + expect(snapshot.allowances[0]?.deliverySource).toBe("live"); expect(snapshot.allowances[1]?.windows[0]?.scope).toBe("seven_day_opus"); expect(snapshot.allowances[1]?.extraUsage?.usedCredits).toBe(2.5); }); diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 55946c976843..016828f4e6c7 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -209,6 +209,19 @@ export type SubscriptionAllowanceStatus = typeof SubscriptionAllowanceStatus.Typ export const SubscriptionAllowanceFreshness = Schema.Literals(["fresh", "stale"]); export type SubscriptionAllowanceFreshness = typeof SubscriptionAllowanceFreshness.Type; +/** Whether an allowance record contains a complete provider observation. */ +export const SubscriptionAllowanceCompleteness = Schema.Literals(["complete", "partial"]); +export type SubscriptionAllowanceCompleteness = typeof SubscriptionAllowanceCompleteness.Type; + +/** The provider observation that supplied the allowance facts. */ +export const SubscriptionAllowanceObservationSource = Schema.Literals(["snapshot", "liveUpdate"]); +export type SubscriptionAllowanceObservationSource = + typeof SubscriptionAllowanceObservationSource.Type; + +/** Whether the current client delivery came directly from the environment or its cache. */ +export const SubscriptionAllowanceDeliverySource = Schema.Literals(["live", "cache"]); +export type SubscriptionAllowanceDeliverySource = typeof SubscriptionAllowanceDeliverySource.Type; + /** * Provider-native allowance bucket identifiers. Codex currently uses the * generic primary/secondary names; Claude supplies names such as @@ -257,6 +270,13 @@ export const SubscriptionAllowance = Schema.Struct({ instanceId: ProviderInstanceId, status: SubscriptionAllowanceStatus, freshness: Schema.optional(SubscriptionAllowanceFreshness), + completeness: Schema.optional(SubscriptionAllowanceCompleteness), + observationSource: Schema.optional(SubscriptionAllowanceObservationSource), + deliverySource: Schema.optional(SubscriptionAllowanceDeliverySource), + /** Equality-only provider identity; clients must never render or log it. */ + verifiedAccountId: Schema.optionalKey(TrimmedNonEmptyString), + /** Provider-reported privacy-safe descriptor, when available. */ + maskedAccountLabel: Schema.optionalKey(TrimmedNonEmptyString), updatedAt: Schema.optional(IsoDateTime), windows: Schema.Array(SubscriptionAllowanceWindow), credits: Schema.optionalKey(Schema.Union([SubscriptionAllowanceCredits, Schema.Null])), From 2933ac0c4f865ff0089ec9274ec993f3ce50dd39 Mon Sep 17 00:00:00 2001 From: naveed949 Date: Tue, 11 Aug 2026 15:56:19 +0500 Subject: [PATCH 05/29] feat(mobile): add subscription allowance usage --- .../src/features/usage/UsageRouteScreen.tsx | 460 ++++++++++++++++-- .../src/features/usage/usageAllowance.test.ts | 171 +++++++ .../src/features/usage/usageAllowance.ts | 175 +++++++ .../src/features/usage/usageProviderLabels.ts | 6 + .../src/features/usage/usageProviders.ts | 7 +- .../src/features/usage/usageView.test.ts | 27 + apps/mobile/src/features/usage/usageView.ts | 21 + .../mobile/src/state/subscriptionAllowance.ts | 79 +++ docs/README.md | 1 + docs/user/usage.md | 16 + 10 files changed, 915 insertions(+), 48 deletions(-) create mode 100644 apps/mobile/src/features/usage/usageAllowance.test.ts create mode 100644 apps/mobile/src/features/usage/usageAllowance.ts create mode 100644 apps/mobile/src/features/usage/usageProviderLabels.ts create mode 100644 apps/mobile/src/features/usage/usageView.test.ts create mode 100644 apps/mobile/src/features/usage/usageView.ts create mode 100644 apps/mobile/src/state/subscriptionAllowance.ts create mode 100644 docs/user/usage.md diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 54a9ac7bc21d..e6b5510c9107 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,4 +1,4 @@ -import { useNavigation } from "@react-navigation/native"; +import { useIsFocused, useNavigation } from "@react-navigation/native"; import type { MergedUsage } from "@t3tools/shared/usageMerge"; import { enumerateDays, @@ -10,17 +10,36 @@ import { makeWindow, } from "@t3tools/shared/usageFormat"; import { useMemo, useState } from "react"; -import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; +import { + ActivityIndicator, + Platform, + Pressable, + RefreshControl, + ScrollView, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { useSubscriptionAllowance } from "../../state/subscriptionAllowance"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; import { SettingsSection } from "../settings/components/SettingsSection"; import { UsageDailyChart } from "./UsageDailyChart"; import type { UsageChartMetric } from "./usageChartData"; +import { + formatAllowanceDuration, + formatAllowanceEnvironmentNotice, + formatAllowanceResetAt, + formatAllowanceUpdatedAt, + formatAllowanceWindowScope, + presentAllowanceGroup, + progressWidthForAllowance, + type MobileAllowanceCardModel, +} from "./usageAllowance"; import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; +import { subscriptionViewPhase, USAGE_VIEW_OPTIONS, type UsageView } from "./usageView"; const WINDOW_OPTIONS = [ { days: 7, label: "7 days" }, @@ -33,6 +52,48 @@ const CHART_HEIGHT = 180; export function UsageRouteScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); + const isFocused = useIsFocused(); + const [view, setView] = useState("historical"); + + return ( + + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : null} + + + ); +} + +function UsageContent(props: { + readonly view: UsageView; + readonly isFocused: boolean; + readonly onViewChange: (view: UsageView) => void; + readonly insetsBottom: number; +}) { + // Mount only the selected data source so leaving Subscription tears down its + // allowance stream and leaving Historical releases its transcript query. + if (!props.isFocused) return null; + + return props.view === "subscription" ? ( + + ) : ( + + ); +} + +function HistoricalUsageContent(props: { + readonly onViewChange: (view: UsageView) => void; + readonly insetsBottom: number; +}) { const [windowDays, setWindowDays] = useState(30); const [metric, setMetric] = useState("cost"); @@ -52,69 +113,382 @@ export function UsageRouteScreen() { const refreshing = environments.some((entry) => entry.isPending && entry.summary !== null); return ( - - {Platform.OS === "android" ? ( + } + > + + ({ value: option.days, label: option.label }))} + selected={windowDays} + onSelect={setWindowDays} + /> + + + + {isPending ? ( + + Scanning provider transcripts… + + ) : environments.length === 0 ? ( + + Connect an environment to see usage. + + ) : ( <> - - navigation.goBack()} /> + + + + + + )} + + ); +} + +function UsageViewTabs(props: { + readonly value: UsageView; + readonly onChange: (view: UsageView) => void; +}) { + return ( + + ); +} + +function SubscriptionUsageContent(props: { + readonly onViewChange: (view: UsageView) => void; + readonly insetsBottom: number; +}) { + const { groups, environments, isPending, isPartial, isRefreshing, refresh } = + useSubscriptionAllowance(); + const environmentNotices = environments.flatMap((environment) => { + const message = formatAllowanceEnvironmentNotice(environment); + return message === null ? [] : [{ environmentId: environment.environmentId, message }]; + }); + // Keep already available cards visible while another environment answers. + // Only an empty first response needs the full loading placeholder. + const phase = subscriptionViewPhase({ + isPending, + isPartial, + groupCount: groups.length, + }); + + return ( + } + > + + + + + + Subscription allowance + + + Current provider-reported limits and reset windows. + + + + + {isRefreshing ? "Refreshing…" : "Refresh"} + + + + + {phase === "loading" ? ( + + + Reading provider allowance… + + ) : groups.length === 0 ? ( + + {environmentNotices.length > 0 ? ( + environmentNotices.map((notice) => ( + + {notice.message} + + )) + ) : environments.length === 0 ? ( + + Connect an environment to see subscription allowance. + + ) : ( + + No enabled provider reports subscription allowance data. + + )} + + ) : ( + <> + {environmentNotices.length > 0 ? ( + + {environmentNotices.map((notice) => ( + + {notice.message} + + ))} + + ) : null} + {phase === "partial" ? ( + + Some environments are still reporting. + + ) : null} + + {groups.map((group) => ( + + ))} + + )} + + ); +} + +function SubscriptionAllowanceCard(props: { readonly model: MobileAllowanceCardModel }) { + const { model } = props; + const updatedAt = formatAllowanceUpdatedAt(model.updatedAt); + + return ( + + + + + {model.providerLabel} + + {model.accountLabel !== null ? ( + + {model.accountLabel} + + ) : null} + + + + {model.sourceLabel} + + + {model.freshness === "stale" + ? "Stale" + : updatedAt === null + ? "Updated time unavailable" + : `Updated ${updatedAt}`} + + + + + {model.status === "unavailable" ? ( + {model.message} + ) : ( + <> + + {model.windows.map((window) => ( + + ))} + + + + )} + + {model.hasMultipleReadings ? ( + + Multiple readings are available; showing one whole provider source. + ) : null} - } - > - ({ value: option.days, label: option.label }))} - selected={windowDays} - onSelect={setWindowDays} - /> + {model.sources.length > 1 ? : null} + + ); +} + +function AllowanceWindowRow(props: { + readonly window: MobileAllowanceCardModel["windows"][number]; +}) { + const { window } = props; + const hasUsage = window.usedPercent !== undefined && window.usedPercent !== null; + const duration = formatAllowanceDuration(window.windowDurationMins); + const reset = + window.resetsAt === undefined || window.resetsAt === null + ? null + : formatAllowanceResetAt(window.resetsAt); + + return ( + + + + {formatAllowanceWindowScope(window.scope)} + + + {hasUsage ? `${window.usedPercent}% used` : "Not reported"} + + + {hasUsage ? ( + + + + ) : null} + {duration !== null || reset !== null ? ( + + {[duration, reset === null ? null : `Resets ${reset}`].filter(Boolean).join(" · ")} + + ) : null} + + ); +} - +function AllowanceMetadata(props: { readonly model: MobileAllowanceCardModel }) { + const { model } = props; + const credits = model.credits; + const spendingControl = model.spendingControl; + const extraUsage = model.extraUsage; - {isPending ? ( - - Scanning provider transcripts… + return ( + <> + {credits !== null ? ( + + Credits + {credits.balance !== undefined && credits.balance !== null ? ( + Balance {credits.balance} + ) : null} + + {credits.unlimited ? "Unlimited" : credits.hasCredits ? "Available" : "No credits"} - ) : environments.length === 0 ? ( - - Connect an environment to see usage. + + ) : null} + + {spendingControl !== null ? ( + + Spending control + {spendingControl.reached !== undefined && spendingControl.reached !== null ? ( + + {spendingControl.reached ? "Reached" : "Not reached"} + + ) : null} + {spendingControl.used !== undefined && + spendingControl.used !== null && + spendingControl.limit !== undefined && + spendingControl.limit !== null ? ( + + Used {spendingControl.used} / {spendingControl.limit} + + ) : null} + {spendingControl.remainingPercent !== undefined && + spendingControl.remainingPercent !== null ? ( + + {spendingControl.remainingPercent}% remaining + + ) : null} + {spendingControl.resetsAt !== undefined && spendingControl.resetsAt !== null ? ( + + Resets {formatAllowanceResetAt(spendingControl.resetsAt)} + + ) : null} + + ) : null} + + {extraUsage !== null ? ( + + Extra usage + + {extraUsage.isEnabled ? "Enabled" : "Disabled"} - ) : ( - <> - - - - - - )} - + {extraUsage.monthlyLimit !== null ? ( + + Monthly limit {extraUsage.monthlyLimit} + + ) : null} + {extraUsage.usedCredits !== null ? ( + + Used credits {extraUsage.usedCredits} + + ) : null} + {extraUsage.utilization !== null ? ( + {extraUsage.utilization}% used + ) : null} + {extraUsage.currency !== undefined && extraUsage.currency !== null ? ( + {extraUsage.currency} + ) : null} + + ) : null} + + ); +} + +function AllowanceSources(props: { + readonly sources: readonly MobileAllowanceCardModel["sources"][number][]; +}) { + return ( + + Sources + {props.sources.map((source) => ( + + {source.environmentLabel} · {source.instanceId} · {source.connectionLabel} ·{" "} + {source.status} + {source.status === "unavailable" + ? " · unavailable" + : source.freshness === "stale" + ? " · stale" + : " · current"} + {source.isEffective ? " · shown" : ""} + + ))} ); } function SegmentedControl(props: { + readonly accessibilityLabel?: string; readonly options: readonly { readonly value: Value; readonly label: string }[]; readonly selected: Value; readonly onSelect: (value: Value) => void; }) { return ( - + {props.options.map((option) => { const active = option.value === props.selected; return ( props.onSelect(option.value)} diff --git a/apps/mobile/src/features/usage/usageAllowance.test.ts b/apps/mobile/src/features/usage/usageAllowance.test.ts new file mode 100644 index 000000000000..0ad281761b86 --- /dev/null +++ b/apps/mobile/src/features/usage/usageAllowance.test.ts @@ -0,0 +1,171 @@ +import type { SubscriptionAllowanceGroup } from "@t3tools/client-runtime/state/subscription-allowance"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + formatAllowanceEnvironmentNotice, + formatAllowanceWindowScope, + presentAllowanceGroup, + progressWidthForAllowance, +} from "./usageAllowance"; + +const readAt = "2026-08-11T12:00:00.000Z"; + +function group(overrides: Partial = {}): SubscriptionAllowanceGroup { + const source = { + environmentId: EnvironmentId.make("phone"), + environmentLabel: "Phone", + connectionPhase: "connected" as const, + allowance: { + provider: "codex" as const, + instanceId: ProviderInstanceId.make("codex-personal"), + status: "available" as const, + freshness: "fresh" as const, + updatedAt: readAt, + windows: [{ scope: "primary", usedPercent: 42, windowDurationMins: 300, resetsAt: readAt }], + }, + }; + + return { + key: "allowance-group:0", + provider: "codex", + accountLabel: null, + status: "available", + sources: [source], + effectiveSource: source, + hasMultipleReadings: false, + ...overrides, + }; +} + +describe("mobile subscription allowance presentation", () => { + it("keeps provider-native windows and optional values intact", () => { + const model = presentAllowanceGroup( + group({ + accountLabel: "n•••@example.com", + }), + ); + + expect(model).toMatchObject({ + provider: "codex", + providerLabel: "Codex", + accountLabel: "n•••@example.com", + status: "available", + freshness: "fresh", + updatedAt: readAt, + windows: [ + { + scope: "primary", + usedPercent: 42, + windowDurationMins: 300, + resetsAt: readAt, + }, + ], + }); + }); + + it("uses the stable Claude placeholder without inferring account state", () => { + const source = group().effectiveSource!; + const claudeSource = { + ...source, + allowance: { + ...source.allowance, + provider: "claude" as const, + instanceId: ProviderInstanceId.make("claude-personal"), + status: "unavailable" as const, + windows: [], + updatedAt: undefined, + message: undefined, + }, + }; + + const model = presentAllowanceGroup( + group({ + provider: "claude", + status: "unavailable", + sources: [claudeSource], + effectiveSource: claudeSource, + }), + ); + + expect(model.message).toBe( + "Claude subscription usage is unavailable. Claude did not provide usage limits.", + ); + expect(model.windows).toEqual([]); + expect(model.accountLabel).toBeNull(); + }); + + it("retains stale state, source identity, and multiple-reading context", () => { + const source = group().effectiveSource!; + const staleSource = { + ...source, + connectionPhase: "offline" as const, + allowance: { + ...source.allowance, + freshness: "stale" as const, + }, + }; + + const model = presentAllowanceGroup( + group({ + sources: [staleSource], + effectiveSource: staleSource, + hasMultipleReadings: true, + }), + ); + + expect(model.freshness).toBe("stale"); + expect(model.sourceLabel).toContain("Phone"); + expect(model.sourceLabel).toContain("codex-personal"); + expect(model.sourceLabel).toContain("Offline"); + expect(model.hasMultipleReadings).toBe(true); + }); + + it("keeps multiple environment sources inspectable without blending them", () => { + const source = group().effectiveSource!; + const secondSource = { + ...source, + environmentId: EnvironmentId.make("desktop"), + environmentLabel: "Desktop", + allowance: { + ...source.allowance, + instanceId: ProviderInstanceId.make("codex-work"), + windows: [{ scope: "secondary", usedPercent: 8 }], + }, + }; + + const model = presentAllowanceGroup( + group({ + sources: [source, secondSource], + effectiveSource: source, + hasMultipleReadings: true, + }), + ); + + expect(model.windows).toEqual(source.allowance.windows); + expect(model.sources.map((item) => item.environmentLabel)).toEqual(["Phone", "Desktop"]); + expect(model.sources.map((item) => item.isEffective)).toEqual([true, false]); + }); + + it("keeps missing values explicit and describes environment connection state", () => { + expect(formatAllowanceWindowScope("seven_day_opus")).toBe("7-day Opus limit"); + expect(progressWidthForAllowance(-1)).toBe(0); + expect(progressWidthForAllowance(101)).toBe(100); + expect( + formatAllowanceEnvironmentNotice({ + label: "Desktop", + connectionPhase: "offline", + error: null, + snapshot: null, + }), + ).toBe("Desktop is offline; subscription usage will return when it reconnects."); + expect( + formatAllowanceEnvironmentNotice({ + label: "Desktop", + connectionPhase: "connected", + error: "failed", + snapshot: null, + }), + ).toBe("Desktop could not report subscription usage."); + }); +}); diff --git a/apps/mobile/src/features/usage/usageAllowance.ts b/apps/mobile/src/features/usage/usageAllowance.ts new file mode 100644 index 000000000000..3af180ea9ea9 --- /dev/null +++ b/apps/mobile/src/features/usage/usageAllowance.ts @@ -0,0 +1,175 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { SubscriptionAllowanceGroup } from "@t3tools/client-runtime/state/subscription-allowance"; +import type { + SubscriptionAllowance, + SubscriptionAllowanceProviderKind, + SubscriptionAllowanceSpendingControl, + SubscriptionAllowanceWindow, +} from "@t3tools/contracts"; + +import { PROVIDER_LABEL } from "./usageProviderLabels"; + +export interface MobileAllowanceSourceModel { + readonly key: string; + readonly environmentLabel: string; + readonly instanceId: string; + readonly connectionLabel: string; + readonly status: SubscriptionAllowance["status"]; + readonly freshness: "fresh" | "stale"; + readonly isEffective: boolean; +} + +export interface MobileAllowanceCardModel { + readonly key: string; + readonly provider: SubscriptionAllowanceProviderKind; + readonly providerLabel: string; + readonly accountLabel: string | null; + readonly sourceLabel: string; + readonly status: SubscriptionAllowance["status"]; + readonly message: string; + readonly freshness: "fresh" | "stale"; + readonly updatedAt: string | null; + readonly windows: readonly SubscriptionAllowanceWindow[]; + readonly credits: NonNullable | null; + readonly spendingControl: SubscriptionAllowanceSpendingControl | null; + readonly extraUsage: NonNullable | null; + readonly hasMultipleReadings: boolean; + readonly sources: readonly MobileAllowanceSourceModel[]; +} + +export function formatAllowanceWindowScope(scope: string): string { + switch (scope) { + case "primary": + return "Primary limit"; + case "secondary": + return "Secondary limit"; + case "five_hour": + return "5-hour limit"; + case "seven_day": + return "7-day limit"; + case "seven_day_oauth_apps": + return "7-day OAuth apps limit"; + case "seven_day_opus": + return "7-day Opus limit"; + case "seven_day_sonnet": + return "7-day Sonnet limit"; + default: + return scope; + } +} + +export function formatAllowanceDuration(minutes: number | null | undefined): string | null { + if (minutes === null || minutes === undefined) return null; + if (minutes % (24 * 60) === 0) { + const days = minutes / (24 * 60); + return `${days} ${days === 1 ? "day" : "days"}`; + } + if (minutes % 60 === 0) { + const hours = minutes / 60; + return `${hours} ${hours === 1 ? "hour" : "hours"}`; + } + return `${minutes} minutes`; +} + +export function progressWidthForAllowance(usedPercent: number): number { + return Math.min(100, Math.max(0, usedPercent)); +} + +export function formatAllowanceResetAt(resetsAt: string): string { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(resetsAt)); +} + +export function formatAllowanceUpdatedAt(updatedAt: string | null | undefined): string | null { + if (updatedAt === null || updatedAt === undefined) return null; + const date = new Date(updatedAt); + if (Number.isNaN(date.getTime())) return null; + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +export function formatAllowanceConnectionPhase(phase: EnvironmentConnectionPhase): string { + switch (phase) { + case "available": + return "Available"; + case "offline": + return "Offline"; + case "connecting": + return "Connecting"; + case "reconnecting": + return "Reconnecting"; + case "connected": + return "Connected"; + case "error": + return "Connection failed"; + } +} + +export function formatAllowanceUnavailableMessage( + provider: SubscriptionAllowanceProviderKind, + message: string | undefined, +): string { + if (message !== undefined) return message; + return provider === "claude" + ? "Claude subscription usage is unavailable. Claude did not provide usage limits." + : "Subscription usage is unavailable."; +} + +export function formatAllowanceEnvironmentNotice(environment: { + readonly label: string; + readonly connectionPhase: EnvironmentConnectionPhase; + readonly error: string | null; + readonly snapshot: unknown; +}): string | null { + if (environment.error !== null) { + return `${environment.label} could not report subscription usage.`; + } + if (environment.snapshot === null && environment.connectionPhase !== "connected") { + return `${environment.label} is ${formatAllowanceConnectionPhase(environment.connectionPhase).toLowerCase()}; subscription usage will return when it reconnects.`; + } + return null; +} + +export function presentAllowanceGroup(group: SubscriptionAllowanceGroup): MobileAllowanceCardModel { + const displayedSource = group.effectiveSource ?? group.sources[0]; + if (displayedSource === undefined) { + throw new Error("Subscription allowance groups must contain a source"); + } + + const allowance = displayedSource.allowance; + const sourceLabel = [ + displayedSource.environmentLabel, + allowance.instanceId, + formatAllowanceConnectionPhase(displayedSource.connectionPhase), + ].join(" · "); + + return { + key: group.key, + provider: allowance.provider, + providerLabel: PROVIDER_LABEL[allowance.provider], + accountLabel: group.accountLabel, + sourceLabel, + status: allowance.status, + message: formatAllowanceUnavailableMessage(allowance.provider, allowance.message), + freshness: allowance.freshness ?? "fresh", + updatedAt: allowance.updatedAt ?? null, + windows: allowance.windows, + credits: allowance.credits ?? null, + spendingControl: allowance.spendingControl ?? null, + extraUsage: allowance.extraUsage ?? null, + hasMultipleReadings: group.hasMultipleReadings, + sources: group.sources.map((source, index) => ({ + key: `${source.environmentId}:${source.allowance.instanceId}:${index}`, + environmentLabel: source.environmentLabel, + instanceId: source.allowance.instanceId, + connectionLabel: formatAllowanceConnectionPhase(source.connectionPhase), + status: source.allowance.status, + freshness: source.allowance.freshness ?? "fresh", + isEffective: source === displayedSource, + })), + }; +} diff --git a/apps/mobile/src/features/usage/usageProviderLabels.ts b/apps/mobile/src/features/usage/usageProviderLabels.ts new file mode 100644 index 000000000000..6abebae4a37c --- /dev/null +++ b/apps/mobile/src/features/usage/usageProviderLabels.ts @@ -0,0 +1,6 @@ +import type { UsageProviderKind } from "@t3tools/contracts"; + +export const PROVIDER_LABEL: Record = { + claude: "Claude Code", + codex: "Codex", +}; diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts index 3e2d027a9e35..deb72a36b2ce 100644 --- a/apps/mobile/src/features/usage/usageProviders.ts +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -1,17 +1,14 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { useColorScheme } from "react-native"; +export { PROVIDER_LABEL } from "./usageProviderLabels"; + /** * Series and table order. The chart stacks providers from the bottom in this * order, so it also fixes which band sits on top of the bars. */ export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; -export const PROVIDER_LABEL: Record = { - claude: "Claude Code", - codex: "Codex", -}; - /** * Claude's brand orange holds in both themes; Codex is neutral and must flip * with the theme or its bars vanish against the matching background. diff --git a/apps/mobile/src/features/usage/usageView.test.ts b/apps/mobile/src/features/usage/usageView.test.ts new file mode 100644 index 000000000000..b0cfa99fa926 --- /dev/null +++ b/apps/mobile/src/features/usage/usageView.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { subscriptionViewPhase, USAGE_VIEW_OPTIONS } from "./usageView"; + +describe("mobile usage view state", () => { + it("keeps the Subscription/Historical labels stable for accessible controls", () => { + expect(USAGE_VIEW_OPTIONS).toEqual([ + { value: "subscription", label: "Subscription" }, + { value: "historical", label: "Historical" }, + ]); + }); + + it("blocks only the first empty response and keeps partial cards visible", () => { + expect(subscriptionViewPhase({ isPending: true, isPartial: false, groupCount: 0 })).toBe( + "loading", + ); + expect(subscriptionViewPhase({ isPending: false, isPartial: true, groupCount: 0 })).toBe( + "loading", + ); + expect(subscriptionViewPhase({ isPending: false, isPartial: true, groupCount: 1 })).toBe( + "partial", + ); + expect(subscriptionViewPhase({ isPending: false, isPartial: false, groupCount: 2 })).toBe( + "ready", + ); + }); +}); diff --git a/apps/mobile/src/features/usage/usageView.ts b/apps/mobile/src/features/usage/usageView.ts new file mode 100644 index 000000000000..c8328681a144 --- /dev/null +++ b/apps/mobile/src/features/usage/usageView.ts @@ -0,0 +1,21 @@ +export type UsageView = "subscription" | "historical"; + +export const USAGE_VIEW_OPTIONS = [ + { value: "subscription" as const, label: "Subscription" }, + { value: "historical" as const, label: "Historical" }, +] as const; + +export type SubscriptionViewPhase = "loading" | "partial" | "ready"; + +/** + * A partial projection can still render the sources that already answered. + * Only an empty first response needs the blocking loading state. + */ +export function subscriptionViewPhase(input: { + readonly isPending: boolean; + readonly isPartial: boolean; + readonly groupCount: number; +}): SubscriptionViewPhase { + if (input.isPending || (input.isPartial && input.groupCount === 0)) return "loading"; + return input.isPartial ? "partial" : "ready"; +} diff --git a/apps/mobile/src/state/subscriptionAllowance.ts b/apps/mobile/src/state/subscriptionAllowance.ts new file mode 100644 index 000000000000..4598562897fd --- /dev/null +++ b/apps/mobile/src/state/subscriptionAllowance.ts @@ -0,0 +1,79 @@ +/** + * Multi-environment subscription allowance state. + * + * The stream is read only while the mobile Subscription view is mounted. The + * shared projection keeps provider instances and environments separate and + * selects one whole source for display without blending native windows. + */ +import { useAtomValue } from "@effect/atom-react"; +import { + reconcileSubscriptionAllowances, + type EnvironmentSubscriptionAllowanceStatus, + type SubscriptionAllowanceProjection, + type SubscriptionAllowanceSource, +} from "@t3tools/client-runtime/state/subscription-allowance"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useMemo, useState } from "react"; + +import { useAtomCommand } from "./use-atom-command"; +import { environmentPresentations } from "./presentation"; +import { serverEnvironment } from "./server"; + +const subscriptionAllowanceByEnvironmentAtom = Atom.make( + (get): readonly EnvironmentSubscriptionAllowanceStatus[] => { + const presentations = get(environmentPresentations.presentationsAtom); + const statuses: EnvironmentSubscriptionAllowanceStatus[] = []; + + for (const [environmentId, presentation] of presentations) { + const result = get(serverEnvironment.subscriptionAllowance({ environmentId, input: {} })); + statuses.push({ + environmentId, + label: presentation.entry.target.label, + connectionPhase: presentation.connection.phase, + isPending: result.waiting, + error: + result._tag === "Failure" + ? "This environment could not report subscription usage." + : null, + snapshot: Option.getOrNull(AsyncResult.value(result)), + }); + } + + return statuses; + }, +).pipe(Atom.withLabel("mobile-usage:subscription-allowance")); + +export interface SubscriptionAllowanceView extends SubscriptionAllowanceProjection { + readonly isRefreshing: boolean; + readonly refresh: () => void; +} + +export function useSubscriptionAllowance(): SubscriptionAllowanceView { + const environments = useAtomValue(subscriptionAllowanceByEnvironmentAtom); + const projection = useMemo(() => reconcileSubscriptionAllowances(environments), [environments]); + const refreshAllowance = useAtomCommand(serverEnvironment.refreshSubscriptionAllowance, { + reportFailure: false, + }); + const [isRefreshing, setIsRefreshing] = useState(false); + + const refresh = useCallback(() => { + setIsRefreshing(true); + void Promise.all( + projection.refreshEnvironmentIds.map((environmentId) => + refreshAllowance({ + environmentId, + input: {}, + }), + ), + ).finally(() => setIsRefreshing(false)); + }, [projection.refreshEnvironmentIds, refreshAllowance]); + + return { + ...projection, + isRefreshing, + refresh, + }; +} + +export type { EnvironmentSubscriptionAllowanceStatus, SubscriptionAllowanceSource }; diff --git a/docs/README.md b/docs/README.md index 51277fd73d28..ff4cf8398c0e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ - [Permission modes](./user/permission-modes.md) - [Keyboard shortcuts](./user/keybindings.md) - [Organizing threads](./user/thread-sidebar.md) +- [Usage](./user/usage.md) - [Customize a project icon](./user/project-settings.md) - [Remote access](./user/remote-access.md) - [Keeping app and server in sync](./user/updating.md) diff --git a/docs/user/usage.md b/docs/user/usage.md new file mode 100644 index 000000000000..4c2b91a964d4 --- /dev/null +++ b/docs/user/usage.md @@ -0,0 +1,16 @@ +# Usage + +Open **Settings** → **Usage** to switch between two views: + +- **Historical** shows transcript-derived token activity, date windows, charts, and API-equivalent cost. +- **Subscription** shows the current allowance windows reported by each enabled Codex or Claude provider. + +Subscription allowance is kept separate for each provider and environment. T3 Code displays provider-reported percentages, window scopes, reset times, credits, and spending controls only when the provider supplies them. It does not infer a quota, reset, account status, or combined cross-provider total. + +On mobile, use the **Refresh** button or pull down on the Usage screen to request a new observation. Leaving the Subscription view stops its live allowance demand; returning to it starts a fresh snapshot-first subscription. If an environment is offline, its last known reading remains identified by its connection state rather than being presented as current. + +When Claude does not provide subscription limits, the Claude section remains visible with this explanation: + +> Claude subscription usage is unavailable. Claude did not provide usage limits. + +Historical usage remains available independently of subscription allowance reporting. From d3835da6b918988ae5dd79ecb105b1c3ee4e1cf9 Mon Sep 17 00:00:00 2001 From: naveed949 Date: Tue, 11 Aug 2026 16:39:43 +0500 Subject: [PATCH 06/29] fix(usage): handle allowance compatibility remotely --- .../src/features/usage/usageAllowance.test.ts | 11 +++ .../src/features/usage/usageAllowance.ts | 5 ++ .../mobile/src/state/subscriptionAllowance.ts | 16 +++-- apps/server/src/server.test.ts | 70 +++++++++++++++++++ apps/web/src/components/usage/UsagePage.tsx | 9 +++ apps/web/src/state/subscriptionAllowance.ts | 16 +++-- .../client-runtime/src/rpc/client.test.ts | 38 +++++++++- packages/client-runtime/src/rpc/client.ts | 22 ++++++ packages/client-runtime/src/state/runtime.ts | 8 ++- packages/client-runtime/src/state/server.ts | 1 + .../src/state/subscriptionAllowance.test.ts | 18 +++++ .../src/state/subscriptionAllowance.ts | 25 ++++++- 12 files changed, 227 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/features/usage/usageAllowance.test.ts b/apps/mobile/src/features/usage/usageAllowance.test.ts index 0ad281761b86..1b1012df1233 100644 --- a/apps/mobile/src/features/usage/usageAllowance.test.ts +++ b/apps/mobile/src/features/usage/usageAllowance.test.ts @@ -159,6 +159,17 @@ describe("mobile subscription allowance presentation", () => { snapshot: null, }), ).toBe("Desktop is offline; subscription usage will return when it reconnects."); + expect( + formatAllowanceEnvironmentNotice({ + label: "Desktop", + connectionPhase: "connected", + compatibility: true, + error: "failed", + snapshot: null, + }), + ).toBe( + "Desktop: Subscription allowance reporting is not supported by this environment version.", + ); expect( formatAllowanceEnvironmentNotice({ label: "Desktop", diff --git a/apps/mobile/src/features/usage/usageAllowance.ts b/apps/mobile/src/features/usage/usageAllowance.ts index 3af180ea9ea9..7f71c74812f9 100644 --- a/apps/mobile/src/features/usage/usageAllowance.ts +++ b/apps/mobile/src/features/usage/usageAllowance.ts @@ -6,6 +6,7 @@ import type { SubscriptionAllowanceSpendingControl, SubscriptionAllowanceWindow, } from "@t3tools/contracts"; +import { SUBSCRIPTION_ALLOWANCE_COMPATIBILITY_MESSAGE } from "@t3tools/client-runtime/state/subscription-allowance"; import { PROVIDER_LABEL } from "./usageProviderLabels"; @@ -122,9 +123,13 @@ export function formatAllowanceUnavailableMessage( export function formatAllowanceEnvironmentNotice(environment: { readonly label: string; readonly connectionPhase: EnvironmentConnectionPhase; + readonly compatibility?: boolean; readonly error: string | null; readonly snapshot: unknown; }): string | null { + if (environment.compatibility === true) { + return `${environment.label}: ${SUBSCRIPTION_ALLOWANCE_COMPATIBILITY_MESSAGE}`; + } if (environment.error !== null) { return `${environment.label} could not report subscription usage.`; } diff --git a/apps/mobile/src/state/subscriptionAllowance.ts b/apps/mobile/src/state/subscriptionAllowance.ts index 4598562897fd..be158ad73f8c 100644 --- a/apps/mobile/src/state/subscriptionAllowance.ts +++ b/apps/mobile/src/state/subscriptionAllowance.ts @@ -7,6 +7,7 @@ */ import { useAtomValue } from "@effect/atom-react"; import { + isSubscriptionAllowanceCompatibilityCause, reconcileSubscriptionAllowances, type EnvironmentSubscriptionAllowanceStatus, type SubscriptionAllowanceProjection, @@ -27,16 +28,23 @@ const subscriptionAllowanceByEnvironmentAtom = Atom.make( for (const [environmentId, presentation] of presentations) { const result = get(serverEnvironment.subscriptionAllowance({ environmentId, input: {} })); + const connectionPhase = presentation.connection.phase; + const isConnected = connectionPhase === "connected"; + const compatibility = + isConnected && + result._tag === "Failure" && + isSubscriptionAllowanceCompatibilityCause(result.cause); statuses.push({ environmentId, label: presentation.entry.target.label, - connectionPhase: presentation.connection.phase, - isPending: result.waiting, + connectionPhase, + isPending: isConnected && result.waiting, + compatibility, error: - result._tag === "Failure" + isConnected && result._tag === "Failure" && !compatibility ? "This environment could not report subscription usage." : null, - snapshot: Option.getOrNull(AsyncResult.value(result)), + snapshot: compatibility ? null : Option.getOrNull(AsyncResult.value(result)), }); } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 04f77efaeac6..dc57f9549e86 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4085,6 +4085,41 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("requires orchestration read scope for server.refreshSubscriptionAllowance", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const { response: tokenResponse, body: tokenBody } = yield* exchangeAccessToken( + defaultDesktopBootstrapToken, + { scope: "access:write" }, + ); + assert.equal(tokenResponse.status, 200); + assert.isDefined(tokenBody.access_token); + + const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { + authorization: `Bearer ${tokenBody.access_token ?? ""}`, + }, + }); + const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string }; + assert.equal(wsTicketResponse.status, 200); + + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; + const rpcError = yield* Effect.flip( + Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverRefreshSubscriptionAllowance]({}), + ), + ), + ); + + assert.equal(rpcError._tag, "EnvironmentAuthorizationError"); + if (rpcError._tag === "EnvironmentAuthorizationError") { + assert.equal(rpcError.requiredScope, "orchestration:read"); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes authenticated websocket rpc server.refreshSubscriptionAllowance", () => Effect.gen(function* () { const snapshot = { @@ -4741,6 +4776,41 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("requires orchestration read scope for subscribeSubscriptionAllowance", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const { response: tokenResponse, body: tokenBody } = yield* exchangeAccessToken( + defaultDesktopBootstrapToken, + { scope: "access:write" }, + ); + assert.equal(tokenResponse.status, 200); + assert.isDefined(tokenBody.access_token); + + const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { + authorization: `Bearer ${tokenBody.access_token ?? ""}`, + }, + }); + const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string }; + assert.equal(wsTicketResponse.status, 200); + + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; + const rpcError = yield* Effect.flip( + Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeSubscriptionAllowance]({}).pipe(Stream.runHead), + ), + ), + ); + + assert.equal(rpcError._tag, "EnvironmentAuthorizationError"); + if (rpcError._tag === "EnvironmentAuthorizationError") { + assert.equal(rpcError.requiredScope, "orchestration:read"); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => Effect.gen(function* () { const nextProviders = [ diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 1f2bb0a9cef5..82963afcc262 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -9,6 +9,7 @@ import { isElectron } from "../../env"; import { cn } from "../../lib/utils"; import { useSubscriptionAllowance } from "../../state/subscriptionAllowance"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; +import { SUBSCRIPTION_ALLOWANCE_COMPATIBILITY_MESSAGE } from "@t3tools/client-runtime/state/subscription-allowance"; import { enumerateDays, formatCount, @@ -459,6 +460,14 @@ function SubscriptionUsagePage({ const { groups, environments, isPending, isPartial, isRefreshing, refresh } = useSubscriptionAllowance(); const environmentNotices = environments.flatMap((environment) => { + if (environment.compatibility) { + return [ + { + environmentId: environment.environmentId, + message: `${environment.label}: ${SUBSCRIPTION_ALLOWANCE_COMPATIBILITY_MESSAGE}`, + }, + ]; + } if (environment.error !== null) { return [ { diff --git a/apps/web/src/state/subscriptionAllowance.ts b/apps/web/src/state/subscriptionAllowance.ts index f62e603d17a5..09575ffc04f8 100644 --- a/apps/web/src/state/subscriptionAllowance.ts +++ b/apps/web/src/state/subscriptionAllowance.ts @@ -2,6 +2,7 @@ import { useAtomCommand } from "../state/use-atom-command"; import { useAtomValue } from "@effect/atom-react"; import { type EnvironmentId, type SubscriptionAllowance } from "@t3tools/contracts"; import { + isSubscriptionAllowanceCompatibilityCause, reconcileSubscriptionAllowances, type EnvironmentSubscriptionAllowanceStatus, type SubscriptionAllowanceProjection, @@ -21,16 +22,23 @@ const subscriptionAllowanceByEnvironmentAtom = Atom.make( for (const [environmentId, presentation] of presentations) { const result = get(serverEnvironment.subscriptionAllowance({ environmentId, input: {} })); + const connectionPhase = presentation.connection.phase; + const isConnected = connectionPhase === "connected"; + const compatibility = + isConnected && + result._tag === "Failure" && + isSubscriptionAllowanceCompatibilityCause(result.cause); statuses.push({ environmentId, label: presentation.entry.target.label, - connectionPhase: presentation.connection.phase, - isPending: result.waiting, + connectionPhase, + isPending: isConnected && result.waiting, + compatibility, error: - result._tag === "Failure" + isConnected && result._tag === "Failure" && !compatibility ? "This environment could not report subscription usage." : null, - snapshot: Option.getOrNull(AsyncResult.value(result)), + snapshot: compatibility ? null : Option.getOrNull(AsyncResult.value(result)), }); } diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 507d137caccb..3f5e62716b89 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -25,7 +25,13 @@ import { import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; -import { EnvironmentRpcRequestObserver, request, runStream, subscribe } from "./client.ts"; +import { + EnvironmentRpcRequestObserver, + isRpcMethodNotFoundError, + request, + runStream, + subscribe, +} from "./client.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -231,6 +237,36 @@ describe("environment RPC", () => { }), ); + it.effect("surfaces an additive method-not-found as a compatibility failure when requested", () => + Effect.gen(function* () { + const client = { + [WS_METHODS.subscribeSubscriptionAllowance]: () => + Stream.fail( + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: `Unknown request tag: ${WS_METHODS.subscribeSubscriptionAllowance}`, + cause: new Error("method is not implemented by this server"), + }), + }), + ), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const error = yield* subscribe( + WS_METHODS.subscribeSubscriptionAllowance, + {}, + { failOnMethodNotFound: true }, + ).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.flip, + ); + + expect(isRpcMethodNotFoundError(error)).toBe(true); + }), + ); + it.effect("surfaces domain subscription failures without reconnecting", () => Effect.gen(function* () { const domainError = new Error("terminal subscription rejected"); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index a608debdecdc..dd3ce7659e7c 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -84,6 +84,19 @@ export class EnvironmentRpcSubscriptionObserver extends Context.Reference<{ export const isRpcClientError = Schema.is(RpcClientError.RpcClientError); +/** + * Older servers answer additive RPCs with a protocol defect rather than a + * provider failure. Keep this classification narrow so callers can project a + * compatibility state without surfacing the defect as account auth failure. + */ +export function isRpcMethodNotFoundError(error: unknown): boolean { + return ( + isRpcClientError(error) && + error.reason._tag === "RpcClientDefect" && + /^Unknown request tag:\s*\S+/.test(error.reason.message) + ); +} + export type EnvironmentRpcInput = Parameters>[0]; export type EnvironmentRpcSuccess = @@ -169,6 +182,8 @@ export function runStream( } interface SubscriptionOptions { + /** Preserve an additive method-not-found as a typed stream failure. */ + readonly failOnMethodNotFound?: boolean; readonly onExpectedFailure?: ( cause: Cause.Cause>, ) => Effect.Effect; @@ -235,6 +250,13 @@ export function subscribeDynamic( (reason) => reason._tag === "Fail" && isRpcClientError(reason.error), ); if (isTransportFailure) { + const isMethodNotFound = cause.reasons.every( + (reason) => + reason._tag === "Fail" && isRpcMethodNotFoundError(reason.error), + ); + if (isMethodNotFound && options?.failOnMethodNotFound === true) { + return Stream.failCause(cause); + } return Stream.fromEffect( Effect.logWarning( "Durable RPC subscription lost its transport; waiting for the next session.", diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 0a00e919c4ba..5acf8534f4d1 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -622,6 +622,7 @@ export function createEnvironmentRpcSubscriptionAtomFamily< readonly label: string; readonly tag: TTag; readonly idleTtlMs?: number; + readonly failOnMethodNotFound?: boolean; readonly transform?: ( stream: Stream.Stream< EnvironmentRpcStreamValue, @@ -635,7 +636,12 @@ export function createEnvironmentRpcSubscriptionAtomFamily< label: options.label, ...(options.idleTtlMs === undefined ? {} : { idleTtlMs: options.idleTtlMs }), subscribe: (input: EnvironmentRpcInput) => { - const stream = subscribe(options.tag, input); + const stream = + options.failOnMethodNotFound === undefined + ? subscribe(options.tag, input) + : subscribe(options.tag, input, { + failOnMethodNotFound: options.failOnMethodNotFound, + }); return options.transform === undefined ? (stream as Stream.Stream, EnvironmentSupervisor | R>) : options.transform(stream); diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 6fcc27973792..1072c736324e 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -718,6 +718,7 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:subscription-allowance", tag: WS_METHODS.subscribeSubscriptionAllowance, idleTtlMs: 0, + failOnMethodNotFound: true, }), configProjection, welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { diff --git a/packages/client-runtime/src/state/subscriptionAllowance.test.ts b/packages/client-runtime/src/state/subscriptionAllowance.test.ts index e67281bd784c..72244d7dc7e3 100644 --- a/packages/client-runtime/src/state/subscriptionAllowance.test.ts +++ b/packages/client-runtime/src/state/subscriptionAllowance.test.ts @@ -31,6 +31,7 @@ function environment( label: environmentId, connectionPhase: "connected", isPending: allowances === null, + compatibility: false, error: null, snapshot: allowances === null @@ -184,6 +185,23 @@ describe("reconcileSubscriptionAllowances", () => { ).toBe("offline"); }); + it("keeps older-server compatibility separate and does not refresh unsupported environments", () => { + const result = reconcileSubscriptionAllowances([ + environment("old-server", null, { + isPending: false, + compatibility: true, + }), + environment("connected", null), + ]); + + expect(result.isPending).toBe(true); + expect(result.refreshEnvironmentIds).toEqual([EnvironmentId.make("connected")]); + expect( + result.environments.find((entry) => entry.environmentId === EnvironmentId.make("connected")) + ?.compatibility, + ).toBe(false); + }); + it("does not wait on disconnected environments after a connected snapshot arrives", () => { const result = reconcileSubscriptionAllowances([ environment("offline", null, { diff --git a/packages/client-runtime/src/state/subscriptionAllowance.ts b/packages/client-runtime/src/state/subscriptionAllowance.ts index d398bdf5c816..4313c518867e 100644 --- a/packages/client-runtime/src/state/subscriptionAllowance.ts +++ b/packages/client-runtime/src/state/subscriptionAllowance.ts @@ -3,8 +3,13 @@ import type { SubscriptionAllowance, SubscriptionAllowanceSnapshot, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; +import { isRpcMethodNotFoundError } from "../rpc/client.ts"; + +export const SUBSCRIPTION_ALLOWANCE_COMPATIBILITY_MESSAGE = + "Subscription allowance reporting is not supported by this environment version."; /** * The allowance state owned by one connected environment. @@ -18,10 +23,21 @@ export interface EnvironmentSubscriptionAllowanceStatus { readonly label: string; readonly connectionPhase: EnvironmentConnectionPhase; readonly isPending: boolean; + /** The connected server predates the additive allowance RPC. */ + readonly compatibility: boolean; readonly error: string | null; readonly snapshot: SubscriptionAllowanceSnapshot | null; } +export function isSubscriptionAllowanceCompatibilityCause(cause: Cause.Cause): boolean { + return ( + cause.reasons.length > 0 && + cause.reasons.every( + (reason) => Cause.isFailReason(reason) && isRpcMethodNotFoundError(reason.error), + ) + ); +} + export interface SubscriptionAllowanceSource { readonly environmentId: EnvironmentId; readonly environmentLabel: string; @@ -280,7 +296,10 @@ export function reconcileSubscriptionAllowances( const answeredCount = connected.filter((environment) => environment.snapshot !== null).length; const stillReporting = connected.filter( (environment) => - environment.snapshot === null && environment.error === null && environment.isPending, + environment.snapshot === null && + !environment.compatibility && + environment.error === null && + environment.isPending, ).length; const sources = projectSources(environments); @@ -290,6 +309,8 @@ export function reconcileSubscriptionAllowances( groups: makeGroups(sources), isPending: answeredCount === 0 && stillReporting > 0, isPartial: answeredCount > 0 && stillReporting > 0, - refreshEnvironmentIds: connected.map((environment) => environment.environmentId), + refreshEnvironmentIds: connected + .filter((environment) => !environment.compatibility) + .map((environment) => environment.environmentId), }; } From 1379ec3c47202300fe28c25041d38abbf50fad47 Mon Sep 17 00:00:00 2001 From: naveed949 Date: Tue, 11 Aug 2026 17:19:37 +0500 Subject: [PATCH 07/29] fix(usage): finalize subscription allowance release handoff Default Usage to Subscription, keep disconnected sources from appearing current, and document user, maintainer, and release evidence boundaries. --- .../src/features/usage/UsageRouteScreen.tsx | 13 +- .../src/features/usage/usageAllowance.test.ts | 1 + .../src/features/usage/usageAllowance.ts | 7 +- .../src/features/usage/usageView.test.ts | 6 +- apps/mobile/src/features/usage/usageView.ts | 2 + .../src/components/usage/UsagePage.test.tsx | 8 +- apps/web/src/components/usage/UsagePage.tsx | 13 +- docs/README.md | 2 + docs/internals/subscription-allowance.md | 119 ++++++++++++++++++ .../subscription-allowance-release.md | 100 +++++++++++++++ docs/user/usage.md | 31 ++++- .../src/state/subscriptionAllowance.test.ts | 15 +++ .../src/state/subscriptionAllowance.ts | 8 ++ 13 files changed, 311 insertions(+), 14 deletions(-) create mode 100644 docs/internals/subscription-allowance.md create mode 100644 docs/operations/subscription-allowance-release.md diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index e6b5510c9107..8c97b8c24ebf 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -39,7 +39,12 @@ import { type MobileAllowanceCardModel, } from "./usageAllowance"; import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; -import { subscriptionViewPhase, USAGE_VIEW_OPTIONS, type UsageView } from "./usageView"; +import { + DEFAULT_USAGE_VIEW, + subscriptionViewPhase, + USAGE_VIEW_OPTIONS, + type UsageView, +} from "./usageView"; const WINDOW_OPTIONS = [ { days: 7, label: "7 days" }, @@ -53,7 +58,7 @@ export function UsageRouteScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); const isFocused = useIsFocused(); - const [view, setView] = useState("historical"); + const [view, setView] = useState(DEFAULT_USAGE_VIEW); return ( @@ -464,7 +469,9 @@ function AllowanceSources(props: { ? " · unavailable" : source.freshness === "stale" ? " · stale" - : " · current"} + : source.isCurrent + ? " · current" + : " · not current"} {source.isEffective ? " · shown" : ""} ))} diff --git a/apps/mobile/src/features/usage/usageAllowance.test.ts b/apps/mobile/src/features/usage/usageAllowance.test.ts index 1b1012df1233..50558b39f572 100644 --- a/apps/mobile/src/features/usage/usageAllowance.test.ts +++ b/apps/mobile/src/features/usage/usageAllowance.test.ts @@ -119,6 +119,7 @@ describe("mobile subscription allowance presentation", () => { expect(model.sourceLabel).toContain("codex-personal"); expect(model.sourceLabel).toContain("Offline"); expect(model.hasMultipleReadings).toBe(true); + expect(model.sources[0]?.isCurrent).toBe(false); }); it("keeps multiple environment sources inspectable without blending them", () => { diff --git a/apps/mobile/src/features/usage/usageAllowance.ts b/apps/mobile/src/features/usage/usageAllowance.ts index 7f71c74812f9..904b0703b3ca 100644 --- a/apps/mobile/src/features/usage/usageAllowance.ts +++ b/apps/mobile/src/features/usage/usageAllowance.ts @@ -6,7 +6,10 @@ import type { SubscriptionAllowanceSpendingControl, SubscriptionAllowanceWindow, } from "@t3tools/contracts"; -import { SUBSCRIPTION_ALLOWANCE_COMPATIBILITY_MESSAGE } from "@t3tools/client-runtime/state/subscription-allowance"; +import { + isSubscriptionAllowanceSourceCurrent, + SUBSCRIPTION_ALLOWANCE_COMPATIBILITY_MESSAGE, +} from "@t3tools/client-runtime/state/subscription-allowance"; import { PROVIDER_LABEL } from "./usageProviderLabels"; @@ -17,6 +20,7 @@ export interface MobileAllowanceSourceModel { readonly connectionLabel: string; readonly status: SubscriptionAllowance["status"]; readonly freshness: "fresh" | "stale"; + readonly isCurrent: boolean; readonly isEffective: boolean; } @@ -174,6 +178,7 @@ export function presentAllowanceGroup(group: SubscriptionAllowanceGroup): Mobile connectionLabel: formatAllowanceConnectionPhase(source.connectionPhase), status: source.allowance.status, freshness: source.allowance.freshness ?? "fresh", + isCurrent: isSubscriptionAllowanceSourceCurrent(source), isEffective: source === displayedSource, })), }; diff --git a/apps/mobile/src/features/usage/usageView.test.ts b/apps/mobile/src/features/usage/usageView.test.ts index b0cfa99fa926..70992282923f 100644 --- a/apps/mobile/src/features/usage/usageView.test.ts +++ b/apps/mobile/src/features/usage/usageView.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { subscriptionViewPhase, USAGE_VIEW_OPTIONS } from "./usageView"; +import { DEFAULT_USAGE_VIEW, subscriptionViewPhase, USAGE_VIEW_OPTIONS } from "./usageView"; describe("mobile usage view state", () => { + it("opens on the Subscription view", () => { + expect(DEFAULT_USAGE_VIEW).toBe("subscription"); + }); + it("keeps the Subscription/Historical labels stable for accessible controls", () => { expect(USAGE_VIEW_OPTIONS).toEqual([ { value: "subscription", label: "Subscription" }, diff --git a/apps/mobile/src/features/usage/usageView.ts b/apps/mobile/src/features/usage/usageView.ts index c8328681a144..4c0aeed9230c 100644 --- a/apps/mobile/src/features/usage/usageView.ts +++ b/apps/mobile/src/features/usage/usageView.ts @@ -1,5 +1,7 @@ export type UsageView = "subscription" | "historical"; +export const DEFAULT_USAGE_VIEW: UsageView = "subscription"; + export const USAGE_VIEW_OPTIONS = [ { value: "subscription" as const, label: "Subscription" }, { value: "historical" as const, label: "Historical" }, diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index b02bf13d3011..0db7389f498e 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -1,7 +1,13 @@ import { Children, type ReactElement, type ReactNode } from "react"; import { describe, expect, it } from "vite-plus/test"; -import { UsageViewTabs, type UsageView } from "./UsagePage"; +import { DEFAULT_USAGE_VIEW, UsageViewTabs, type UsageView } from "./UsagePage"; + +describe("UsagePage defaults", () => { + it("opens on the Subscription view", () => { + expect(DEFAULT_USAGE_VIEW).toBe("subscription"); + }); +}); describe("UsageViewTabs", () => { it("exposes an accessible Subscription/Historical segmented control", () => { diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 82963afcc262..eb6253715e51 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -9,7 +9,10 @@ import { isElectron } from "../../env"; import { cn } from "../../lib/utils"; import { useSubscriptionAllowance } from "../../state/subscriptionAllowance"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; -import { SUBSCRIPTION_ALLOWANCE_COMPATIBILITY_MESSAGE } from "@t3tools/client-runtime/state/subscription-allowance"; +import { + isSubscriptionAllowanceSourceCurrent, + SUBSCRIPTION_ALLOWANCE_COMPATIBILITY_MESSAGE, +} from "@t3tools/client-runtime/state/subscription-allowance"; import { enumerateDays, formatCount, @@ -49,8 +52,10 @@ export const USAGE_VIEW_OPTIONS: ReadonlyArray<{ { value: "historical", label: "Historical" }, ]; +export const DEFAULT_USAGE_VIEW: UsageView = "subscription"; + export function UsagePage() { - const [view, setView] = useState("historical"); + const [view, setView] = useState(DEFAULT_USAGE_VIEW); return view === "historical" ? ( @@ -711,7 +716,9 @@ function SubscriptionAllowanceCard({ group }: { readonly group: SubscriptionAllo ? " · unavailable" : source.allowance.freshness === "stale" ? " · stale" - : " · current"} + : isSubscriptionAllowanceSourceCurrent(source) + ? " · current" + : " · not current"} {group.effectiveSource === source ? " · shown" : ""} ))} diff --git a/docs/README.md b/docs/README.md index ff4cf8398c0e..94011ca792f6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,12 +33,14 @@ policy in [CONTRIBUTING.md](../CONTRIBUTING.md); agent rules in [AGENTS.md](../A - [Server updates](./internals/server-updates.md) - [Resource telemetry](./internals/resource-telemetry.md) - [Environment auth](./internals/environment-auth.md) +- [Subscription allowance](./internals/subscription-allowance.md) - [T3 Connect](./internals/t3-connect.md) - [CI gates](./internals/ci.md) ### Runbooks - [Release](./operations/release.md) +- [Subscription allowance release evidence](./operations/subscription-allowance-release.md) - [Observability](./operations/observability.md) - [Relay observability](./operations/relay-observability.md) - [Mobile app store screenshots](./operations/mobile-app-store-screenshots.md) diff --git a/docs/internals/subscription-allowance.md b/docs/internals/subscription-allowance.md new file mode 100644 index 000000000000..f0c64f436f97 --- /dev/null +++ b/docs/internals/subscription-allowance.md @@ -0,0 +1,119 @@ +# Subscription allowance + +> For maintainers. Using T3 Code? See [docs/user/usage.md](../user/usage.md). + +Subscription allowance is a current provider observation. It is deliberately separate from the +transcript-derived `UsageSummary` used by Historical. The allowance path is additive: it does not +change the historical contract, persist allowance history, or require a database migration. + +## Boundaries + +The public provider seam is [`ProviderAllowanceReader`][reader]. A materialized provider instance +may expose one reader with two responsibilities: + +- `read` acquires one complete provider-native observation; +- `update` maps a provider runtime event to a sparse observation, when that provider supports live + updates. + +The reader does not expose subprocesses, SDK query objects, cache state, or orchestration state. +Those details stay in the provider adapter. The shared contract in [`usage.ts`][contract] preserves +provider-native windows, percentage meaning, reset timestamps, optional credits or spending +controls, freshness, provenance, and privacy-safe instance identity. Missing fields mean “not +reported”; they are never converted to zero. + +## Server lifecycle + +[`SubscriptionAllowanceService`][service] owns the environment-local lifecycle: + +1. A Subscription consumer acquires demand. The service subscribes to provider events and provider + registry changes, then starts one snapshot acquisition. +2. The snapshot is published before sparse live updates. Reconnects therefore have a complete + record without replaying an event history. +3. While demand exists, automatic refresh is bounded to one acquisition per five minutes. Manual + refresh bypasses freshness but joins the same per-service single-flight operation. +4. A failed refresh retains an available last-known record as `stale`. Passing a provider reset + timestamp also marks an unrefreshed record stale; the service never resets a percentage itself. +5. Sparse updates are folded into the last complete record and published as a complete snapshot. + A sparse event cannot create the first public record. Provider-instance generation checks prevent + a replaced reader from publishing into its successor. +6. When the last consumer leaves Subscription, the demand-scoped event and refresh fibers close. + The bounded in-memory last-known record may remain, but allowance history is not persisted. + +The service treats provider failure as an explicit unavailable record so one failing provider does +not turn a multi-provider response into an empty or misleading success. Diagnostics retain only a +bounded outcome category and privacy-safe provider-instance identifier; raw native payloads do not +cross the service boundary. + +## Provider adapters + +Codex uses the configured app-server client to request `account/rateLimits/read`. The adapter keeps +Codex’s native primary/secondary windows, used-percent meaning, reset timestamps, credits, and +spending controls. Its runtime event mapper accepts sparse `account.rate-limits.updated` facts. + +Claude reuses the configured executable, environment, home/configuration path, and OAuth setup +through the existing Agent SDK query. The allowance probe: + +- starts a never-yielding input stream; +- waits for SDK initialization; +- feature-detects the experimental + `usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET` control method; +- requests usage without sending a user message or making a model turn; +- applies a bounded deadline; and +- aborts and closes the query on success, unavailable response, error, timeout, or cancellation. + +When Claude reports no limits, lacks the experimental method, or returns a response that cannot +provide limits, the adapter returns the stable unavailable presentation: + +> Claude subscription usage is unavailable. Claude did not provide usage limits. + +That result does not infer whether the account lacks a subscription, uses an API key, lacks a +scope, or encountered an outage. The owner-selected rollout of this existing configured path is +not Anthropic approval and is not a compliance determination. + +## Transport and compatibility + +The server exposes three additive methods in [`rpc.ts`][rpc]: + +- `server.getSubscriptionAllowance` for a one-shot compatibility read; +- `server.refreshSubscriptionAllowance` for explicit refresh demand; and +- `subscribeSubscriptionAllowance` for snapshot-first live delivery. + +All three use the same authenticated environment access as Historical usage. The wire shape +contains allowance observations only; credentials, raw native payloads, transcripts, unmasked +emails, local configuration paths, and Claude behavior data remain inside the owning environment. + +A newer client connected to an older server treats a method-not-found response as environment +compatibility, not provider authentication failure. It keeps Historical working and shows a +Subscription compatibility message for that environment. An older client ignores the additive +methods when connected to a newer server. No historical contract version bump or persistence +migration is required. + +## Client reconciliation + +[`subscriptionAllowance.ts`][projection] keeps every environment and provider-instance source in +the projection. Sources are grouped only when the provider reports an exact verified allowance +identity. Labels, emails, plan names, environment names, paths, or locally derived fingerprints do +not prove identity. + +Grouping is a presentation view, not a merge. The projection chooses one complete whole-source +observation by deterministic freshness, completeness, delivery, connection, and source ordering; +it never combines one environment’s windows with another environment’s credits. The web and mobile +surfaces render that same projection in their native layouts. Leaving Subscription unmounts the +allowance consumer, which releases demand while leaving Historical independent. + +## Evidence and release boundary + +The implementation, automated tests, live provider evidence, integrated surface checks, +documentation, and release readiness are separate states. Use the +[subscription allowance release evidence checklist][release-checklist] to record them. A green +focused test or local commit is not a live-provider, browser/device, push, merge, deployment, or +release receipt. The user-visible feature is not release-ready until Codex, Claude, web/desktop, +mobile, remote/compatibility behavior, Historical regression, privacy, and required evidence gates +are all complete. + +[reader]: ../../apps/server/src/provider/Services/ProviderAllowanceReader.ts +[contract]: ../../packages/contracts/src/usage.ts +[service]: ../../apps/server/src/usage/SubscriptionAllowanceService.ts +[rpc]: ../../packages/contracts/src/rpc.ts +[projection]: ../../packages/client-runtime/src/state/subscriptionAllowance.ts +[release-checklist]: ../operations/subscription-allowance-release.md diff --git a/docs/operations/subscription-allowance-release.md b/docs/operations/subscription-allowance-release.md new file mode 100644 index 000000000000..7710004fe8fb --- /dev/null +++ b/docs/operations/subscription-allowance-release.md @@ -0,0 +1,100 @@ +# Subscription allowance release evidence + +> For maintainers. This is an evidence checklist, not a release declaration. + +The Subscription view is release-ready only when each evidence class is complete. Keep the +receipts privacy-sanitized and record the exact client, server, provider, environment, and commit +used for each live or integrated pass. Do not treat an implementation commit, a worker report, or +green automated tests as proof of a live provider, remote, or release outcome. + +## Evidence states + +Record these states separately in the release record: + +| State | Meaning | +| ------------- | ------------------------------------------------------------------------------------------------------ | +| Implemented | The scoped code and documentation exist locally. | +| Tested | Focused tests, targeted formatting/linting, and scoped typechecks passed. | +| Live-verified | A real configured provider/environment returned the required sanitized evidence. | +| Documented | User and maintainer behavior, privacy, compatibility, and rollout boundaries are written down. | +| Release-ready | Every required provider, client, connection, regression, privacy, and evidence gate below is complete. | + +## Production residue + +- [ ] The shipped source contains no fixture allowance values, alternate prototype variants, URL + variant selector, or prototype switcher. +- [ ] The production Usage path opens on Subscription and still reaches unchanged Historical + behavior through the segmented control. +- [ ] No release note or user-facing document calls the owner-selected Claude rollout Anthropic + approval or a compliance determination. + +## Automated validation + +- [ ] Contract tests cover native Codex and Claude snapshots, sparse updates, nullable values, + freshness, provenance, privacy-safe identity, and compatibility. +- [ ] Provider reader tests cover Codex mapping and Claude initialization-before-usage, zero user + messages, no model turn, bounded cleanup, native field preservation, unavailable responses, + and experimental-method compatibility. +- [ ] Server lifecycle tests cover snapshot-first demand, single-flight/manual refresh, five-minute + refresh bounds, sparse folding, stale transitions, generation safety, reconnect, demand + teardown, and cleanup. +- [ ] Shared projection and web/mobile presentation tests cover multiple sources, exact identity + grouping, whole-source selection, provider placeholders, stale/refreshing/loading states, + accessibility, and unchanged Historical controls. +- [ ] Focused tests, targeted formatting/linting, and scoped typechecks pass for every changed + package. Repository-wide checks are CI-owned unless separately requested. + +## Provider evidence + +Store snapshots outside the repository or in an approved sanitized evidence location. Redact +credentials, raw payloads, unmasked emails, configuration paths, transcripts, session IDs, and +local behavior data. Keep only provider-reported fields needed to prove the UI. + +### Codex positive path + +- [ ] An enabled configured Codex instance returned a complete snapshot through the production + provider reader. +- [ ] The record shows the provider-native window scope, used/remaining meaning, reset time, and + credits or spending controls only where Codex supplied them. +- [ ] The captured instance/environment label is privacy-safe and the receipt identifies the exact + build and server without exposing secrets. + +### Claude positive path + +- [ ] An enabled configured Claude instance with an active subscription returned + `rate_limits_available: true` through the configured Agent SDK path. +- [ ] The sanitized receipt proves initialization, the usage control request, zero user messages, + no model turn, cleanup, and provider-native window/extra-usage fields. +- [ ] The receipt does not infer a plan, account state, scope, or approval from fields Claude did + not report. + +### Claude unavailable path + +- [ ] An authenticated response with unavailable limits renders the exact placeholder: + `Claude subscription usage is unavailable. Claude did not provide usage limits.` +- [ ] The evidence does not claim why limits were absent. + +## Integrated acceptance + +### Web and desktop + +- [ ] One integrated pass covers Subscription/Historical switching, both providers, manual refresh, + loading/refreshing/stale/unavailable states, multiple instances/environments, accessibility, + unchanged Historical content, and desktop parity. +- [ ] Remote disconnect/reconnect and newer-client/older-server compatibility are observed. +- [ ] Before/after images are captured; capture a short recording when refresh or live-update timing + is material. + +### Mobile + +- [ ] One integrated pass covers the equivalent provider/state/refresh/remote/accessibility + behavior in a supported simulator or device, including compact layout and unchanged + Historical content. + +## Release boundary + +Do not declare the feature released until all boxes above are checked and the release record names +which state is supported by each receipt. This checklist does not authorize pushing, merging, +deploying, changing issue state, or closing the parent or child issue. The public Claude rollout is +an owner decision about the configured runtime path; it must never be described as Anthropic +approval or a compliance determination. diff --git a/docs/user/usage.md b/docs/user/usage.md index 4c2b91a964d4..3d281b672110 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -1,16 +1,37 @@ # Usage -Open **Settings** → **Usage** to switch between two views: +Open **Settings** → **Usage**. The page opens on **Subscription**, and the segmented control lets +you switch between two independent views: -- **Historical** shows transcript-derived token activity, date windows, charts, and API-equivalent cost. - **Subscription** shows the current allowance windows reported by each enabled Codex or Claude provider. +- **Historical** shows transcript-derived token activity, date windows, charts, and API-equivalent cost. -Subscription allowance is kept separate for each provider and environment. T3 Code displays provider-reported percentages, window scopes, reset times, credits, and spending controls only when the provider supplies them. It does not infer a quota, reset, account status, or combined cross-provider total. +Subscription allowance is kept separate from Historical usage, and each provider instance and +environment remains inspectable. T3 Code displays provider-reported percentages, window scopes, +reset times, credits, and spending controls only when the provider supplies them. It does not infer +a quota, reset, account status, or combined cross-provider total. -On mobile, use the **Refresh** button or pull down on the Usage screen to request a new observation. Leaving the Subscription view stops its live allowance demand; returning to it starts a fresh snapshot-first subscription. If an environment is offline, its last known reading remains identified by its connection state rather than being presented as current. +Use **Refresh** on web or mobile to request a new provider observation. On mobile, you can also pull +down on the Usage screen. A fresh reading is current provider data; a stale reading is retained +data from before a failed refresh or a passed reset time. T3 Code does not reset percentages locally. + +Leaving the Subscription view stops its live allowance updates; returning to it requests a fresh +reading. If an environment is offline or reconnecting, its last known reading remains identified +by its connection state and is not presented as current. Multiple environments and provider +instances are shown separately unless the provider supplies an exact, privacy-safe identity that +proves they share one allowance account; even then, the displayed group uses one whole source and +never blends windows from several sources. When Claude does not provide subscription limits, the Claude section remains visible with this explanation: > Claude subscription usage is unavailable. Claude did not provide usage limits. -Historical usage remains available independently of subscription allowance reporting. +This message does not diagnose the account, subscription, OAuth scope, or provider outage. If a +newer client is connected to an older environment that does not support Subscription, the page +shows an environment compatibility message while Historical remains available. Older clients can +continue using Historical when connected to a newer environment because the new connection methods +are additive. + +Historical usage remains available independently of subscription allowance reporting. Subscription +allowance does not add a new analytics stream or expose credentials, raw provider payloads, +transcripts, configuration paths, or unmasked account details. diff --git a/packages/client-runtime/src/state/subscriptionAllowance.test.ts b/packages/client-runtime/src/state/subscriptionAllowance.test.ts index 72244d7dc7e3..18c04c93993c 100644 --- a/packages/client-runtime/src/state/subscriptionAllowance.test.ts +++ b/packages/client-runtime/src/state/subscriptionAllowance.test.ts @@ -2,6 +2,7 @@ import { EnvironmentId, ProviderInstanceId, type SubscriptionAllowance } from "@ import { describe, expect, it } from "vite-plus/test"; import { + isSubscriptionAllowanceSourceCurrent, reconcileSubscriptionAllowances, type EnvironmentSubscriptionAllowanceStatus, } from "./subscriptionAllowance.ts"; @@ -45,6 +46,20 @@ function environment( } describe("reconcileSubscriptionAllowances", () => { + it("does not call a disconnected last-known source current", () => { + const source = { + environmentId: EnvironmentId.make("offline"), + environmentLabel: "Offline", + connectionPhase: "offline" as const, + allowance: allowance("codex-offline"), + }; + + expect(isSubscriptionAllowanceSourceCurrent(source)).toBe(false); + expect(isSubscriptionAllowanceSourceCurrent({ ...source, connectionPhase: "connected" })).toBe( + true, + ); + }); + it("keeps matching descriptors separate when no verified provider identity exists", () => { const result = reconcileSubscriptionAllowances([ environment("environment-b", [ diff --git a/packages/client-runtime/src/state/subscriptionAllowance.ts b/packages/client-runtime/src/state/subscriptionAllowance.ts index 4313c518867e..483ab357be86 100644 --- a/packages/client-runtime/src/state/subscriptionAllowance.ts +++ b/packages/client-runtime/src/state/subscriptionAllowance.ts @@ -45,6 +45,14 @@ export interface SubscriptionAllowanceSource { readonly allowance: SubscriptionAllowance; } +export function isSubscriptionAllowanceSourceCurrent(source: SubscriptionAllowanceSource): boolean { + return ( + source.connectionPhase === "connected" && + source.allowance.status === "available" && + source.allowance.freshness !== "stale" + ); +} + export interface SubscriptionAllowanceGroup { /** Opaque local key. Verified account ids are used only to derive this key. */ readonly key: string; From 583e972b6bb6641aa4f61cba9c95425f1b151813 Mon Sep 17 00:00:00 2001 From: naveed949 Date: Wed, 12 Aug 2026 12:07:29 +0500 Subject: [PATCH 08/29] fix(usage): simplify allowance card metadata --- .../src/features/usage/UsageRouteScreen.tsx | 42 +++-------------- .../src/features/usage/usageAllowance.test.ts | 12 +++-- .../src/features/usage/usageAllowance.ts | 29 ++++++------ apps/web/src/components/usage/UsagePage.tsx | 45 +++---------------- .../components/usage/usageAllowance.test.ts | 10 +++++ .../src/components/usage/usageAllowance.ts | 18 +++++--- 6 files changed, 55 insertions(+), 101 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 8c97b8c24ebf..9cb3925bf465 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -296,10 +296,7 @@ function SubscriptionAllowanceCard(props: { readonly model: MobileAllowanceCardM ) : null} - - - {model.sourceLabel} - + {model.freshness === "stale" - ? "Stale" + ? updatedAt === null + ? "Stale" + : `Stale · ${updatedAt}` : updatedAt === null ? "Updated time unavailable" - : `Updated ${updatedAt}`} + : updatedAt} @@ -380,7 +379,6 @@ function AllowanceWindowRow(props: { function AllowanceMetadata(props: { readonly model: MobileAllowanceCardModel }) { const { model } = props; const credits = model.credits; - const spendingControl = model.spendingControl; const extraUsage = model.extraUsage; return ( @@ -397,36 +395,6 @@ function AllowanceMetadata(props: { readonly model: MobileAllowanceCardModel }) ) : null} - {spendingControl !== null ? ( - - Spending control - {spendingControl.reached !== undefined && spendingControl.reached !== null ? ( - - {spendingControl.reached ? "Reached" : "Not reached"} - - ) : null} - {spendingControl.used !== undefined && - spendingControl.used !== null && - spendingControl.limit !== undefined && - spendingControl.limit !== null ? ( - - Used {spendingControl.used} / {spendingControl.limit} - - ) : null} - {spendingControl.remainingPercent !== undefined && - spendingControl.remainingPercent !== null ? ( - - {spendingControl.remainingPercent}% remaining - - ) : null} - {spendingControl.resetsAt !== undefined && spendingControl.resetsAt !== null ? ( - - Resets {formatAllowanceResetAt(spendingControl.resetsAt)} - - ) : null} - - ) : null} - {extraUsage !== null ? ( Extra usage diff --git a/apps/mobile/src/features/usage/usageAllowance.test.ts b/apps/mobile/src/features/usage/usageAllowance.test.ts index 50558b39f572..a6a8211a67ec 100644 --- a/apps/mobile/src/features/usage/usageAllowance.test.ts +++ b/apps/mobile/src/features/usage/usageAllowance.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { formatAllowanceEnvironmentNotice, + formatAllowanceUpdatedAt, formatAllowanceWindowScope, presentAllowanceGroup, progressWidthForAllowance, @@ -115,9 +116,6 @@ describe("mobile subscription allowance presentation", () => { ); expect(model.freshness).toBe("stale"); - expect(model.sourceLabel).toContain("Phone"); - expect(model.sourceLabel).toContain("codex-personal"); - expect(model.sourceLabel).toContain("Offline"); expect(model.hasMultipleReadings).toBe(true); expect(model.sources[0]?.isCurrent).toBe(false); }); @@ -180,4 +178,12 @@ describe("mobile subscription allowance presentation", () => { }), ).toBe("Desktop could not report subscription usage."); }); + + it("shows relative freshness without exposing a local timestamp", () => { + const now = Date.parse("2026-08-12T12:00:00.000Z"); + + expect(formatAllowanceUpdatedAt("2026-08-12T12:00:00.000Z", now)).toBe("Updated just now"); + expect(formatAllowanceUpdatedAt("2026-08-12T11:57:00.000Z", now)).toBe("Updated 3m ago"); + expect(formatAllowanceUpdatedAt("2026-08-12T10:00:00.000Z", now)).toBe("Updated 2h ago"); + }); }); diff --git a/apps/mobile/src/features/usage/usageAllowance.ts b/apps/mobile/src/features/usage/usageAllowance.ts index 904b0703b3ca..3471c88de9d7 100644 --- a/apps/mobile/src/features/usage/usageAllowance.ts +++ b/apps/mobile/src/features/usage/usageAllowance.ts @@ -3,7 +3,6 @@ import type { SubscriptionAllowanceGroup } from "@t3tools/client-runtime/state/s import type { SubscriptionAllowance, SubscriptionAllowanceProviderKind, - SubscriptionAllowanceSpendingControl, SubscriptionAllowanceWindow, } from "@t3tools/contracts"; import { @@ -29,14 +28,12 @@ export interface MobileAllowanceCardModel { readonly provider: SubscriptionAllowanceProviderKind; readonly providerLabel: string; readonly accountLabel: string | null; - readonly sourceLabel: string; readonly status: SubscriptionAllowance["status"]; readonly message: string; readonly freshness: "fresh" | "stale"; readonly updatedAt: string | null; readonly windows: readonly SubscriptionAllowanceWindow[]; readonly credits: NonNullable | null; - readonly spendingControl: SubscriptionAllowanceSpendingControl | null; readonly extraUsage: NonNullable | null; readonly hasMultipleReadings: boolean; readonly sources: readonly MobileAllowanceSourceModel[]; @@ -87,14 +84,22 @@ export function formatAllowanceResetAt(resetsAt: string): string { }).format(new Date(resetsAt)); } -export function formatAllowanceUpdatedAt(updatedAt: string | null | undefined): string | null { +export function formatAllowanceUpdatedAt( + updatedAt: string | null | undefined, + now = Date.now(), +): string | null { if (updatedAt === null || updatedAt === undefined) return null; const date = new Date(updatedAt); if (Number.isNaN(date.getTime())) return null; - return new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", - }).format(date); + + const elapsedMinutes = Math.max(0, Math.floor((now - date.getTime()) / 60_000)); + if (elapsedMinutes < 1) return "Updated just now"; + if (elapsedMinutes < 60) return `Updated ${elapsedMinutes}m ago`; + + const elapsedHours = Math.floor(elapsedMinutes / 60); + if (elapsedHours < 24) return `Updated ${elapsedHours}h ago`; + + return `Updated ${Math.floor(elapsedHours / 24)}d ago`; } export function formatAllowanceConnectionPhase(phase: EnvironmentConnectionPhase): string { @@ -150,25 +155,17 @@ export function presentAllowanceGroup(group: SubscriptionAllowanceGroup): Mobile } const allowance = displayedSource.allowance; - const sourceLabel = [ - displayedSource.environmentLabel, - allowance.instanceId, - formatAllowanceConnectionPhase(displayedSource.connectionPhase), - ].join(" · "); - return { key: group.key, provider: allowance.provider, providerLabel: PROVIDER_LABEL[allowance.provider], accountLabel: group.accountLabel, - sourceLabel, status: allowance.status, message: formatAllowanceUnavailableMessage(allowance.provider, allowance.message), freshness: allowance.freshness ?? "fresh", updatedAt: allowance.updatedAt ?? null, windows: allowance.windows, credits: allowance.credits ?? null, - spendingControl: allowance.spendingControl ?? null, extraUsage: allowance.extraUsage ?? null, hasMultipleReadings: group.hasMultipleReadings, sources: group.sources.map((source, index) => ({ diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index eb6253715e51..1f461f6ed409 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -559,14 +559,7 @@ function SubscriptionUsagePage({ function SubscriptionAllowanceCard({ group }: { readonly group: SubscriptionAllowanceGroup }) { const displayedSource = group.effectiveSource ?? group.sources[0]!; const { allowance } = displayedSource; - const sourceConnectionLabel = formatAllowanceConnectionPhase(displayedSource.connectionPhase); - const sourceLabel = [ - displayedSource.environmentLabel, - allowance.instanceId, - sourceConnectionLabel, - ] - .filter((part) => part !== null) - .join(" · "); + const updatedAt = formatAllowanceUpdatedAt(allowance.updatedAt); return (
@@ -579,14 +572,12 @@ function SubscriptionAllowanceCard({ group }: { readonly group: SubscriptionAllo ) : null}
- {sourceLabel} {allowance.freshness === "stale" - ? "Stale" - : (() => { - const updatedAt = formatAllowanceUpdatedAt(allowance.updatedAt); - return updatedAt === null ? "Updated time unavailable" : `Updated ${updatedAt}`; - })()} + ? updatedAt === null + ? "Stale" + : `Stale · ${updatedAt}` + : (updatedAt ?? "Updated time unavailable")}
@@ -651,32 +642,6 @@ function SubscriptionAllowanceCard({ group }: { readonly group: SubscriptionAllo
) : null} - {allowance.spendingControl !== undefined && allowance.spendingControl !== null ? ( -
- Spending control - {allowance.spendingControl.reached !== undefined && - allowance.spendingControl.reached !== null ? ( - {allowance.spendingControl.reached ? "Reached" : "Not reached"} - ) : null} - {allowance.spendingControl.used !== undefined && - allowance.spendingControl.used !== null && - allowance.spendingControl.limit !== undefined && - allowance.spendingControl.limit !== null ? ( - - Used {allowance.spendingControl.used} / {allowance.spendingControl.limit} - - ) : null} - {allowance.spendingControl.remainingPercent !== undefined && - allowance.spendingControl.remainingPercent !== null ? ( - {allowance.spendingControl.remainingPercent}% remaining - ) : null} - {allowance.spendingControl.resetsAt !== undefined && - allowance.spendingControl.resetsAt !== null ? ( - Resets {formatAllowanceResetAt(allowance.spendingControl.resetsAt)} - ) : null} -
- ) : null} - {allowance.extraUsage !== undefined && allowance.extraUsage !== null ? (
Extra usage diff --git a/apps/web/src/components/usage/usageAllowance.test.ts b/apps/web/src/components/usage/usageAllowance.test.ts index d276e7beb502..0e2ef9279a44 100644 --- a/apps/web/src/components/usage/usageAllowance.test.ts +++ b/apps/web/src/components/usage/usageAllowance.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { formatAllowanceDuration, + formatAllowanceUpdatedAt, formatAllowanceWindowScope, progressWidthForAllowance, } from "./usageAllowance"; @@ -23,4 +24,13 @@ describe("usage allowance presentation", () => { expect(progressWidthForAllowance(42)).toBe(42); expect(progressWidthForAllowance(101)).toBe(100); }); + + it("shows relative freshness without exposing a local timestamp", () => { + const now = Date.parse("2026-08-12T12:00:00.000Z"); + + expect(formatAllowanceUpdatedAt("2026-08-12T12:00:00.000Z", now)).toBe("Updated just now"); + expect(formatAllowanceUpdatedAt("2026-08-12T11:57:00.000Z", now)).toBe("Updated 3m ago"); + expect(formatAllowanceUpdatedAt("2026-08-12T10:00:00.000Z", now)).toBe("Updated 2h ago"); + expect(formatAllowanceUpdatedAt("2026-08-10T12:00:00.000Z", now)).toBe("Updated 2d ago"); + }); }); diff --git a/apps/web/src/components/usage/usageAllowance.ts b/apps/web/src/components/usage/usageAllowance.ts index 0801ff4f4f62..4be7bb3c319b 100644 --- a/apps/web/src/components/usage/usageAllowance.ts +++ b/apps/web/src/components/usage/usageAllowance.ts @@ -45,12 +45,20 @@ export function formatAllowanceResetAt(resetsAt: string): string { }).format(new Date(resetsAt)); } -export function formatAllowanceUpdatedAt(updatedAt: string | null | undefined): string | null { +export function formatAllowanceUpdatedAt( + updatedAt: string | null | undefined, + now = Date.now(), +): string | null { if (updatedAt === null || updatedAt === undefined) return null; const date = new Date(updatedAt); if (Number.isNaN(date.getTime())) return null; - return new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", - }).format(date); + + const elapsedMinutes = Math.max(0, Math.floor((now - date.getTime()) / 60_000)); + if (elapsedMinutes < 1) return "Updated just now"; + if (elapsedMinutes < 60) return `Updated ${elapsedMinutes}m ago`; + + const elapsedHours = Math.floor(elapsedMinutes / 60); + if (elapsedHours < 24) return `Updated ${elapsedHours}h ago`; + + return `Updated ${Math.floor(elapsedHours / 24)}d ago`; } From ddfccb03390d0e42ba019a4bbaebe0289c2bab78 Mon Sep 17 00:00:00 2001 From: naveed949 Date: Wed, 12 Aug 2026 13:13:14 +0500 Subject: [PATCH 09/29] feat(usage): align allowance provider colors --- .../src/features/usage/UsageRouteScreen.tsx | 17 +++++++++++++---- apps/web/src/components/usage/UsagePage.tsx | 15 +++++++++++---- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 9cb3925bf465..1a5d08f3530e 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -282,6 +282,7 @@ function SubscriptionUsageContent(props: { function SubscriptionAllowanceCard(props: { readonly model: MobileAllowanceCardModel }) { const { model } = props; const updatedAt = formatAllowanceUpdatedAt(model.updatedAt); + const providerColors = useProviderColors(); return ( @@ -321,7 +322,11 @@ function SubscriptionAllowanceCard(props: { readonly model: MobileAllowanceCardM <> {model.windows.map((window) => ( - + ))} @@ -340,8 +345,9 @@ function SubscriptionAllowanceCard(props: { readonly model: MobileAllowanceCardM function AllowanceWindowRow(props: { readonly window: MobileAllowanceCardModel["windows"][number]; + readonly progressColor: string; }) { - const { window } = props; + const { window, progressColor } = props; const hasUsage = window.usedPercent !== undefined && window.usedPercent !== null; const duration = formatAllowanceDuration(window.windowDurationMins); const reset = @@ -362,8 +368,11 @@ function AllowanceWindowRow(props: { {hasUsage ? ( ) : null} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 1f461f6ed409..173ba9c96607 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -106,7 +106,9 @@ function HistoricalUsagePage({ return (
- +
+ +

{formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)} @@ -496,7 +498,9 @@ function SubscriptionUsagePage({ return (

- +
+ +

Subscription allowance

@@ -609,8 +613,11 @@ function SubscriptionAllowanceCard({ group }: { readonly group: SubscriptionAllo {hasUsage ? (
) : null} From c5ec2f1b4c30994f652644333d2845196aba5f61 Mon Sep 17 00:00:00 2001 From: naveed949 Date: Wed, 12 Aug 2026 18:15:58 +0500 Subject: [PATCH 10/29] fix(usage): simplify subscription allowance flow --- .../src/features/usage/UsageRouteScreen.tsx | 69 +++++-- .../src/features/usage/usageAllowance.test.ts | 53 ++++- .../src/features/usage/usageAllowance.ts | 182 ---------------- .../src/features/usage/usageView.test.ts | 6 +- apps/mobile/src/features/usage/usageView.ts | 23 --- apps/server/src/auth/RpcAuthorization.ts | 1 - apps/server/src/server.test.ts | 67 +----- .../SubscriptionAllowanceService.test.ts | 184 +++++++++++------ .../src/usage/SubscriptionAllowanceService.ts | 115 +++++------ apps/server/src/ws.ts | 4 - .../src/components/usage/UsagePage.test.tsx | 6 +- apps/web/src/components/usage/UsagePage.tsx | 157 +++++++------- .../components/usage/usageAllowance.test.ts | 2 +- .../src/components/usage/usageAllowance.ts | 64 ------ .../src/state/subscriptionAllowance.test.ts | 41 ---- apps/web/src/state/subscriptionAllowance.ts | 28 --- docs/internals/subscription-allowance.md | 10 +- .../src/state/subscriptionAllowance.ts | 195 ++++++++++++++++++ packages/contracts/src/rpc.ts | 11 - packages/contracts/src/usage.test.ts | 12 +- 20 files changed, 549 insertions(+), 681 deletions(-) delete mode 100644 apps/mobile/src/features/usage/usageAllowance.ts delete mode 100644 apps/mobile/src/features/usage/usageView.ts delete mode 100644 apps/web/src/components/usage/usageAllowance.ts delete mode 100644 apps/web/src/state/subscriptionAllowance.test.ts diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 66522eb236dd..92c3d423e5e7 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,4 +1,18 @@ import { useIsFocused, useNavigation } from "@react-navigation/native"; +import { + DEFAULT_USAGE_VIEW, + formatAllowanceDuration, + formatAllowanceEnvironmentNotice, + formatAllowanceResetAt, + formatAllowanceUpdatedAt, + formatAllowanceWindowScope, + presentSubscriptionAllowanceGroup, + progressWidthForAllowance, + subscriptionViewPhase, + USAGE_VIEW_OPTIONS, + type SubscriptionAllowanceCardModel, + type UsageView, +} from "@t3tools/client-runtime/state/subscription-allowance"; import type { DailyTotals, MergedUsage } from "@t3tools/shared/usageMerge"; import { enumerateDays, @@ -30,23 +44,7 @@ import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; import { SettingsSection } from "../settings/components/SettingsSection"; import { UsageDailyChart } from "./UsageDailyChart"; import type { UsageChartMetric } from "./usageChartData"; -import { - formatAllowanceDuration, - formatAllowanceEnvironmentNotice, - formatAllowanceResetAt, - formatAllowanceUpdatedAt, - formatAllowanceWindowScope, - presentAllowanceGroup, - progressWidthForAllowance, - type MobileAllowanceCardModel, -} from "./usageAllowance"; import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; -import { - DEFAULT_USAGE_VIEW, - subscriptionViewPhase, - USAGE_VIEW_OPTIONS, - type UsageView, -} from "./usageView"; const WINDOW_OPTIONS = [ { days: 1, label: "Past 24h" }, @@ -315,7 +313,10 @@ function SubscriptionUsageContent(props: { ) : null} {groups.map((group) => ( - + ))} @@ -324,7 +325,7 @@ function SubscriptionUsageContent(props: { ); } -function SubscriptionAllowanceCard(props: { readonly model: MobileAllowanceCardModel }) { +function SubscriptionAllowanceCard(props: { readonly model: SubscriptionAllowanceCardModel }) { const { model } = props; const updatedAt = formatAllowanceUpdatedAt(model.updatedAt); const providerColors = useProviderColors(); @@ -334,7 +335,7 @@ function SubscriptionAllowanceCard(props: { readonly model: MobileAllowanceCardM - {model.providerLabel} + {PROVIDER_LABEL[model.provider]} {model.accountLabel !== null ? ( @@ -389,7 +390,7 @@ function SubscriptionAllowanceCard(props: { readonly model: MobileAllowanceCardM } function AllowanceWindowRow(props: { - readonly window: MobileAllowanceCardModel["windows"][number]; + readonly window: SubscriptionAllowanceCardModel["windows"][number]; readonly progressColor: string; }) { const { window, progressColor } = props; @@ -430,9 +431,10 @@ function AllowanceWindowRow(props: { ); } -function AllowanceMetadata(props: { readonly model: MobileAllowanceCardModel }) { +function AllowanceMetadata(props: { readonly model: SubscriptionAllowanceCardModel }) { const { model } = props; const credits = model.credits; + const spendingControl = model.spendingControl; const extraUsage = model.extraUsage; return ( @@ -449,6 +451,29 @@ function AllowanceMetadata(props: { readonly model: MobileAllowanceCardModel }) ) : null} + {spendingControl !== null ? ( + + Spending control + {spendingControl.limit !== undefined && spendingControl.limit !== null ? ( + Limit {spendingControl.limit} + ) : null} + {spendingControl.used !== undefined && spendingControl.used !== null ? ( + Used {spendingControl.used} + ) : null} + {spendingControl.remainingPercent !== undefined && + spendingControl.remainingPercent !== null ? ( + + {spendingControl.remainingPercent}% remaining + + ) : null} + {spendingControl.reached !== undefined && spendingControl.reached !== null ? ( + + {spendingControl.reached ? "Limit reached" : "Limit not reached"} + + ) : null} + + ) : null} + {extraUsage !== null ? ( Extra usage @@ -478,7 +503,7 @@ function AllowanceMetadata(props: { readonly model: MobileAllowanceCardModel }) } function AllowanceSources(props: { - readonly sources: readonly MobileAllowanceCardModel["sources"][number][]; + readonly sources: readonly SubscriptionAllowanceCardModel["sources"][number][]; }) { return ( diff --git a/apps/mobile/src/features/usage/usageAllowance.test.ts b/apps/mobile/src/features/usage/usageAllowance.test.ts index a6a8211a67ec..86826b62f594 100644 --- a/apps/mobile/src/features/usage/usageAllowance.test.ts +++ b/apps/mobile/src/features/usage/usageAllowance.test.ts @@ -1,14 +1,13 @@ -import type { SubscriptionAllowanceGroup } from "@t3tools/client-runtime/state/subscription-allowance"; -import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - import { formatAllowanceEnvironmentNotice, formatAllowanceUpdatedAt, formatAllowanceWindowScope, - presentAllowanceGroup, + presentSubscriptionAllowanceGroup, progressWidthForAllowance, -} from "./usageAllowance"; + type SubscriptionAllowanceGroup, +} from "@t3tools/client-runtime/state/subscription-allowance"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; const readAt = "2026-08-11T12:00:00.000Z"; @@ -41,15 +40,41 @@ function group(overrides: Partial = {}): Subscriptio describe("mobile subscription allowance presentation", () => { it("keeps provider-native windows and optional values intact", () => { - const model = presentAllowanceGroup( + const source = group().effectiveSource!; + const model = presentSubscriptionAllowanceGroup( group({ accountLabel: "n•••@example.com", + sources: [ + { + ...source, + allowance: { + ...source.allowance, + spendingControl: { + reached: false, + limit: "100", + remainingPercent: 75, + used: "25", + }, + }, + }, + ], + effectiveSource: { + ...source, + allowance: { + ...source.allowance, + spendingControl: { + reached: false, + limit: "100", + remainingPercent: 75, + used: "25", + }, + }, + }, }), ); expect(model).toMatchObject({ provider: "codex", - providerLabel: "Codex", accountLabel: "n•••@example.com", status: "available", freshness: "fresh", @@ -62,6 +87,12 @@ describe("mobile subscription allowance presentation", () => { resetsAt: readAt, }, ], + spendingControl: { + reached: false, + limit: "100", + remainingPercent: 75, + used: "25", + }, }); }); @@ -80,7 +111,7 @@ describe("mobile subscription allowance presentation", () => { }, }; - const model = presentAllowanceGroup( + const model = presentSubscriptionAllowanceGroup( group({ provider: "claude", status: "unavailable", @@ -107,7 +138,7 @@ describe("mobile subscription allowance presentation", () => { }, }; - const model = presentAllowanceGroup( + const model = presentSubscriptionAllowanceGroup( group({ sources: [staleSource], effectiveSource: staleSource, @@ -133,7 +164,7 @@ describe("mobile subscription allowance presentation", () => { }, }; - const model = presentAllowanceGroup( + const model = presentSubscriptionAllowanceGroup( group({ sources: [source, secondSource], effectiveSource: source, diff --git a/apps/mobile/src/features/usage/usageAllowance.ts b/apps/mobile/src/features/usage/usageAllowance.ts deleted file mode 100644 index 3471c88de9d7..000000000000 --- a/apps/mobile/src/features/usage/usageAllowance.ts +++ /dev/null @@ -1,182 +0,0 @@ -import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; -import type { SubscriptionAllowanceGroup } from "@t3tools/client-runtime/state/subscription-allowance"; -import type { - SubscriptionAllowance, - SubscriptionAllowanceProviderKind, - SubscriptionAllowanceWindow, -} from "@t3tools/contracts"; -import { - isSubscriptionAllowanceSourceCurrent, - SUBSCRIPTION_ALLOWANCE_COMPATIBILITY_MESSAGE, -} from "@t3tools/client-runtime/state/subscription-allowance"; - -import { PROVIDER_LABEL } from "./usageProviderLabels"; - -export interface MobileAllowanceSourceModel { - readonly key: string; - readonly environmentLabel: string; - readonly instanceId: string; - readonly connectionLabel: string; - readonly status: SubscriptionAllowance["status"]; - readonly freshness: "fresh" | "stale"; - readonly isCurrent: boolean; - readonly isEffective: boolean; -} - -export interface MobileAllowanceCardModel { - readonly key: string; - readonly provider: SubscriptionAllowanceProviderKind; - readonly providerLabel: string; - readonly accountLabel: string | null; - readonly status: SubscriptionAllowance["status"]; - readonly message: string; - readonly freshness: "fresh" | "stale"; - readonly updatedAt: string | null; - readonly windows: readonly SubscriptionAllowanceWindow[]; - readonly credits: NonNullable | null; - readonly extraUsage: NonNullable | null; - readonly hasMultipleReadings: boolean; - readonly sources: readonly MobileAllowanceSourceModel[]; -} - -export function formatAllowanceWindowScope(scope: string): string { - switch (scope) { - case "primary": - return "Primary limit"; - case "secondary": - return "Secondary limit"; - case "five_hour": - return "5-hour limit"; - case "seven_day": - return "7-day limit"; - case "seven_day_oauth_apps": - return "7-day OAuth apps limit"; - case "seven_day_opus": - return "7-day Opus limit"; - case "seven_day_sonnet": - return "7-day Sonnet limit"; - default: - return scope; - } -} - -export function formatAllowanceDuration(minutes: number | null | undefined): string | null { - if (minutes === null || minutes === undefined) return null; - if (minutes % (24 * 60) === 0) { - const days = minutes / (24 * 60); - return `${days} ${days === 1 ? "day" : "days"}`; - } - if (minutes % 60 === 0) { - const hours = minutes / 60; - return `${hours} ${hours === 1 ? "hour" : "hours"}`; - } - return `${minutes} minutes`; -} - -export function progressWidthForAllowance(usedPercent: number): number { - return Math.min(100, Math.max(0, usedPercent)); -} - -export function formatAllowanceResetAt(resetsAt: string): string { - return new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", - }).format(new Date(resetsAt)); -} - -export function formatAllowanceUpdatedAt( - updatedAt: string | null | undefined, - now = Date.now(), -): string | null { - if (updatedAt === null || updatedAt === undefined) return null; - const date = new Date(updatedAt); - if (Number.isNaN(date.getTime())) return null; - - const elapsedMinutes = Math.max(0, Math.floor((now - date.getTime()) / 60_000)); - if (elapsedMinutes < 1) return "Updated just now"; - if (elapsedMinutes < 60) return `Updated ${elapsedMinutes}m ago`; - - const elapsedHours = Math.floor(elapsedMinutes / 60); - if (elapsedHours < 24) return `Updated ${elapsedHours}h ago`; - - return `Updated ${Math.floor(elapsedHours / 24)}d ago`; -} - -export function formatAllowanceConnectionPhase(phase: EnvironmentConnectionPhase): string { - switch (phase) { - case "available": - return "Available"; - case "offline": - return "Offline"; - case "connecting": - return "Connecting"; - case "reconnecting": - return "Reconnecting"; - case "connected": - return "Connected"; - case "error": - return "Connection failed"; - } -} - -export function formatAllowanceUnavailableMessage( - provider: SubscriptionAllowanceProviderKind, - message: string | undefined, -): string { - if (message !== undefined) return message; - return provider === "claude" - ? "Claude subscription usage is unavailable. Claude did not provide usage limits." - : "Subscription usage is unavailable."; -} - -export function formatAllowanceEnvironmentNotice(environment: { - readonly label: string; - readonly connectionPhase: EnvironmentConnectionPhase; - readonly compatibility?: boolean; - readonly error: string | null; - readonly snapshot: unknown; -}): string | null { - if (environment.compatibility === true) { - return `${environment.label}: ${SUBSCRIPTION_ALLOWANCE_COMPATIBILITY_MESSAGE}`; - } - if (environment.error !== null) { - return `${environment.label} could not report subscription usage.`; - } - if (environment.snapshot === null && environment.connectionPhase !== "connected") { - return `${environment.label} is ${formatAllowanceConnectionPhase(environment.connectionPhase).toLowerCase()}; subscription usage will return when it reconnects.`; - } - return null; -} - -export function presentAllowanceGroup(group: SubscriptionAllowanceGroup): MobileAllowanceCardModel { - const displayedSource = group.effectiveSource ?? group.sources[0]; - if (displayedSource === undefined) { - throw new Error("Subscription allowance groups must contain a source"); - } - - const allowance = displayedSource.allowance; - return { - key: group.key, - provider: allowance.provider, - providerLabel: PROVIDER_LABEL[allowance.provider], - accountLabel: group.accountLabel, - status: allowance.status, - message: formatAllowanceUnavailableMessage(allowance.provider, allowance.message), - freshness: allowance.freshness ?? "fresh", - updatedAt: allowance.updatedAt ?? null, - windows: allowance.windows, - credits: allowance.credits ?? null, - extraUsage: allowance.extraUsage ?? null, - hasMultipleReadings: group.hasMultipleReadings, - sources: group.sources.map((source, index) => ({ - key: `${source.environmentId}:${source.allowance.instanceId}:${index}`, - environmentLabel: source.environmentLabel, - instanceId: source.allowance.instanceId, - connectionLabel: formatAllowanceConnectionPhase(source.connectionPhase), - status: source.allowance.status, - freshness: source.allowance.freshness ?? "fresh", - isCurrent: isSubscriptionAllowanceSourceCurrent(source), - isEffective: source === displayedSource, - })), - }; -} diff --git a/apps/mobile/src/features/usage/usageView.test.ts b/apps/mobile/src/features/usage/usageView.test.ts index 70992282923f..504eb6fe1f8f 100644 --- a/apps/mobile/src/features/usage/usageView.test.ts +++ b/apps/mobile/src/features/usage/usageView.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { DEFAULT_USAGE_VIEW, subscriptionViewPhase, USAGE_VIEW_OPTIONS } from "./usageView"; +import { + DEFAULT_USAGE_VIEW, + subscriptionViewPhase, + USAGE_VIEW_OPTIONS, +} from "@t3tools/client-runtime/state/subscription-allowance"; describe("mobile usage view state", () => { it("opens on the Subscription view", () => { diff --git a/apps/mobile/src/features/usage/usageView.ts b/apps/mobile/src/features/usage/usageView.ts deleted file mode 100644 index 4c0aeed9230c..000000000000 --- a/apps/mobile/src/features/usage/usageView.ts +++ /dev/null @@ -1,23 +0,0 @@ -export type UsageView = "subscription" | "historical"; - -export const DEFAULT_USAGE_VIEW: UsageView = "subscription"; - -export const USAGE_VIEW_OPTIONS = [ - { value: "subscription" as const, label: "Subscription" }, - { value: "historical" as const, label: "Historical" }, -] as const; - -export type SubscriptionViewPhase = "loading" | "partial" | "ready"; - -/** - * A partial projection can still render the sources that already answered. - * Only an empty first response needs the blocking loading state. - */ -export function subscriptionViewPhase(input: { - readonly isPending: boolean; - readonly isPartial: boolean; - readonly groupCount: number; -}): SubscriptionViewPhase { - if (input.isPending || (input.isPartial && input.groupCount === 0)) return "loading"; - return input.isPartial ? "partial" : "ready"; -} diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index a28e2f0649ed..b95e456f28c8 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -46,7 +46,6 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, - [WS_METHODS.serverGetSubscriptionAllowance]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshSubscriptionAllowance]: AuthOrchestrationReadScope, [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 43917f4abef9..0df348d4bbda 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -833,7 +833,11 @@ const buildAppUnderTest = (options?: { Layer.provide(UsageService.layerTest), Layer.provide( Layer.mock(SubscriptionAllowanceService.SubscriptionAllowanceService)({ - read: Effect.succeed({ + subscribe: Effect.succeed({ + latest: { readAt: "1970-01-01T00:00:00.000Z", allowances: [] }, + changes: Stream.empty, + }), + refresh: Effect.succeed({ readAt: "1970-01-01T00:00:00.000Z", allowances: [], }), @@ -4024,67 +4028,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("routes authenticated websocket rpc server.getSubscriptionAllowance", () => - Effect.gen(function* () { - const snapshot = { - readAt: "2026-08-11T12:00:00.000Z", - allowances: [ - { - provider: "codex" as const, - instanceId: ProviderInstanceId.make("codex"), - status: "available" as const, - windows: [{ scope: "primary" as const, usedPercent: 23 }], - }, - ], - }; - yield* buildAppUnderTest({ - layers: { - subscriptionAllowance: { read: Effect.succeed(snapshot) }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const response = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetSubscriptionAllowance]({})), - ); - - assert.deepEqual(response, snapshot); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("requires orchestration read scope for server.getSubscriptionAllowance", () => - Effect.gen(function* () { - yield* buildAppUnderTest(); - - const { response: tokenResponse, body: tokenBody } = yield* exchangeAccessToken( - defaultDesktopBootstrapToken, - { scope: "access:write" }, - ); - assert.equal(tokenResponse.status, 200); - assert.isDefined(tokenBody.access_token); - - const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { - headers: { - authorization: `Bearer ${tokenBody.access_token ?? ""}`, - }, - }); - const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string }; - assert.equal(wsTicketResponse.status, 200); - - const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; - const rpcError = yield* Effect.flip( - Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetSubscriptionAllowance]({})), - ), - ); - - assert.equal(rpcError._tag, "EnvironmentAuthorizationError"); - if (rpcError._tag === "EnvironmentAuthorizationError") { - assert.equal(rpcError.requiredScope, "orchestration:read"); - } - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("requires orchestration read scope for server.refreshSubscriptionAllowance", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/usage/SubscriptionAllowanceService.test.ts b/apps/server/src/usage/SubscriptionAllowanceService.test.ts index 96daea5b8667..56e1b278420b 100644 --- a/apps/server/src/usage/SubscriptionAllowanceService.test.ts +++ b/apps/server/src/usage/SubscriptionAllowanceService.test.ts @@ -4,24 +4,21 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ProviderInstanceId } from "@t3tools/contracts"; -import { - CLAUDE_SUBSCRIPTION_ALLOWANCE_UNAVAILABLE_MESSAGE, - ProviderAllowanceReadError, - type ProviderAllowanceReader, -} from "../provider/Services/ProviderAllowanceReader.ts"; +import type { ProviderAllowanceReader } from "../provider/Services/ProviderAllowanceReader.ts"; import { ProviderInstanceRegistry } from "../provider/Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import { foldSubscriptionAllowance, make, markSubscriptionAllowanceSnapshotStale, - readSubscriptionAllowances, type SubscriptionAllowanceProviderInstance, } from "./SubscriptionAllowanceService.ts"; @@ -64,67 +61,6 @@ const registryLayerFor = (providerInstance: SubscriptionAllowanceProviderInstanc ), }); -describe("readSubscriptionAllowances", () => { - it.effect("omits disabled instances and contains an unavailable receipt for a failed read", () => - Effect.gen(function* () { - const snapshot = yield* readSubscriptionAllowances( - [ - instance({ allowanceReader: reader(Effect.succeed(allowance)) }), - instance({ enabled: false, allowanceReader: reader(Effect.succeed(allowance)) }), - instance({ - instanceId: ProviderInstanceId.make("codex-failing"), - allowanceReader: reader( - Effect.fail(new ProviderAllowanceReadError({ detail: "provider failure" })), - ), - }), - ], - readAt, - ); - - expect(snapshot).toEqual({ - readAt, - allowances: [ - allowance, - { - provider: "codex", - instanceId: ProviderInstanceId.make("codex-failing"), - status: "unavailable", - windows: [], - message: "Codex subscription usage is unavailable.", - }, - ], - }); - }), - ); - - it.effect("keeps the Claude unavailable presentation stable when acquisition fails", () => - Effect.gen(function* () { - const snapshot = yield* readSubscriptionAllowances( - [ - instance({ - instanceId: ProviderInstanceId.make("claude"), - allowanceReader: reader( - Effect.fail(new ProviderAllowanceReadError({ detail: "provider failure" })), - "claude", - ), - }), - ], - readAt, - ); - - expect(snapshot.allowances).toEqual([ - { - provider: "claude", - instanceId: ProviderInstanceId.make("claude"), - status: "unavailable", - windows: [], - message: CLAUDE_SUBSCRIPTION_ALLOWANCE_UNAVAILABLE_MESSAGE, - }, - ]); - }), - ); -}); - describe("subscription allowance lifecycle helpers", () => { it("folds sparse windows without dropping a previously complete record", () => { const previous = { @@ -194,6 +130,51 @@ describe("subscription allowance lifecycle helpers", () => { }); describe("SubscriptionAllowanceService", () => { + it.effect("publishes complete snapshot provenance through the subscription seam", () => + Effect.gen(function* () { + const providerInstance = instance({ + allowanceReader: reader(Effect.succeed(allowance)), + }); + const service = yield* make.pipe(Effect.provide(registryLayerFor(providerInstance))); + + yield* Effect.scoped( + Effect.gen(function* () { + const subscription = yield* service.subscribe; + const published = yield* Stream.runHead(subscription.changes); + + expect(Option.getOrThrow(published).allowances[0]).toMatchObject({ + completeness: "complete", + observationSource: "snapshot", + deliverySource: "live", + }); + }), + ); + }), + ); + + it.effect("identifies a retained reconnect snapshot as cache delivery", () => + Effect.gen(function* () { + const providerInstance = instance({ + allowanceReader: reader(Effect.succeed(allowance)), + }); + const service = yield* make.pipe(Effect.provide(registryLayerFor(providerInstance))); + + yield* Effect.scoped( + Effect.gen(function* () { + const subscription = yield* service.subscribe; + yield* Stream.runHead(subscription.changes); + }), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const subscription = yield* service.subscribe; + expect(subscription.latest.allowances[0]?.deliverySource).toBe("cache"); + }), + ); + }), + ); + it.effect("starts one demand-scoped acquisition and shares concurrent refreshes", () => Effect.gen(function* () { const readStarted = yield* Deferred.make(); @@ -231,6 +212,75 @@ describe("SubscriptionAllowanceService", () => { }), ); + it.effect("acquires a replacement provider generation immediately", () => + Effect.gen(function* () { + const oldReadStarted = yield* Deferred.make(); + const releaseOldRead = yield* Deferred.make(); + const registryObservedReplacement = yield* Deferred.make(); + const newReadStarted = yield* Deferred.make(); + const changes = yield* PubSub.unbounded(); + const oldInstance = instance({ + allowanceReader: reader( + Effect.gen(function* () { + yield* Deferred.succeed(oldReadStarted, undefined); + yield* Deferred.await(releaseOldRead); + return allowance; + }), + ), + }) as ProviderInstance; + const replacementAllowance = { + ...allowance, + windows: [{ scope: "primary" as const, usedPercent: 90 }], + }; + const newInstance = instance({ + allowanceReader: reader( + Effect.gen(function* () { + yield* Deferred.succeed(newReadStarted, undefined); + return replacementAllowance; + }), + ), + }) as ProviderInstance; + const instances = yield* Ref.make>([oldInstance]); + const registryLayer = Layer.succeed(ProviderInstanceRegistry, { + getInstance: () => Effect.succeed(undefined), + listInstances: Ref.get(instances).pipe( + Effect.tap((current) => + current[0] === newInstance + ? Deferred.succeed(registryObservedReplacement, undefined) + : Effect.void, + ), + ), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.fromPubSub(changes), + subscribeChanges: PubSub.subscribe(changes), + }); + const service = yield* make.pipe(Effect.provide(registryLayer)); + + yield* Effect.scoped( + Effect.gen(function* () { + const subscription = yield* service.subscribe; + yield* Deferred.await(oldReadStarted); + const replacementSnapshot = yield* subscription.changes.pipe( + Stream.filter((snapshot) => + snapshot.allowances.some((item) => item.windows[0]?.usedPercent === 90), + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + + yield* Ref.set(instances, [newInstance]); + yield* PubSub.publish(changes, undefined); + yield* Deferred.await(registryObservedReplacement); + yield* Deferred.succeed(releaseOldRead, undefined); + const published = Option.getOrThrow(yield* Fiber.join(replacementSnapshot)); + + expect(Option.isSome(yield* Deferred.poll(newReadStarted))).toBe(true); + expect(published.allowances[0]?.windows[0]?.usedPercent).toBe(90); + }), + ); + }), + ); + it.effect("refreshes on the controllable interval and stops after demand teardown", () => Effect.gen(function* () { const automaticRefresh = yield* Deferred.make(); diff --git a/apps/server/src/usage/SubscriptionAllowanceService.ts b/apps/server/src/usage/SubscriptionAllowanceService.ts index 6e93ebc72759..dc0707839b0b 100644 --- a/apps/server/src/usage/SubscriptionAllowanceService.ts +++ b/apps/server/src/usage/SubscriptionAllowanceService.ts @@ -72,9 +72,15 @@ const unavailableMessage = (provider: ProviderAllowanceReader["provider"]): stri const isUsableAllowance = (allowance: SubscriptionAllowance | undefined): boolean => allowance?.status === "available"; -const markFresh = (allowance: SubscriptionAllowance, updatedAt: string): SubscriptionAllowance => ({ +const markFreshSnapshot = ( + allowance: SubscriptionAllowance, + updatedAt: string, +): SubscriptionAllowance => ({ ...allowance, freshness: "fresh", + completeness: "complete", + observationSource: "snapshot", + deliverySource: "live", updatedAt, }); @@ -83,6 +89,16 @@ const markStale = (allowance: SubscriptionAllowance): SubscriptionAllowance => ( freshness: "stale", }); +const markSnapshotDeliveredFromCache = ( + snapshot: SubscriptionAllowanceSnapshot, +): SubscriptionAllowanceSnapshot => ({ + ...snapshot, + allowances: snapshot.allowances.map((allowance) => ({ + ...allowance, + deliverySource: "cache", + })), +}); + const hasPassedReset = ( allowance: SubscriptionAllowance, snapshotReadAt: string, @@ -139,56 +155,9 @@ export function foldSubscriptionAllowance( }; } -/** - * Reads the enabled materialized provider readers in settings order. A broken - * provider read becomes an explicit unavailable allowance so one provider - * cannot turn a multi-provider snapshot into a misleading empty response. - */ -export const readSubscriptionAllowances = Effect.fn("readSubscriptionAllowances")(function* ( - instances: ReadonlyArray, - readAt: string, -) { - const allowances = yield* Effect.forEach( - instances.filter( - (instance): instance is ReadableSubscriptionAllowanceProviderInstance => - instance.enabled && instance.allowanceReader !== undefined, - ), - (instance) => { - const reader = instance.allowanceReader; - return reader.read.pipe( - Effect.catchCause(() => - Effect.logWarning("Provider allowance read failed", { - provider: reader.provider, - instanceId: instance.instanceId, - }).pipe( - Effect.andThen( - Effect.succeed( - unavailableAllowance({ - provider: reader.provider, - instanceId: instance.instanceId, - message: unavailableMessage(reader.provider), - }), - ), - ), - ), - ), - ); - }, - ); - - return { - readAt, - allowances, - } satisfies SubscriptionAllowanceSnapshot; -}); - export class SubscriptionAllowanceService extends Context.Service< SubscriptionAllowanceService, { - /** One-shot compatibility read. It also updates the server-owned cache. */ - readonly read: Effect.Effect; - readonly latest: Effect.Effect; - readonly changes: Stream.Stream; readonly subscribe: Effect.Effect< { readonly latest: SubscriptionAllowanceSnapshot; @@ -255,13 +224,13 @@ export const make = Effect.gen(function* () { Effect.result, Effect.map((result) => { if (Result.isSuccess(result)) { - return markFresh(result.success, readAt); + return markFreshSnapshot(result.success, readAt); } const previousAllowance = previousByInstance.get(instance.instanceId); return previousAllowance !== undefined && isUsableAllowance(previousAllowance) ? markStale(previousAllowance) - : markFresh( + : markFreshSnapshot( unavailableAllowance({ provider: instance.allowanceReader.provider, instanceId: instance.instanceId, @@ -357,6 +326,25 @@ export const make = Effect.gen(function* () { }), ); + const refreshAfterRegistryChange = Effect.gen(function* () { + yield* refresh; + const needsReplacementRead = yield* stateMutex.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(state); + const publishedInstanceIds = new Set( + current.snapshot.allowances.map((allowance) => allowance.instanceId), + ); + return Array.from(current.instances.values()).some( + (instance) => + instance.enabled && + instance.allowanceReader !== undefined && + !publishedInstanceIds.has(instance.instanceId), + ); + }), + ); + if (needsReplacementRead) yield* refresh; + }); + const handleProviderUpdate = (event: ProviderRuntimeEvent) => stateMutex.withPermits(1)( Effect.gen(function* () { @@ -381,7 +369,14 @@ export const make = Effect.gen(function* () { return; } const updatedAt = event.createdAt; - const folded = markFresh(foldSubscriptionAllowance(previous, update), updatedAt); + const folded = { + ...foldSubscriptionAllowance(previous, update), + freshness: "fresh" as const, + completeness: "complete" as const, + observationSource: "liveUpdate" as const, + deliverySource: "live" as const, + updatedAt, + }; const allowances = current.snapshot.allowances.some( (allowance) => allowance.instanceId === instanceId, ) @@ -418,7 +413,7 @@ export const make = Effect.gen(function* () { }), ); if (Option.isSome(refreshScope)) { - yield* refresh.pipe(Effect.ignore, Effect.forkIn(refreshScope.value)); + yield* refreshAfterRegistryChange.pipe(Effect.ignore, Effect.forkIn(refreshScope.value)); } }); @@ -479,13 +474,6 @@ export const make = Effect.gen(function* () { }), ); - const read = Effect.scoped( - Effect.gen(function* () { - yield* Effect.acquireRelease(acquireDemand, () => releaseDemand); - return yield* refresh; - }), - ); - const latestUnlocked = Effect.gen(function* () { const current = yield* Ref.get(state); const nextSnapshot = markSubscriptionAllowanceSnapshotStale( @@ -497,20 +485,16 @@ export const make = Effect.gen(function* () { } return nextSnapshot; }); - const latest = stateMutex.withPermits(1)(latestUnlocked); const subscribe = subscribeBeforeSnapshot( changes, Effect.gen(function* () { yield* Effect.acquireRelease(acquireDemand, () => releaseDemand); - return yield* latestUnlocked; + return markSnapshotDeliveredFromCache(yield* latestUnlocked); }), stateMutex, ); return SubscriptionAllowanceService.of({ - read, - latest, - changes: Stream.fromPubSub(changes), subscribe, refresh, }); @@ -526,9 +510,6 @@ const emptySnapshot: SubscriptionAllowanceSnapshot = { export const layerTest = Layer.succeed( SubscriptionAllowanceService, SubscriptionAllowanceService.of({ - read: Effect.succeed(emptySnapshot), - latest: Effect.succeed(emptySnapshot), - changes: Stream.empty, subscribe: Effect.succeed({ latest: emptySnapshot, changes: Stream.empty }), refresh: Effect.succeed(emptySnapshot), }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index b8fdfd33f1b5..428eb554b381 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1570,10 +1570,6 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetUsageSummary, usage.readSummary(input), { "rpc.aggregate": "server", }), - [WS_METHODS.serverGetSubscriptionAllowance]: (_input) => - observeRpcEffect(WS_METHODS.serverGetSubscriptionAllowance, subscriptionAllowance.read, { - "rpc.aggregate": "server", - }), [WS_METHODS.serverRefreshSubscriptionAllowance]: (_input) => observeRpcEffect( WS_METHODS.serverRefreshSubscriptionAllowance, diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 0db7389f498e..46d36733ea0d 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -1,7 +1,11 @@ import { Children, type ReactElement, type ReactNode } from "react"; import { describe, expect, it } from "vite-plus/test"; +import { + DEFAULT_USAGE_VIEW, + type UsageView, +} from "@t3tools/client-runtime/state/subscription-allowance"; -import { DEFAULT_USAGE_VIEW, UsageViewTabs, type UsageView } from "./UsagePage"; +import { UsageViewTabs } from "./UsagePage"; describe("UsagePage defaults", () => { it("opens on the Subscription view", () => { diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 65661c69bbe8..649bb5d42df0 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -1,5 +1,18 @@ -import type { SubscriptionAllowanceGroup } from "@t3tools/client-runtime/state/subscription-allowance"; -import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { + DEFAULT_USAGE_VIEW, + formatAllowanceConnectionPhase, + formatAllowanceDuration, + formatAllowanceEnvironmentNotice, + formatAllowanceResetAt, + formatAllowanceUpdatedAt, + formatAllowanceWindowScope, + presentSubscriptionAllowanceGroup, + progressWidthForAllowance, + subscriptionViewPhase, + USAGE_VIEW_OPTIONS, + type SubscriptionAllowanceCardModel, + type UsageView, +} from "@t3tools/client-runtime/state/subscription-allowance"; import type { UsageProviderKind } from "@t3tools/contracts"; import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"; import type { ReactNode } from "react"; @@ -9,10 +22,6 @@ import { isElectron } from "../../env"; import { cn } from "../../lib/utils"; import { useSubscriptionAllowance } from "../../state/subscriptionAllowance"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; -import { - isSubscriptionAllowanceSourceCurrent, - SUBSCRIPTION_ALLOWANCE_COMPATIBILITY_MESSAGE, -} from "@t3tools/client-runtime/state/subscription-allowance"; import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; import { enumerateDays, @@ -31,13 +40,6 @@ import { SidebarInset } from "../ui/sidebar"; import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; -import { - formatAllowanceDuration, - formatAllowanceResetAt, - formatAllowanceUpdatedAt, - formatAllowanceWindowScope, - progressWidthForAllowance, -} from "./usageAllowance"; import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; const WINDOW_OPTIONS = [ @@ -47,18 +49,6 @@ const WINDOW_OPTIONS = [ { days: 90, label: "90 days" }, ] as const; -export type UsageView = "subscription" | "historical"; - -export const USAGE_VIEW_OPTIONS: ReadonlyArray<{ - readonly value: UsageView; - readonly label: string; -}> = [ - { value: "subscription", label: "Subscription" }, - { value: "historical", label: "Historical" }, -]; - -export const DEFAULT_USAGE_VIEW: UsageView = "subscription"; - export function UsagePage() { const [view, setView] = useState(DEFAULT_USAGE_VIEW); @@ -559,33 +549,14 @@ function SubscriptionUsagePage({ const { groups, environments, isPending, isPartial, isRefreshing, refresh } = useSubscriptionAllowance(); const environmentNotices = environments.flatMap((environment) => { - if (environment.compatibility) { - return [ - { - environmentId: environment.environmentId, - message: `${environment.label}: ${SUBSCRIPTION_ALLOWANCE_COMPATIBILITY_MESSAGE}`, - }, - ]; - } - if (environment.error !== null) { - return [ - { - environmentId: environment.environmentId, - message: `${environment.label} could not report subscription usage.`, - }, - ]; - } - if (environment.snapshot === null && environment.connectionPhase !== "connected") { - return [ - { - environmentId: environment.environmentId, - message: `${environment.label} is ${formatAllowanceConnectionPhase(environment.connectionPhase).toLowerCase()}; subscription usage will return when it reconnects.`, - }, - ]; - } - return []; + const message = formatAllowanceEnvironmentNotice(environment); + return message === null ? [] : [{ environmentId: environment.environmentId, message }]; + }); + const phase = subscriptionViewPhase({ + isPending, + isPartial, + groupCount: groups.length, }); - const settling = isPending || isPartial; return ( @@ -612,7 +583,7 @@ function SubscriptionUsagePage({
- {settling ? ( + {phase === "loading" ? (
Reading provider allowance…
@@ -635,14 +606,17 @@ function SubscriptionUsagePage({ ))}
) : null} - {isPartial ? ( + {phase === "partial" ? (

Some environments are still reporting.

) : null}
{groups.map((group) => ( - + ))}
@@ -652,9 +626,11 @@ function SubscriptionUsagePage({ ); } -function SubscriptionAllowanceCard({ group }: { readonly group: SubscriptionAllowanceGroup }) { - const displayedSource = group.effectiveSource ?? group.sources[0]!; - const { allowance } = displayedSource; +function SubscriptionAllowanceCard({ + model: allowance, +}: { + readonly model: SubscriptionAllowanceCardModel; +}) { const updatedAt = formatAllowanceUpdatedAt(allowance.updatedAt); return ( @@ -663,8 +639,8 @@ function SubscriptionAllowanceCard({ group }: { readonly group: SubscriptionAllo

{PROVIDER_LABEL[allowance.provider]} - {group.accountLabel !== null ? ( - {group.accountLabel} + {allowance.accountLabel !== null ? ( + {allowance.accountLabel} ) : null}

@@ -741,6 +717,30 @@ function SubscriptionAllowanceCard({ group }: { readonly group: SubscriptionAllo
) : null} + {allowance.spendingControl !== undefined && allowance.spendingControl !== null ? ( +
+ Spending control + {allowance.spendingControl.limit !== undefined && + allowance.spendingControl.limit !== null ? ( + Limit {allowance.spendingControl.limit} + ) : null} + {allowance.spendingControl.used !== undefined && + allowance.spendingControl.used !== null ? ( + Used {allowance.spendingControl.used} + ) : null} + {allowance.spendingControl.remainingPercent !== undefined && + allowance.spendingControl.remainingPercent !== null ? ( + {allowance.spendingControl.remainingPercent}% remaining + ) : null} + {allowance.spendingControl.reached !== undefined && + allowance.spendingControl.reached !== null ? ( + + {allowance.spendingControl.reached ? "Limit reached" : "Limit not reached"} + + ) : null} +
+ ) : null} + {allowance.extraUsage !== undefined && allowance.extraUsage !== null ? (
Extra usage @@ -762,28 +762,26 @@ function SubscriptionAllowanceCard({ group }: { readonly group: SubscriptionAllo ) : null} )} - {group.hasMultipleReadings ? ( + {allowance.hasMultipleReadings ? (

Multiple readings are available; showing one whole provider source.

) : null} - {group.sources.length > 1 ? ( + {allowance.sources.length > 1 ? (
Sources - {group.sources.map((source, index) => ( - - {source.environmentLabel} · {source.allowance.instanceId} - {formatAllowanceConnectionPhase(source.connectionPhase) === null - ? "" - : ` · ${formatAllowanceConnectionPhase(source.connectionPhase)?.toLowerCase()}`} - {source.allowance.status === "unavailable" + {allowance.sources.map((source) => ( + + {source.environmentLabel} · {source.instanceId} ·{" "} + {source.connectionLabel.toLowerCase()} + {source.status === "unavailable" ? " · unavailable" - : source.allowance.freshness === "stale" + : source.freshness === "stale" ? " · stale" - : isSubscriptionAllowanceSourceCurrent(source) + : source.isCurrent ? " · current" : " · not current"} - {group.effectiveSource === source ? " · shown" : ""} + {source.isEffective ? " · shown" : ""} ))}
@@ -792,23 +790,6 @@ function SubscriptionAllowanceCard({ group }: { readonly group: SubscriptionAllo ); } -function formatAllowanceConnectionPhase(phase: EnvironmentConnectionPhase): string { - switch (phase) { - case "available": - return "Available"; - case "offline": - return "Offline"; - case "connecting": - return "Connecting"; - case "reconnecting": - return "Reconnecting"; - case "connected": - return "Connected"; - case "error": - return "Connection failed"; - } -} - /** Brand mark for the harness a row belongs to. */ function ProviderMark({ provider, diff --git a/apps/web/src/components/usage/usageAllowance.test.ts b/apps/web/src/components/usage/usageAllowance.test.ts index 0e2ef9279a44..5491d7b923b1 100644 --- a/apps/web/src/components/usage/usageAllowance.test.ts +++ b/apps/web/src/components/usage/usageAllowance.test.ts @@ -5,7 +5,7 @@ import { formatAllowanceUpdatedAt, formatAllowanceWindowScope, progressWidthForAllowance, -} from "./usageAllowance"; +} from "@t3tools/client-runtime/state/subscription-allowance"; describe("usage allowance presentation", () => { it("uses the provider scope and duration without deriving missing values", () => { diff --git a/apps/web/src/components/usage/usageAllowance.ts b/apps/web/src/components/usage/usageAllowance.ts deleted file mode 100644 index 4be7bb3c319b..000000000000 --- a/apps/web/src/components/usage/usageAllowance.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { SubscriptionAllowanceWindowScope } from "@t3tools/contracts"; - -export function formatAllowanceWindowScope(scope: SubscriptionAllowanceWindowScope): string { - switch (scope) { - case "primary": - return "Primary limit"; - case "secondary": - return "Secondary limit"; - case "five_hour": - return "5-hour limit"; - case "seven_day": - return "7-day limit"; - case "seven_day_oauth_apps": - return "7-day OAuth apps limit"; - case "seven_day_opus": - return "7-day Opus limit"; - case "seven_day_sonnet": - return "7-day Sonnet limit"; - default: - return scope; - } -} - -export function formatAllowanceDuration(minutes: number | null | undefined): string | null { - if (minutes === null || minutes === undefined) return null; - if (minutes % (24 * 60) === 0) { - const days = minutes / (24 * 60); - return `${days} ${days === 1 ? "day" : "days"}`; - } - if (minutes % 60 === 0) { - const hours = minutes / 60; - return `${hours} ${hours === 1 ? "hour" : "hours"}`; - } - return `${minutes} minutes`; -} - -export function progressWidthForAllowance(usedPercent: number): number { - return Math.min(100, Math.max(0, usedPercent)); -} - -export function formatAllowanceResetAt(resetsAt: string): string { - return new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", - }).format(new Date(resetsAt)); -} - -export function formatAllowanceUpdatedAt( - updatedAt: string | null | undefined, - now = Date.now(), -): string | null { - if (updatedAt === null || updatedAt === undefined) return null; - const date = new Date(updatedAt); - if (Number.isNaN(date.getTime())) return null; - - const elapsedMinutes = Math.max(0, Math.floor((now - date.getTime()) / 60_000)); - if (elapsedMinutes < 1) return "Updated just now"; - if (elapsedMinutes < 60) return `Updated ${elapsedMinutes}m ago`; - - const elapsedHours = Math.floor(elapsedMinutes / 60); - if (elapsedHours < 24) return `Updated ${elapsedHours}h ago`; - - return `Updated ${Math.floor(elapsedHours / 24)}d ago`; -} diff --git a/apps/web/src/state/subscriptionAllowance.test.ts b/apps/web/src/state/subscriptionAllowance.test.ts deleted file mode 100644 index ac2d70040696..000000000000 --- a/apps/web/src/state/subscriptionAllowance.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { ProviderInstanceId, type EnvironmentId } from "@t3tools/contracts"; - -import { flattenSubscriptionAllowances } from "./subscriptionAllowance"; - -describe("flattenSubscriptionAllowances", () => { - it("keeps environment identity while exposing provider snapshots to the view", () => { - const allowance = { - provider: "codex" as const, - instanceId: ProviderInstanceId.make("codex"), - status: "available" as const, - windows: [{ scope: "primary" as const, usedPercent: 20 }], - }; - - expect( - flattenSubscriptionAllowances([ - { - environmentId: "environment-1" as EnvironmentId, - label: "Laptop", - isPending: false, - error: null, - snapshot: { readAt: "2026-08-11T12:00:00.000Z", allowances: [allowance] }, - }, - { - environmentId: "environment-2" as EnvironmentId, - label: "Desktop", - isPending: false, - error: "This environment could not report subscription usage.", - snapshot: null, - }, - ]), - ).toEqual([ - { - environmentId: "environment-1", - environmentLabel: "Laptop", - allowance, - }, - ]); - }); -}); diff --git a/apps/web/src/state/subscriptionAllowance.ts b/apps/web/src/state/subscriptionAllowance.ts index 09575ffc04f8..f685de22c32e 100644 --- a/apps/web/src/state/subscriptionAllowance.ts +++ b/apps/web/src/state/subscriptionAllowance.ts @@ -1,6 +1,5 @@ import { useAtomCommand } from "../state/use-atom-command"; import { useAtomValue } from "@effect/atom-react"; -import { type EnvironmentId, type SubscriptionAllowance } from "@t3tools/contracts"; import { isSubscriptionAllowanceCompatibilityCause, reconcileSubscriptionAllowances, @@ -46,33 +45,6 @@ const subscriptionAllowanceByEnvironmentAtom = Atom.make( }, ).pipe(Atom.withLabel("web-usage:subscription-allowance")); -/** Backward-compatible web helper; reconciliation uses the shared source model below. */ -export interface EnvironmentSubscriptionAllowance { - readonly environmentId: EnvironmentId; - readonly environmentLabel: string; - readonly allowance: SubscriptionAllowance; -} - -export function flattenSubscriptionAllowances( - environments: readonly (Pick< - EnvironmentSubscriptionAllowanceStatus, - "environmentId" | "snapshot" - > & { - readonly label: string; - readonly isPending?: boolean; - readonly error?: string | null; - })[], -): readonly EnvironmentSubscriptionAllowance[] { - return environments.flatMap( - (environment) => - environment.snapshot?.allowances.map((allowance) => ({ - environmentId: environment.environmentId, - environmentLabel: environment.label, - allowance, - })) ?? [], - ); -} - export interface SubscriptionAllowanceView extends SubscriptionAllowanceProjection { readonly isRefreshing: boolean; readonly refresh: () => void; diff --git a/docs/internals/subscription-allowance.md b/docs/internals/subscription-allowance.md index f0c64f436f97..340845a25155 100644 --- a/docs/internals/subscription-allowance.md +++ b/docs/internals/subscription-allowance.md @@ -61,6 +61,11 @@ through the existing Agent SDK query. The allowance probe: - applies a bounded deadline; and - aborts and closes the query on success, unavailable response, error, timeout, or cancellation. +The provider session interface does not expose account-control requests, so the snapshot reader uses +the bounded no-turn query rather than reaching through an active conversation session. Compatible +runtime rate-limit events are reused as live updates. Cursor, Grok, and OpenCode do not currently +provide an allowance reader and are explicitly unsupported by this feature. + When Claude reports no limits, lacks the experimental method, or returns a response that cannot provide limits, the adapter returns the stable unavailable presentation: @@ -72,13 +77,12 @@ not Anthropic approval and is not a compliance determination. ## Transport and compatibility -The server exposes three additive methods in [`rpc.ts`][rpc]: +The server exposes two additive methods in [`rpc.ts`][rpc]: -- `server.getSubscriptionAllowance` for a one-shot compatibility read; - `server.refreshSubscriptionAllowance` for explicit refresh demand; and - `subscribeSubscriptionAllowance` for snapshot-first live delivery. -All three use the same authenticated environment access as Historical usage. The wire shape +Both use the same authenticated environment access as Historical usage. The wire shape contains allowance observations only; credentials, raw native payloads, transcripts, unmasked emails, local configuration paths, and Claude behavior data remain inside the owning environment. diff --git a/packages/client-runtime/src/state/subscriptionAllowance.ts b/packages/client-runtime/src/state/subscriptionAllowance.ts index 483ab357be86..ab94424c4dd5 100644 --- a/packages/client-runtime/src/state/subscriptionAllowance.ts +++ b/packages/client-runtime/src/state/subscriptionAllowance.ts @@ -11,6 +11,120 @@ import { isRpcMethodNotFoundError } from "../rpc/client.ts"; export const SUBSCRIPTION_ALLOWANCE_COMPATIBILITY_MESSAGE = "Subscription allowance reporting is not supported by this environment version."; +export type UsageView = "subscription" | "historical"; + +export const DEFAULT_USAGE_VIEW: UsageView = "subscription"; + +export const USAGE_VIEW_OPTIONS = [ + { value: "subscription" as const, label: "Subscription" }, + { value: "historical" as const, label: "Historical" }, +] as const; + +export type SubscriptionViewPhase = "loading" | "partial" | "ready"; + +export function subscriptionViewPhase(input: { + readonly isPending: boolean; + readonly isPartial: boolean; + readonly groupCount: number; +}): SubscriptionViewPhase { + if (input.isPending || (input.isPartial && input.groupCount === 0)) return "loading"; + return input.isPartial ? "partial" : "ready"; +} + +export function formatAllowanceWindowScope(scope: string): string { + switch (scope) { + case "primary": + return "Primary limit"; + case "secondary": + return "Secondary limit"; + case "five_hour": + return "5-hour limit"; + case "seven_day": + return "7-day limit"; + case "seven_day_oauth_apps": + return "7-day OAuth apps limit"; + case "seven_day_opus": + return "7-day Opus limit"; + case "seven_day_sonnet": + return "7-day Sonnet limit"; + default: + return scope; + } +} + +export function formatAllowanceDuration(minutes: number | null | undefined): string | null { + if (minutes === null || minutes === undefined) return null; + if (minutes % (24 * 60) === 0) { + const days = minutes / (24 * 60); + return `${days} ${days === 1 ? "day" : "days"}`; + } + if (minutes % 60 === 0) { + const hours = minutes / 60; + return `${hours} ${hours === 1 ? "hour" : "hours"}`; + } + return `${minutes} minutes`; +} + +export function progressWidthForAllowance(usedPercent: number): number { + return Math.min(100, Math.max(0, usedPercent)); +} + +export function formatAllowanceResetAt(resetsAt: string): string { + // @effect-diagnostics-next-line globalDate:off + const date = new Date(resetsAt); + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +export function formatAllowanceUpdatedAt( + updatedAt: string | null | undefined, + // UI formatting is intentionally wall-clock relative and remains a pure helper for both clients. + // @effect-diagnostics-next-line globalDate:off + now = Date.now(), +): string | null { + if (updatedAt === null || updatedAt === undefined) return null; + // @effect-diagnostics-next-line globalDate:off + const date = new Date(updatedAt); + if (Number.isNaN(date.getTime())) return null; + + const elapsedMinutes = Math.max(0, Math.floor((now - date.getTime()) / 60_000)); + if (elapsedMinutes < 1) return "Updated just now"; + if (elapsedMinutes < 60) return `Updated ${elapsedMinutes}m ago`; + + const elapsedHours = Math.floor(elapsedMinutes / 60); + if (elapsedHours < 24) return `Updated ${elapsedHours}h ago`; + return `Updated ${Math.floor(elapsedHours / 24)}d ago`; +} + +export function formatAllowanceConnectionPhase(phase: EnvironmentConnectionPhase): string { + switch (phase) { + case "available": + return "Available"; + case "offline": + return "Offline"; + case "connecting": + return "Connecting"; + case "reconnecting": + return "Reconnecting"; + case "connected": + return "Connected"; + case "error": + return "Connection failed"; + } +} + +export function formatAllowanceUnavailableMessage( + provider: SubscriptionAllowance["provider"], + message: string | undefined, +): string { + if (message !== undefined) return message; + return provider === "claude" + ? "Claude subscription usage is unavailable. Claude did not provide usage limits." + : "Subscription usage is unavailable."; +} + /** * The allowance state owned by one connected environment. * @@ -66,6 +180,87 @@ export interface SubscriptionAllowanceGroup { readonly hasMultipleReadings: boolean; } +export interface SubscriptionAllowanceSourceModel { + readonly key: string; + readonly environmentLabel: string; + readonly instanceId: string; + readonly connectionLabel: string; + readonly status: SubscriptionAllowance["status"]; + readonly freshness: "fresh" | "stale"; + readonly isCurrent: boolean; + readonly isEffective: boolean; +} + +export interface SubscriptionAllowanceCardModel { + readonly key: string; + readonly provider: SubscriptionAllowance["provider"]; + readonly accountLabel: string | null; + readonly status: SubscriptionAllowance["status"]; + readonly message: string; + readonly freshness: "fresh" | "stale"; + readonly updatedAt: string | null; + readonly windows: SubscriptionAllowance["windows"]; + readonly credits: NonNullable | null; + readonly spendingControl: NonNullable | null; + readonly extraUsage: NonNullable | null; + readonly hasMultipleReadings: boolean; + readonly sources: readonly SubscriptionAllowanceSourceModel[]; +} + +export function formatAllowanceEnvironmentNotice(environment: { + readonly label: string; + readonly connectionPhase: EnvironmentConnectionPhase; + readonly compatibility?: boolean; + readonly error: string | null; + readonly snapshot: unknown; +}): string | null { + if (environment.compatibility === true) { + return `${environment.label}: ${SUBSCRIPTION_ALLOWANCE_COMPATIBILITY_MESSAGE}`; + } + if (environment.error !== null) { + return `${environment.label} could not report subscription usage.`; + } + if (environment.snapshot === null && environment.connectionPhase !== "connected") { + return `${environment.label} is ${formatAllowanceConnectionPhase(environment.connectionPhase).toLowerCase()}; subscription usage will return when it reconnects.`; + } + return null; +} + +export function presentSubscriptionAllowanceGroup( + group: SubscriptionAllowanceGroup, +): SubscriptionAllowanceCardModel { + const displayedSource = group.effectiveSource ?? group.sources[0]; + if (displayedSource === undefined) { + throw new Error("Subscription allowance groups must contain a source"); + } + + const allowance = displayedSource.allowance; + return { + key: group.key, + provider: allowance.provider, + accountLabel: group.accountLabel, + status: allowance.status, + message: formatAllowanceUnavailableMessage(allowance.provider, allowance.message), + freshness: allowance.freshness ?? "fresh", + updatedAt: allowance.updatedAt ?? null, + windows: allowance.windows, + credits: allowance.credits ?? null, + spendingControl: allowance.spendingControl ?? null, + extraUsage: allowance.extraUsage ?? null, + hasMultipleReadings: group.hasMultipleReadings, + sources: group.sources.map((source, index) => ({ + key: `${source.environmentId}:${source.allowance.instanceId}:${index}`, + environmentLabel: source.environmentLabel, + instanceId: source.allowance.instanceId, + connectionLabel: formatAllowanceConnectionPhase(source.connectionPhase), + status: source.allowance.status, + freshness: source.allowance.freshness ?? "fresh", + isCurrent: isSubscriptionAllowanceSourceCurrent(source), + isEffective: source === displayedSource, + })), + }; +} + export interface SubscriptionAllowanceProjection { readonly environments: readonly EnvironmentSubscriptionAllowanceStatus[]; readonly sources: readonly SubscriptionAllowanceSource[]; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index fb8df0217ab0..faa2ee1b5c18 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -272,7 +272,6 @@ export const WS_METHODS = { serverReportHostPowerState: "server.reportHostPowerState", serverGetBackgroundPolicy: "server.getBackgroundPolicy", serverGetUsageSummary: "server.getUsageSummary", - serverGetSubscriptionAllowance: "server.getSubscriptionAllowance", serverRefreshSubscriptionAllowance: "server.refreshSubscriptionAllowance", // Cloud environment methods @@ -433,15 +432,6 @@ export const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSumm error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]), }); -export const WsServerGetSubscriptionAllowanceRpc = Rpc.make( - WS_METHODS.serverGetSubscriptionAllowance, - { - payload: Schema.Struct({}), - success: SubscriptionAllowanceSnapshot, - error: EnvironmentAuthorizationError, - }, -); - export const WsServerRefreshSubscriptionAllowanceRpc = Rpc.make( WS_METHODS.serverRefreshSubscriptionAllowance, { @@ -1000,7 +990,6 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetResourceTelemetryHistoryRpc, WsServerRetryResourceTelemetryRpc, WsServerGetUsageSummaryRpc, - WsServerGetSubscriptionAllowanceRpc, WsServerRefreshSubscriptionAllowanceRpc, WsServerSignalProcessRpc, WsServerReportClientActivityRpc, diff --git a/packages/contracts/src/usage.test.ts b/packages/contracts/src/usage.test.ts index 16f865add481..b49546db6939 100644 --- a/packages/contracts/src/usage.test.ts +++ b/packages/contracts/src/usage.test.ts @@ -1,7 +1,7 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "@effect/vitest"; -import { SubscriptionAllowanceSnapshot, USAGE_CONTRACT_VERSION } from "./usage.ts"; +import { SubscriptionAllowanceSnapshot } from "./usage.ts"; const decodeSnapshot = Schema.decodeUnknownSync(SubscriptionAllowanceSnapshot); @@ -58,8 +58,12 @@ describe("subscription allowance contract", () => { expect(snapshot.allowances[1]?.extraUsage?.usedCredits).toBe(2.5); }); - it("keeps historical usage contract versioning independent", () => { - expect(USAGE_CONTRACT_VERSION).toBe(3); - expect(SubscriptionAllowanceSnapshot).toBeDefined(); + it("keeps allowance snapshots independent from historical contract versioning", () => { + const snapshot = decodeSnapshot({ + readAt: "2026-08-11T12:00:00.000Z", + allowances: [], + }); + + expect(snapshot).not.toHaveProperty("contractVersion"); }); }); From 37ac80696d74e3209d836b95baf5342b4571b7c9 Mon Sep 17 00:00:00 2001 From: naveed949 Date: Wed, 12 Aug 2026 18:22:59 +0500 Subject: [PATCH 11/29] refactor(usage): hide provider spending details --- .../src/features/usage/UsageRouteScreen.tsx | 50 ------------------- .../src/features/usage/usageAllowance.test.ts | 20 +++++--- apps/web/src/components/usage/UsagePage.tsx | 44 ---------------- .../src/state/subscriptionAllowance.ts | 4 -- 4 files changed, 14 insertions(+), 104 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 92c3d423e5e7..f4153e91ea85 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -434,8 +434,6 @@ function AllowanceWindowRow(props: { function AllowanceMetadata(props: { readonly model: SubscriptionAllowanceCardModel }) { const { model } = props; const credits = model.credits; - const spendingControl = model.spendingControl; - const extraUsage = model.extraUsage; return ( <> @@ -450,54 +448,6 @@ function AllowanceMetadata(props: { readonly model: SubscriptionAllowanceCardMod ) : null} - - {spendingControl !== null ? ( - - Spending control - {spendingControl.limit !== undefined && spendingControl.limit !== null ? ( - Limit {spendingControl.limit} - ) : null} - {spendingControl.used !== undefined && spendingControl.used !== null ? ( - Used {spendingControl.used} - ) : null} - {spendingControl.remainingPercent !== undefined && - spendingControl.remainingPercent !== null ? ( - - {spendingControl.remainingPercent}% remaining - - ) : null} - {spendingControl.reached !== undefined && spendingControl.reached !== null ? ( - - {spendingControl.reached ? "Limit reached" : "Limit not reached"} - - ) : null} - - ) : null} - - {extraUsage !== null ? ( - - Extra usage - - {extraUsage.isEnabled ? "Enabled" : "Disabled"} - - {extraUsage.monthlyLimit !== null ? ( - - Monthly limit {extraUsage.monthlyLimit} - - ) : null} - {extraUsage.usedCredits !== null ? ( - - Used credits {extraUsage.usedCredits} - - ) : null} - {extraUsage.utilization !== null ? ( - {extraUsage.utilization}% used - ) : null} - {extraUsage.currency !== undefined && extraUsage.currency !== null ? ( - {extraUsage.currency} - ) : null} - - ) : null} ); } diff --git a/apps/mobile/src/features/usage/usageAllowance.test.ts b/apps/mobile/src/features/usage/usageAllowance.test.ts index 86826b62f594..d144b19ba9e7 100644 --- a/apps/mobile/src/features/usage/usageAllowance.test.ts +++ b/apps/mobile/src/features/usage/usageAllowance.test.ts @@ -55,6 +55,12 @@ describe("mobile subscription allowance presentation", () => { remainingPercent: 75, used: "25", }, + extraUsage: { + isEnabled: true, + monthlyLimit: 100, + usedCredits: 25, + utilization: 25, + }, }, }, ], @@ -68,6 +74,12 @@ describe("mobile subscription allowance presentation", () => { remainingPercent: 75, used: "25", }, + extraUsage: { + isEnabled: true, + monthlyLimit: 100, + usedCredits: 25, + utilization: 25, + }, }, }, }), @@ -87,13 +99,9 @@ describe("mobile subscription allowance presentation", () => { resetsAt: readAt, }, ], - spendingControl: { - reached: false, - limit: "100", - remainingPercent: 75, - used: "25", - }, }); + expect(model).not.toHaveProperty("spendingControl"); + expect(model).not.toHaveProperty("extraUsage"); }); it("uses the stable Claude placeholder without inferring account state", () => { diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 649bb5d42df0..08bc4d9288dc 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -716,50 +716,6 @@ function SubscriptionAllowanceCard({
) : null} - - {allowance.spendingControl !== undefined && allowance.spendingControl !== null ? ( -
- Spending control - {allowance.spendingControl.limit !== undefined && - allowance.spendingControl.limit !== null ? ( - Limit {allowance.spendingControl.limit} - ) : null} - {allowance.spendingControl.used !== undefined && - allowance.spendingControl.used !== null ? ( - Used {allowance.spendingControl.used} - ) : null} - {allowance.spendingControl.remainingPercent !== undefined && - allowance.spendingControl.remainingPercent !== null ? ( - {allowance.spendingControl.remainingPercent}% remaining - ) : null} - {allowance.spendingControl.reached !== undefined && - allowance.spendingControl.reached !== null ? ( - - {allowance.spendingControl.reached ? "Limit reached" : "Limit not reached"} - - ) : null} -
- ) : null} - - {allowance.extraUsage !== undefined && allowance.extraUsage !== null ? ( -
- Extra usage - {allowance.extraUsage.isEnabled ? "Enabled" : "Disabled"} - {allowance.extraUsage.monthlyLimit !== null ? ( - Monthly limit {allowance.extraUsage.monthlyLimit} - ) : null} - {allowance.extraUsage.usedCredits !== null ? ( - Used credits {allowance.extraUsage.usedCredits} - ) : null} - {allowance.extraUsage.utilization !== null ? ( - {allowance.extraUsage.utilization}% used - ) : null} - {allowance.extraUsage.currency !== undefined && - allowance.extraUsage.currency !== null ? ( - {allowance.extraUsage.currency} - ) : null} -
- ) : null} )} {allowance.hasMultipleReadings ? ( diff --git a/packages/client-runtime/src/state/subscriptionAllowance.ts b/packages/client-runtime/src/state/subscriptionAllowance.ts index ab94424c4dd5..057ab92f1f4a 100644 --- a/packages/client-runtime/src/state/subscriptionAllowance.ts +++ b/packages/client-runtime/src/state/subscriptionAllowance.ts @@ -201,8 +201,6 @@ export interface SubscriptionAllowanceCardModel { readonly updatedAt: string | null; readonly windows: SubscriptionAllowance["windows"]; readonly credits: NonNullable | null; - readonly spendingControl: NonNullable | null; - readonly extraUsage: NonNullable | null; readonly hasMultipleReadings: boolean; readonly sources: readonly SubscriptionAllowanceSourceModel[]; } @@ -245,8 +243,6 @@ export function presentSubscriptionAllowanceGroup( updatedAt: allowance.updatedAt ?? null, windows: allowance.windows, credits: allowance.credits ?? null, - spendingControl: allowance.spendingControl ?? null, - extraUsage: allowance.extraUsage ?? null, hasMultipleReadings: group.hasMultipleReadings, sources: group.sources.map((source, index) => ({ key: `${source.environmentId}:${source.allowance.instanceId}:${index}`, From 79f49065bbf17177169c293e84e714829244688b Mon Sep 17 00:00:00 2001 From: naveed949 Date: Wed, 12 Aug 2026 20:10:18 +0500 Subject: [PATCH 12/29] fix(usage): show materialized provider allowances --- .../src/features/usage/UsageRouteScreen.tsx | 2 +- .../src/features/usage/usageAllowance.test.ts | 4 +-- .../Services/ProviderAllowanceReader.ts | 2 +- .../SubscriptionAllowanceService.test.ts | 28 +++++++++++++++++++ .../src/usage/SubscriptionAllowanceService.ts | 6 ++-- apps/web/src/components/usage/UsagePage.tsx | 4 +-- docs/internals/subscription-allowance.md | 2 +- .../subscription-allowance-release.md | 2 +- docs/user/usage.md | 2 +- .../src/state/subscriptionAllowance.ts | 2 +- 10 files changed, 40 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index f4153e91ea85..f1e5f1ba789d 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -255,7 +255,7 @@ function SubscriptionUsageContent(props: { Subscription allowance - Current provider-reported limits and reset windows. + Usage limits and reset times from your provider. { }), ); - expect(model.message).toBe( - "Claude subscription usage is unavailable. Claude did not provide usage limits.", - ); + expect(model.message).toBe("Claude did not report subscription usage limits."); expect(model.windows).toEqual([]); expect(model.accountLabel).toBeNull(); }); diff --git a/apps/server/src/provider/Services/ProviderAllowanceReader.ts b/apps/server/src/provider/Services/ProviderAllowanceReader.ts index 8162c33326f5..40f1ccb94a44 100644 --- a/apps/server/src/provider/Services/ProviderAllowanceReader.ts +++ b/apps/server/src/provider/Services/ProviderAllowanceReader.ts @@ -7,7 +7,7 @@ import type * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; export const CLAUDE_SUBSCRIPTION_ALLOWANCE_UNAVAILABLE_MESSAGE = - "Claude subscription usage is unavailable. Claude did not provide usage limits."; + "Claude did not report subscription usage limits."; export class ProviderAllowanceReadError extends Schema.TaggedErrorClass()( "ProviderAllowanceReadError", diff --git a/apps/server/src/usage/SubscriptionAllowanceService.test.ts b/apps/server/src/usage/SubscriptionAllowanceService.test.ts index 56e1b278420b..3d826b5423ac 100644 --- a/apps/server/src/usage/SubscriptionAllowanceService.test.ts +++ b/apps/server/src/usage/SubscriptionAllowanceService.test.ts @@ -130,6 +130,34 @@ describe("subscription allowance lifecycle helpers", () => { }); describe("SubscriptionAllowanceService", () => { + it.effect("acquires providers materialized before first demand", () => + Effect.gen(function* () { + const changes = yield* PubSub.unbounded(); + const instances = yield* Ref.make>([]); + const registryLayer = Layer.succeed(ProviderInstanceRegistry, { + getInstance: () => Effect.succeed(undefined), + listInstances: Ref.get(instances), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.fromPubSub(changes), + subscribeChanges: PubSub.subscribe(changes), + }); + const service = yield* make.pipe(Effect.provide(registryLayer)); + yield* Ref.set(instances, [ + instance({ allowanceReader: reader(Effect.succeed(allowance)) }) as ProviderInstance, + ]); + + yield* Effect.scoped( + Effect.gen(function* () { + const subscription = yield* service.subscribe; + const published = Option.getOrThrow(yield* Stream.runHead(subscription.changes)); + + expect(published.allowances).toHaveLength(1); + expect(published.allowances[0]?.instanceId).toBe(allowance.instanceId); + }), + ); + }), + ); + it.effect("publishes complete snapshot provenance through the subscription seam", () => Effect.gen(function* () { const providerInstance = instance({ diff --git a/apps/server/src/usage/SubscriptionAllowanceService.ts b/apps/server/src/usage/SubscriptionAllowanceService.ts index dc0707839b0b..0cffc824ac91 100644 --- a/apps/server/src/usage/SubscriptionAllowanceService.ts +++ b/apps/server/src/usage/SubscriptionAllowanceService.ts @@ -29,7 +29,7 @@ import { } from "../provider/Services/ProviderService.ts"; import { ProviderInstanceRegistry } from "../provider/Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; -import { subscribeBeforeSnapshot } from "../utils/subscribeBeforeSnapshot.ts"; +import { subscribeBeforeSnapshotWithoutMutex } from "../utils/subscribeBeforeSnapshot.ts"; export const SUBSCRIPTION_ALLOWANCE_REFRESH_INTERVAL_MS = 5 * 60_000; @@ -451,6 +451,7 @@ export const make = Effect.gen(function* () { ), Effect.forkIn(liveScope), ); + yield* syncInstances; yield* Effect.forever( Effect.sleep(`${SUBSCRIPTION_ALLOWANCE_REFRESH_INTERVAL_MS} millis`).pipe( Effect.andThen(refresh.pipe(Effect.ignore)), @@ -485,13 +486,12 @@ export const make = Effect.gen(function* () { } return nextSnapshot; }); - const subscribe = subscribeBeforeSnapshot( + const subscribe = subscribeBeforeSnapshotWithoutMutex( changes, Effect.gen(function* () { yield* Effect.acquireRelease(acquireDemand, () => releaseDemand); return markSnapshotDeliveredFromCache(yield* latestUnlocked); }), - stateMutex, ); return SubscriptionAllowanceService.of({ diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 08bc4d9288dc..b61f350a3361 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -568,7 +568,7 @@ function SubscriptionUsagePage({

Subscription allowance

- Current provider-reported limits and reset windows. + Usage limits and reset times from your provider.

- + + + } + > + + + + {isRefreshing ? "Refreshing subscription usage…" : "Refresh subscription usage"} + +
{phase === "loading" ? ( @@ -631,7 +645,11 @@ function SubscriptionAllowanceCard({ }: { readonly model: SubscriptionAllowanceCardModel; }) { - const updatedAt = formatAllowanceUpdatedAt(allowance.updatedAt); + const nowMinute = useNowMinute(); + const updatedAt = formatAllowanceUpdatedAt( + allowance.updatedAt, + Date.parse(`${nowMinute}:00.000Z`), + ); return (
@@ -644,13 +662,7 @@ function SubscriptionAllowanceCard({ ) : null}
- - {allowance.freshness === "stale" - ? updatedAt === null - ? "Stale" - : `Stale · ${updatedAt}` - : (updatedAt ?? "Updated time unavailable")} - + {updatedAt ?? "Updated time unavailable"}
@@ -732,11 +744,9 @@ function SubscriptionAllowanceCard({ {source.connectionLabel.toLowerCase()} {source.status === "unavailable" ? " · unavailable" - : source.freshness === "stale" - ? " · stale" - : source.isCurrent - ? " · current" - : " · not current"} + : source.isCurrent + ? " · current" + : " · not current"} {source.isEffective ? " · shown" : ""} ))} From 78dff564b73174cc9fcd71008946766b8137c081 Mon Sep 17 00:00:00 2001 From: naveed949 Date: Thu, 13 Aug 2026 13:48:39 +0500 Subject: [PATCH 17/29] fix(usage): clarify allowance freshness presentation --- apps/mobile/src/features/usage/UsageRouteScreen.tsx | 2 +- apps/mobile/src/features/usage/usageAllowance.test.ts | 8 ++++---- apps/web/src/components/usage/UsagePage.tsx | 2 ++ apps/web/src/components/usage/usageAllowance.test.ts | 2 +- docs/internals/subscription-allowance.md | 8 +++++--- docs/operations/subscription-allowance-release.md | 8 ++++---- docs/user/usage.md | 7 ++++--- .../client-runtime/src/state/subscriptionAllowance.ts | 5 +---- 8 files changed, 22 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index a5c0ae8de1f9..4565143f25c5 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -311,7 +311,7 @@ function SubscriptionUsageContent(props: { )) ) : environments.length === 0 ? ( - Connect an environment to see subscription allowance. + Connect an environment to see subscription allowance data. ) : ( diff --git a/apps/mobile/src/features/usage/usageAllowance.test.ts b/apps/mobile/src/features/usage/usageAllowance.test.ts index 87b1ac37dadd..a830a10283b9 100644 --- a/apps/mobile/src/features/usage/usageAllowance.test.ts +++ b/apps/mobile/src/features/usage/usageAllowance.test.ts @@ -89,7 +89,6 @@ describe("mobile subscription allowance presentation", () => { provider: "codex", accountLabel: "n•••@example.com", status: "available", - freshness: "fresh", updatedAt: readAt, windows: [ { @@ -133,7 +132,7 @@ describe("mobile subscription allowance presentation", () => { expect(model.accountLabel).toBeNull(); }); - it("retains stale state, source identity, and multiple-reading context", () => { + it("derives source currentness without exposing raw freshness to the UI", () => { const source = group().effectiveSource!; const staleSource = { ...source, @@ -152,8 +151,9 @@ describe("mobile subscription allowance presentation", () => { }), ); - expect(model.freshness).toBe("stale"); + expect(model).not.toHaveProperty("freshness"); expect(model.hasMultipleReadings).toBe(true); + expect(model.sources[0]).not.toHaveProperty("freshness"); expect(model.sources[0]?.isCurrent).toBe(false); }); @@ -216,7 +216,7 @@ describe("mobile subscription allowance presentation", () => { ).toBe("Desktop could not report subscription usage."); }); - it("shows relative freshness without exposing a local timestamp", () => { + it("shows relative observation age without exposing a local timestamp", () => { const now = Date.parse("2026-08-12T12:00:00.000Z"); expect(formatAllowanceUpdatedAt("2026-08-12T12:00:00.000Z", now)).toBe("Updated just now"); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index c4f52bde7702..5a05acd0022d 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -607,6 +607,8 @@ function SubscriptionUsagePage({ environmentNotices.map((notice) => ( {notice.message} )) + ) : environments.length === 0 ? ( + Connect an environment to see subscription allowance data. ) : ( No enabled provider reports subscription allowance data. )} diff --git a/apps/web/src/components/usage/usageAllowance.test.ts b/apps/web/src/components/usage/usageAllowance.test.ts index 5491d7b923b1..ec3b0f4bec9d 100644 --- a/apps/web/src/components/usage/usageAllowance.test.ts +++ b/apps/web/src/components/usage/usageAllowance.test.ts @@ -25,7 +25,7 @@ describe("usage allowance presentation", () => { expect(progressWidthForAllowance(101)).toBe(100); }); - it("shows relative freshness without exposing a local timestamp", () => { + it("shows relative observation age without exposing a local timestamp", () => { const now = Date.parse("2026-08-12T12:00:00.000Z"); expect(formatAllowanceUpdatedAt("2026-08-12T12:00:00.000Z", now)).toBe("Updated just now"); diff --git a/docs/internals/subscription-allowance.md b/docs/internals/subscription-allowance.md index deb3ec563545..58bed120e019 100644 --- a/docs/internals/subscription-allowance.md +++ b/docs/internals/subscription-allowance.md @@ -101,9 +101,11 @@ not prove identity. Grouping is a presentation view, not a merge. The projection chooses one complete whole-source observation by deterministic freshness, completeness, delivery, connection, and source ordering; -it never combines one environment’s windows with another environment’s credits. The web and mobile -surfaces render that same projection in their native layouts. Leaving Subscription unmounts the -allowance consumer, which releases demand while leaving Historical independent. +it never combines one environment’s windows with another environment’s credits. Freshness remains +an internal selection input: card presentation models expose the observation timestamp and derived +source currentness, not raw freshness. Web and mobile communicate age with **Updated** and do not +render a separate stale badge. Leaving Subscription unmounts the allowance consumer, which releases +demand while leaving Historical independent. ## Evidence and release boundary diff --git a/docs/operations/subscription-allowance-release.md b/docs/operations/subscription-allowance-release.md index 87e0ddf290a5..72e4d8d51904 100644 --- a/docs/operations/subscription-allowance-release.md +++ b/docs/operations/subscription-allowance-release.md @@ -39,8 +39,8 @@ Record these states separately in the release record: refresh bounds, sparse folding, stale transitions, generation safety, reconnect, demand teardown, and cleanup. - [ ] Shared projection and web/mobile presentation tests cover multiple sources, exact identity - grouping, whole-source selection, provider placeholders, stale/refreshing/loading states, - accessibility, and unchanged Historical controls. + grouping, whole-source selection, provider placeholders, observation age, derived source + currentness, refreshing/loading states, accessibility, and unchanged Historical controls. - [ ] Focused tests, targeted formatting/linting, and scoped typechecks pass for every changed package. Repository-wide checks are CI-owned unless separately requested. @@ -79,8 +79,8 @@ local behavior data. Keep only provider-reported fields needed to prove the UI. ### Web and desktop - [ ] One integrated pass covers Subscription/Historical switching, both providers, manual refresh, - loading/refreshing/stale/unavailable states, multiple instances/environments, accessibility, - unchanged Historical content, and desktop parity. + loading/refreshing/unavailable states, observation-age updates, non-current source selection, + multiple instances/environments, accessibility, unchanged Historical content, and desktop parity. - [ ] Remote disconnect/reconnect and newer-client/older-server compatibility are observed. - [ ] Before/after images are captured; capture a short recording when refresh or live-update timing is material. diff --git a/docs/user/usage.md b/docs/user/usage.md index 449bf071b328..0375b55a0ec2 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -11,9 +11,10 @@ environment remains inspectable. T3 Code displays provider-reported percentages, reset times, credits, and spending controls only when the provider supplies them. It does not infer a quota, reset, account status, or combined cross-provider total. -Use **Refresh** on web or mobile to request a new provider observation. On mobile, you can also pull -down on the Usage screen. A fresh reading is current provider data; a stale reading is retained -data from before a failed refresh or a passed reset time. T3 Code does not reset percentages locally. +Use **Refresh** on web or pull down on the mobile Usage screen to request a new provider observation. +The **Updated** timestamp communicates the age of the displayed observation instead of adding a +separate stale badge. Data retained after a failed refresh or a passed reset time is not treated as +current when selecting among provider sources. T3 Code does not reset percentages locally. Leaving the Subscription view stops its live allowance updates; returning to it requests a fresh reading. If an environment is offline or reconnecting, its last known reading remains identified diff --git a/packages/client-runtime/src/state/subscriptionAllowance.ts b/packages/client-runtime/src/state/subscriptionAllowance.ts index 520b7a09189b..4aaae4215772 100644 --- a/packages/client-runtime/src/state/subscriptionAllowance.ts +++ b/packages/client-runtime/src/state/subscriptionAllowance.ts @@ -186,7 +186,7 @@ export interface SubscriptionAllowanceSourceModel { readonly instanceId: string; readonly connectionLabel: string; readonly status: SubscriptionAllowance["status"]; - readonly freshness: "fresh" | "stale"; + /** Derived from connection, availability, and freshness for UI presentation. */ readonly isCurrent: boolean; readonly isEffective: boolean; } @@ -197,7 +197,6 @@ export interface SubscriptionAllowanceCardModel { readonly accountLabel: string | null; readonly status: SubscriptionAllowance["status"]; readonly message: string; - readonly freshness: "fresh" | "stale"; readonly updatedAt: string | null; readonly windows: SubscriptionAllowance["windows"]; readonly credits: NonNullable | null; @@ -239,7 +238,6 @@ export function presentSubscriptionAllowanceGroup( accountLabel: group.accountLabel, status: allowance.status, message: formatAllowanceUnavailableMessage(allowance.provider, allowance.message), - freshness: allowance.freshness ?? "fresh", updatedAt: allowance.updatedAt ?? null, windows: allowance.windows, credits: allowance.credits ?? null, @@ -250,7 +248,6 @@ export function presentSubscriptionAllowanceGroup( instanceId: source.allowance.instanceId, connectionLabel: formatAllowanceConnectionPhase(source.connectionPhase), status: source.allowance.status, - freshness: source.allowance.freshness ?? "fresh", isCurrent: isSubscriptionAllowanceSourceCurrent(source), isEffective: source === displayedSource, })), From aee1280b2e4e33828413f0214031a4a39777a074 Mon Sep 17 00:00:00 2001 From: naveed949 Date: Thu, 13 Aug 2026 14:29:06 +0500 Subject: [PATCH 18/29] fix(usage): address allowance presentation feedback --- .../src/features/usage/UsageRouteScreen.tsx | 64 ++++++++++++++++++- .../src/features/usage/usageAllowance.test.ts | 50 ++++++++++++++- .../src/features/usage/usageView.test.ts | 3 + apps/web/src/components/usage/UsagePage.tsx | 56 +++++++++++++++- docs/internals/subscription-allowance.md | 6 +- docs/user/usage.md | 6 +- .../src/state/subscriptionAllowance.ts | 36 ++++++++++- 7 files changed, 207 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 4565143f25c5..5617cb88ea22 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -8,6 +8,8 @@ import { formatAllowanceWindowScope, presentSubscriptionAllowanceGroup, progressWidthForAllowance, + shouldShowExtraUsage, + shouldShowSpendingControl, subscriptionViewPhase, USAGE_VIEW_OPTIONS, type SubscriptionAllowanceCardModel, @@ -372,8 +374,18 @@ function SubscriptionAllowanceCard(props: { ) : null} - - {updatedAt ?? "Updated time unavailable"} + + {model.status === "available" && !model.isCurrent + ? updatedAt === null + ? "Not current" + : `Not current · ${updatedAt}` + : (updatedAt ?? "Updated time unavailable")} @@ -450,6 +462,8 @@ function AllowanceWindowRow(props: { function AllowanceMetadata(props: { readonly model: SubscriptionAllowanceCardModel }) { const { model } = props; const credits = model.credits; + const spendingControl = model.spendingControl; + const extraUsage = model.extraUsage; return ( <> @@ -464,6 +478,52 @@ function AllowanceMetadata(props: { readonly model: SubscriptionAllowanceCardMod ) : null} + + {spendingControl !== null && shouldShowSpendingControl(spendingControl) ? ( + + Spending control + {spendingControl.limit !== undefined && spendingControl.limit !== null ? ( + Limit {spendingControl.limit} + ) : null} + {spendingControl.used !== undefined && spendingControl.used !== null ? ( + Used {spendingControl.used} + ) : null} + {spendingControl.remainingPercent !== undefined && + spendingControl.remainingPercent !== null ? ( + + {spendingControl.remainingPercent}% remaining + + ) : null} + {spendingControl.reached === true ? ( + Limit reached + ) : null} + + ) : null} + + {extraUsage !== null && shouldShowExtraUsage(extraUsage) ? ( + + Extra usage + + {extraUsage.isEnabled ? "Enabled" : "Disabled"} + + {extraUsage.monthlyLimit !== null ? ( + + Monthly limit {extraUsage.monthlyLimit} + + ) : null} + {extraUsage.usedCredits !== null ? ( + + Used credits {extraUsage.usedCredits} + + ) : null} + {extraUsage.utilization !== null ? ( + {extraUsage.utilization}% used + ) : null} + {extraUsage.currency !== undefined && extraUsage.currency !== null ? ( + {extraUsage.currency} + ) : null} + + ) : null} ); } diff --git a/apps/mobile/src/features/usage/usageAllowance.test.ts b/apps/mobile/src/features/usage/usageAllowance.test.ts index a830a10283b9..b9057f4c2e48 100644 --- a/apps/mobile/src/features/usage/usageAllowance.test.ts +++ b/apps/mobile/src/features/usage/usageAllowance.test.ts @@ -4,6 +4,8 @@ import { formatAllowanceWindowScope, presentSubscriptionAllowanceGroup, progressWidthForAllowance, + shouldShowExtraUsage, + shouldShowSpendingControl, type SubscriptionAllowanceGroup, } from "@t3tools/client-runtime/state/subscription-allowance"; import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; @@ -39,6 +41,37 @@ function group(overrides: Partial = {}): Subscriptio } describe("mobile subscription allowance presentation", () => { + it("hides non-actionable provider control states", () => { + expect(shouldShowSpendingControl({ reached: false })).toBe(false); + expect(shouldShowSpendingControl({ reached: true })).toBe(true); + expect(shouldShowSpendingControl({ reached: false, limit: "100" })).toBe(true); + + expect( + shouldShowExtraUsage({ + isEnabled: false, + monthlyLimit: null, + usedCredits: null, + utilization: null, + }), + ).toBe(false); + expect( + shouldShowExtraUsage({ + isEnabled: true, + monthlyLimit: null, + usedCredits: null, + utilization: null, + }), + ).toBe(true); + expect( + shouldShowExtraUsage({ + isEnabled: false, + monthlyLimit: 0, + usedCredits: null, + utilization: null, + }), + ).toBe(true); + }); + it("keeps provider-native windows and optional values intact", () => { const source = group().effectiveSource!; const model = presentSubscriptionAllowanceGroup( @@ -90,6 +123,7 @@ describe("mobile subscription allowance presentation", () => { accountLabel: "n•••@example.com", status: "available", updatedAt: readAt, + isCurrent: true, windows: [ { scope: "primary", @@ -98,9 +132,19 @@ describe("mobile subscription allowance presentation", () => { resetsAt: readAt, }, ], + spendingControl: { + reached: false, + limit: "100", + remainingPercent: 75, + used: "25", + }, + extraUsage: { + isEnabled: true, + monthlyLimit: 100, + usedCredits: 25, + utilization: 25, + }, }); - expect(model).not.toHaveProperty("spendingControl"); - expect(model).not.toHaveProperty("extraUsage"); }); it("uses the stable Claude placeholder without inferring account state", () => { @@ -136,7 +180,6 @@ describe("mobile subscription allowance presentation", () => { const source = group().effectiveSource!; const staleSource = { ...source, - connectionPhase: "offline" as const, allowance: { ...source.allowance, freshness: "stale" as const, @@ -152,6 +195,7 @@ describe("mobile subscription allowance presentation", () => { ); expect(model).not.toHaveProperty("freshness"); + expect(model.isCurrent).toBe(false); expect(model.hasMultipleReadings).toBe(true); expect(model.sources[0]).not.toHaveProperty("freshness"); expect(model.sources[0]?.isCurrent).toBe(false); diff --git a/apps/mobile/src/features/usage/usageView.test.ts b/apps/mobile/src/features/usage/usageView.test.ts index 504eb6fe1f8f..89665f3a2e5d 100644 --- a/apps/mobile/src/features/usage/usageView.test.ts +++ b/apps/mobile/src/features/usage/usageView.test.ts @@ -28,6 +28,9 @@ describe("mobile usage view state", () => { expect(subscriptionViewPhase({ isPending: false, isPartial: true, groupCount: 1 })).toBe( "partial", ); + expect(subscriptionViewPhase({ isPending: true, isPartial: false, groupCount: 1 })).toBe( + "partial", + ); expect(subscriptionViewPhase({ isPending: false, isPartial: false, groupCount: 2 })).toBe( "ready", ); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 5a05acd0022d..2a05421c0703 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -7,6 +7,8 @@ import { formatAllowanceWindowScope, presentSubscriptionAllowanceGroup, progressWidthForAllowance, + shouldShowExtraUsage, + shouldShowSpendingControl, subscriptionViewPhase, USAGE_VIEW_OPTIONS, type SubscriptionAllowanceCardModel, @@ -664,7 +666,19 @@ function SubscriptionAllowanceCard({ ) : null}
- {updatedAt ?? "Updated time unavailable"} + + {allowance.status === "available" && !allowance.isCurrent + ? updatedAt === null + ? "Not current" + : `Not current · ${updatedAt}` + : (updatedAt ?? "Updated time unavailable")} +
@@ -730,6 +744,46 @@ function SubscriptionAllowanceCard({
) : null} + + {allowance.spendingControl !== null && + shouldShowSpendingControl(allowance.spendingControl) ? ( +
+ Spending control + {allowance.spendingControl.limit !== undefined && + allowance.spendingControl.limit !== null ? ( + Limit {allowance.spendingControl.limit} + ) : null} + {allowance.spendingControl.used !== undefined && + allowance.spendingControl.used !== null ? ( + Used {allowance.spendingControl.used} + ) : null} + {allowance.spendingControl.remainingPercent !== undefined && + allowance.spendingControl.remainingPercent !== null ? ( + {allowance.spendingControl.remainingPercent}% remaining + ) : null} + {allowance.spendingControl.reached === true ? Limit reached : null} +
+ ) : null} + + {allowance.extraUsage !== null && shouldShowExtraUsage(allowance.extraUsage) ? ( +
+ Extra usage + {allowance.extraUsage.isEnabled ? "Enabled" : "Disabled"} + {allowance.extraUsage.monthlyLimit !== null ? ( + Monthly limit {allowance.extraUsage.monthlyLimit} + ) : null} + {allowance.extraUsage.usedCredits !== null ? ( + Used credits {allowance.extraUsage.usedCredits} + ) : null} + {allowance.extraUsage.utilization !== null ? ( + {allowance.extraUsage.utilization}% used + ) : null} + {allowance.extraUsage.currency !== undefined && + allowance.extraUsage.currency !== null ? ( + {allowance.extraUsage.currency} + ) : null} +
+ ) : null} )} {allowance.hasMultipleReadings ? ( diff --git a/docs/internals/subscription-allowance.md b/docs/internals/subscription-allowance.md index 58bed120e019..2f10e11fdea9 100644 --- a/docs/internals/subscription-allowance.md +++ b/docs/internals/subscription-allowance.md @@ -103,9 +103,9 @@ Grouping is a presentation view, not a merge. The projection chooses one complet observation by deterministic freshness, completeness, delivery, connection, and source ordering; it never combines one environment’s windows with another environment’s credits. Freshness remains an internal selection input: card presentation models expose the observation timestamp and derived -source currentness, not raw freshness. Web and mobile communicate age with **Updated** and do not -render a separate stale badge. Leaving Subscription unmounts the allowance consumer, which releases -demand while leaving Historical independent. +source currentness, not raw freshness. Web and mobile communicate age with **Updated** and mark +retained available data **Not current**. Leaving Subscription unmounts the allowance consumer, +which releases demand while leaving Historical independent. ## Evidence and release boundary diff --git a/docs/user/usage.md b/docs/user/usage.md index 0375b55a0ec2..100b20327d42 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -12,9 +12,9 @@ reset times, credits, and spending controls only when the provider supplies them a quota, reset, account status, or combined cross-provider total. Use **Refresh** on web or pull down on the mobile Usage screen to request a new provider observation. -The **Updated** timestamp communicates the age of the displayed observation instead of adding a -separate stale badge. Data retained after a failed refresh or a passed reset time is not treated as -current when selecting among provider sources. T3 Code does not reset percentages locally. +The **Updated** timestamp communicates the age of the displayed observation. Data retained after a +failed refresh, a passed reset time, or a disconnected environment is marked **Not current** while +it remains visible. T3 Code does not reset percentages locally. Leaving the Subscription view stops its live allowance updates; returning to it requests a fresh reading. If an environment is offline or reconnecting, its last known reading remains identified diff --git a/packages/client-runtime/src/state/subscriptionAllowance.ts b/packages/client-runtime/src/state/subscriptionAllowance.ts index 4aaae4215772..32528bc9f4bb 100644 --- a/packages/client-runtime/src/state/subscriptionAllowance.ts +++ b/packages/client-runtime/src/state/subscriptionAllowance.ts @@ -27,8 +27,8 @@ export function subscriptionViewPhase(input: { readonly isPartial: boolean; readonly groupCount: number; }): SubscriptionViewPhase { - if (input.isPending || (input.isPartial && input.groupCount === 0)) return "loading"; - return input.isPartial ? "partial" : "ready"; + if (input.groupCount === 0 && (input.isPending || input.isPartial)) return "loading"; + return input.isPending || input.isPartial ? "partial" : "ready"; } export function formatAllowanceWindowScope(scope: string): string { @@ -69,6 +69,31 @@ export function progressWidthForAllowance(usedPercent: number): number { return Math.min(100, Math.max(0, usedPercent)); } +const isReported = (value: unknown): boolean => value !== undefined && value !== null; + +export function shouldShowSpendingControl( + spendingControl: NonNullable, +): boolean { + return ( + spendingControl.reached === true || + isReported(spendingControl.limit) || + isReported(spendingControl.remainingPercent) || + isReported(spendingControl.resetsAt) || + isReported(spendingControl.used) + ); +} + +export function shouldShowExtraUsage( + extraUsage: NonNullable, +): boolean { + return ( + extraUsage.isEnabled || + isReported(extraUsage.monthlyLimit) || + isReported(extraUsage.usedCredits) || + isReported(extraUsage.utilization) + ); +} + export function formatAllowanceResetAt(resetsAt: string): string { // @effect-diagnostics-next-line globalDate:off const date = new Date(resetsAt); @@ -197,9 +222,13 @@ export interface SubscriptionAllowanceCardModel { readonly accountLabel: string | null; readonly status: SubscriptionAllowance["status"]; readonly message: string; + /** Derived from connection, availability, and freshness for UI presentation. */ + readonly isCurrent: boolean; readonly updatedAt: string | null; readonly windows: SubscriptionAllowance["windows"]; readonly credits: NonNullable | null; + readonly spendingControl: NonNullable | null; + readonly extraUsage: NonNullable | null; readonly hasMultipleReadings: boolean; readonly sources: readonly SubscriptionAllowanceSourceModel[]; } @@ -238,9 +267,12 @@ export function presentSubscriptionAllowanceGroup( accountLabel: group.accountLabel, status: allowance.status, message: formatAllowanceUnavailableMessage(allowance.provider, allowance.message), + isCurrent: isSubscriptionAllowanceSourceCurrent(displayedSource), updatedAt: allowance.updatedAt ?? null, windows: allowance.windows, credits: allowance.credits ?? null, + spendingControl: allowance.spendingControl ?? null, + extraUsage: allowance.extraUsage ?? null, hasMultipleReadings: group.hasMultipleReadings, sources: group.sources.map((source, index) => ({ key: `${source.environmentId}:${source.allowance.instanceId}:${index}`, From eb137341982fe3fd337dd43f30cb7fcb6e55ad54 Mon Sep 17 00:00:00 2001 From: naveed949 Date: Thu, 13 Aug 2026 16:01:49 +0500 Subject: [PATCH 19/29] fix(usage): remove redundant allowance currentness labels - Keep observation age under the existing Updated timestamp - Simplify web and mobile source status presentation and documentation --- .../src/features/usage/UsageRouteScreen.tsx | 19 ++---------------- apps/web/src/components/usage/UsagePage.tsx | 20 ++----------------- docs/internals/subscription-allowance.md | 2 +- docs/user/usage.md | 13 ++++++------ 4 files changed, 11 insertions(+), 43 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 5617cb88ea22..de0c62725713 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -374,18 +374,8 @@ function SubscriptionAllowanceCard(props: { ) : null} - - {model.status === "available" && !model.isCurrent - ? updatedAt === null - ? "Not current" - : `Not current · ${updatedAt}` - : (updatedAt ?? "Updated time unavailable")} + + {updatedAt ?? "Updated time unavailable"} @@ -538,11 +528,6 @@ function AllowanceSources(props: { {source.environmentLabel} · {source.instanceId} · {source.connectionLabel} ·{" "} {source.status} - {source.status === "unavailable" - ? "" - : source.isCurrent - ? " · current" - : " · not current"} {source.isEffective ? " · shown" : ""} ))} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 2a05421c0703..006ee6fb5f7c 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -666,19 +666,7 @@ function SubscriptionAllowanceCard({ ) : null}
- - {allowance.status === "available" && !allowance.isCurrent - ? updatedAt === null - ? "Not current" - : `Not current · ${updatedAt}` - : (updatedAt ?? "Updated time unavailable")} - + {updatedAt ?? "Updated time unavailable"}
@@ -798,11 +786,7 @@ function SubscriptionAllowanceCard({ {source.environmentLabel} · {source.instanceId} ·{" "} {source.connectionLabel.toLowerCase()} - {source.status === "unavailable" - ? " · unavailable" - : source.isCurrent - ? " · current" - : " · not current"} + {source.status === "unavailable" ? " · unavailable" : ""} {source.isEffective ? " · shown" : ""} ))} diff --git a/docs/internals/subscription-allowance.md b/docs/internals/subscription-allowance.md index 2f10e11fdea9..b3471e683c2c 100644 --- a/docs/internals/subscription-allowance.md +++ b/docs/internals/subscription-allowance.md @@ -104,7 +104,7 @@ observation by deterministic freshness, completeness, delivery, connection, and it never combines one environment’s windows with another environment’s credits. Freshness remains an internal selection input: card presentation models expose the observation timestamp and derived source currentness, not raw freshness. Web and mobile communicate age with **Updated** and mark -retained available data **Not current**. Leaving Subscription unmounts the allowance consumer, +do not expose a second currentness label. Leaving Subscription unmounts the allowance consumer, which releases demand while leaving Historical independent. ## Evidence and release boundary diff --git a/docs/user/usage.md b/docs/user/usage.md index 100b20327d42..e41524114e40 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -12,16 +12,15 @@ reset times, credits, and spending controls only when the provider supplies them a quota, reset, account status, or combined cross-provider total. Use **Refresh** on web or pull down on the mobile Usage screen to request a new provider observation. -The **Updated** timestamp communicates the age of the displayed observation. Data retained after a -failed refresh, a passed reset time, or a disconnected environment is marked **Not current** while -it remains visible. T3 Code does not reset percentages locally. +The **Updated** timestamp communicates the age of the displayed observation, including data retained +after a failed refresh, a passed reset time, or a disconnected environment. T3 Code does not reset +percentages locally. Leaving the Subscription view stops its live allowance updates; returning to it requests a fresh reading. If an environment is offline or reconnecting, its last known reading remains identified -by its connection state and is not presented as current. Multiple environments and provider -instances are shown separately unless the provider supplies an exact, privacy-safe identity that -proves they share one allowance account; even then, the displayed group uses one whole source and -never blends windows from several sources. +by its connection state. Multiple environments and provider instances are shown separately unless +the provider supplies an exact, privacy-safe identity that proves they share one allowance account; +even then, the displayed group uses one whole source and never blends windows from several sources. When Claude does not provide subscription limits, the Claude section remains visible with this explanation: From 6af7b13b6bb9f4a03c08b4e9fbf3d27c7cae32af Mon Sep 17 00:00:00 2001 From: Naveed Iqbal Date: Fri, 14 Aug 2026 00:07:50 +0500 Subject: [PATCH 20/29] fix(usage): address allowance review feedback --- .../src/features/usage/UsageRouteScreen.tsx | 20 +++++- .../src/features/usage/usageAllowance.test.ts | 4 +- .../SubscriptionAllowanceService.test.ts | 49 ++++++++++++++- .../src/usage/SubscriptionAllowanceService.ts | 19 ++++++ apps/web/src/components/usage/UsagePage.tsx | 62 +++++++++++++++---- docs/user/usage.md | 6 +- .../src/state/subscriptionAllowance.ts | 6 ++ 7 files changed, 146 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index de0c62725713..fa5c12df7edc 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -374,8 +374,18 @@ function SubscriptionAllowanceCard(props: { ) : null} - - {updatedAt ?? "Updated time unavailable"} + + {model.isStale + ? updatedAt === null + ? "Stale" + : `Stale · ${updatedAt}` + : (updatedAt ?? "Updated time unavailable")} @@ -484,6 +494,11 @@ function AllowanceMetadata(props: { readonly model: SubscriptionAllowanceCardMod {spendingControl.remainingPercent}% remaining ) : null} + {spendingControl.resetsAt !== undefined && spendingControl.resetsAt !== null ? ( + + Resets {formatAllowanceResetAt(spendingControl.resetsAt)} + + ) : null} {spendingControl.reached === true ? ( Limit reached ) : null} @@ -528,6 +543,7 @@ function AllowanceSources(props: { {source.environmentLabel} · {source.instanceId} · {source.connectionLabel} ·{" "} {source.status} + {source.isStale ? " · stale" : ""} {source.isEffective ? " · shown" : ""} ))} diff --git a/apps/mobile/src/features/usage/usageAllowance.test.ts b/apps/mobile/src/features/usage/usageAllowance.test.ts index b9057f4c2e48..c52988cce38b 100644 --- a/apps/mobile/src/features/usage/usageAllowance.test.ts +++ b/apps/mobile/src/features/usage/usageAllowance.test.ts @@ -176,7 +176,7 @@ describe("mobile subscription allowance presentation", () => { expect(model.accountLabel).toBeNull(); }); - it("derives source currentness without exposing raw freshness to the UI", () => { + it("derives stale and current presentation state without exposing raw freshness", () => { const source = group().effectiveSource!; const staleSource = { ...source, @@ -195,9 +195,11 @@ describe("mobile subscription allowance presentation", () => { ); expect(model).not.toHaveProperty("freshness"); + expect(model.isStale).toBe(true); expect(model.isCurrent).toBe(false); expect(model.hasMultipleReadings).toBe(true); expect(model.sources[0]).not.toHaveProperty("freshness"); + expect(model.sources[0]?.isStale).toBe(true); expect(model.sources[0]?.isCurrent).toBe(false); }); diff --git a/apps/server/src/usage/SubscriptionAllowanceService.test.ts b/apps/server/src/usage/SubscriptionAllowanceService.test.ts index cb68a855b066..1ef2a03ee3c0 100644 --- a/apps/server/src/usage/SubscriptionAllowanceService.test.ts +++ b/apps/server/src/usage/SubscriptionAllowanceService.test.ts @@ -90,13 +90,34 @@ describe("subscription allowance lifecycle helpers", () => { { scope: "secondary" as const, usedPercent: 5, windowDurationMins: 1_440 }, ], credits: { balance: "10", hasCredits: true, unlimited: false }, + spendingControl: { + reached: false, + limit: "100", + remainingPercent: 90, + resetsAt: readAt, + used: "10", + }, + extraUsage: { + isEnabled: true, + monthlyLimit: 100, + usedCredits: 10, + utilization: 10, + currency: "USD", + }, }; const update = { provider: "codex" as const, instanceId, status: "available" as const, windows: [{ scope: "primary" as const, usedPercent: 31 }], - credits: null, + credits: { hasCredits: false, unlimited: false }, + spendingControl: { reached: true }, + extraUsage: { + isEnabled: true, + monthlyLimit: 100, + usedCredits: 20, + utilization: 20, + }, }; expect(foldSubscriptionAllowance(previous, update)).toEqual({ @@ -107,10 +128,34 @@ describe("subscription allowance lifecycle helpers", () => { { scope: "primary", usedPercent: 31, windowDurationMins: 300 }, { scope: "secondary", usedPercent: 5, windowDurationMins: 1_440 }, ], - credits: null, + credits: { balance: "10", hasCredits: false, unlimited: false }, + spendingControl: { + reached: true, + limit: "100", + remainingPercent: 90, + resetsAt: readAt, + used: "10", + }, + extraUsage: { + isEnabled: true, + monthlyLimit: 100, + usedCredits: 20, + utilization: 20, + currency: "USD", + }, }); }); + it("lets explicit null clear a previously reported nested allowance field", () => { + const previous = { + ...allowance, + credits: { balance: "10", hasCredits: true, unlimited: false }, + }; + const update = { ...allowance, credits: null }; + + expect(foldSubscriptionAllowance(previous, update).credits).toBeNull(); + }); + it("marks a usable record stale after its provider reset without changing utilization", () => { const snapshot = { readAt, diff --git a/apps/server/src/usage/SubscriptionAllowanceService.ts b/apps/server/src/usage/SubscriptionAllowanceService.ts index 2f8c607259fa..987816aa7687 100644 --- a/apps/server/src/usage/SubscriptionAllowanceService.ts +++ b/apps/server/src/usage/SubscriptionAllowanceService.ts @@ -162,10 +162,29 @@ export function foldSubscriptionAllowance( } const windows = Array.from(windowsByScope.values()); + const foldNested = ( + previousValue: Value | null | undefined, + updateValue: Value | null, + ): Value | null => + updateValue === null || previousValue === null || previousValue === undefined + ? updateValue + : { ...previousValue, ...updateValue }; + return { ...previous, ...update, windows, + ...(update.credits === undefined + ? {} + : { credits: foldNested(previous.credits, update.credits) }), + ...(update.spendingControl === undefined + ? {} + : { + spendingControl: foldNested(previous.spendingControl, update.spendingControl), + }), + ...(update.extraUsage === undefined + ? {} + : { extraUsage: foldNested(previous.extraUsage, update.extraUsage) }), }; } diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 006ee6fb5f7c..2fee854d0b42 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -53,11 +53,32 @@ const WINDOW_OPTIONS = [ { days: 90, label: "90 days" }, ] as const; +type HistoricalWindowSelection = { + readonly days: number; + readonly window: ReturnType; +}; + +type HistoricalBreakdown = "model" | "time"; + export function UsagePage() { const [view, setView] = useState(DEFAULT_USAGE_VIEW); + const [windowSelection, setWindowSelection] = useState(() => ({ + days: 30, + window: makeWindow(30), + })); + const [metric, setMetric] = useState("cost"); + const [breakdown, setBreakdown] = useState("model"); return view === "historical" ? ( - + ) : ( ); @@ -65,15 +86,21 @@ export function UsagePage() { function HistoricalUsagePage({ onViewChange, + windowSelection, + onWindowSelectionChange, + metric, + onMetricChange, + breakdown, + onBreakdownChange, }: { readonly onViewChange: (view: UsageView) => void; + readonly windowSelection: HistoricalWindowSelection; + readonly onWindowSelectionChange: (selection: HistoricalWindowSelection) => void; + readonly metric: UsageChartMetric; + readonly onMetricChange: (metric: UsageChartMetric) => void; + readonly breakdown: HistoricalBreakdown; + readonly onBreakdownChange: (breakdown: HistoricalBreakdown) => void; }) { - const [windowSelection, setWindowSelection] = useState(() => ({ - days: 30, - window: makeWindow(30), - })); - const [metric, setMetric] = useState("cost"); - const [breakdown, setBreakdown] = useState<"model" | "time">("model"); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; const { merged, environments, isPending, isPartial, refresh } = useUsage(window); @@ -115,7 +142,7 @@ function HistoricalUsagePage({ const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens; const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; const selectWindow = (days: number) => { - setWindowSelection({ + onWindowSelectionChange({ days, window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), }); @@ -130,7 +157,7 @@ function HistoricalUsagePage({ ) { refresh(); } else { - setWindowSelection({ days: windowDays, window: nextWindow }); + onWindowSelectionChange({ days: windowDays, window: nextWindow }); } }; @@ -284,7 +311,7 @@ function HistoricalUsagePage({
@@ -749,6 +782,10 @@ function SubscriptionAllowanceCard({ allowance.spendingControl.remainingPercent !== null ? ( {allowance.spendingControl.remainingPercent}% remaining ) : null} + {allowance.spendingControl.resetsAt !== undefined && + allowance.spendingControl.resetsAt !== null ? ( + Resets {formatAllowanceResetAt(allowance.spendingControl.resetsAt)} + ) : null} {allowance.spendingControl.reached === true ? Limit reached : null} ) : null} @@ -787,6 +824,7 @@ function SubscriptionAllowanceCard({ {source.environmentLabel} · {source.instanceId} ·{" "} {source.connectionLabel.toLowerCase()} {source.status === "unavailable" ? " · unavailable" : ""} + {source.isStale ? " · stale" : ""} {source.isEffective ? " · shown" : ""} ))} diff --git a/docs/user/usage.md b/docs/user/usage.md index e41524114e40..dbee38fc00b8 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -12,9 +12,9 @@ reset times, credits, and spending controls only when the provider supplies them a quota, reset, account status, or combined cross-provider total. Use **Refresh** on web or pull down on the mobile Usage screen to request a new provider observation. -The **Updated** timestamp communicates the age of the displayed observation, including data retained -after a failed refresh, a passed reset time, or a disconnected environment. T3 Code does not reset -percentages locally. +The **Updated** timestamp communicates the age of the displayed observation. Data retained after a +failed refresh or passed reset time is marked **Stale** while it remains visible. T3 Code does not +reset percentages locally. Leaving the Subscription view stops its live allowance updates; returning to it requests a fresh reading. If an environment is offline or reconnecting, its last known reading remains identified diff --git a/packages/client-runtime/src/state/subscriptionAllowance.ts b/packages/client-runtime/src/state/subscriptionAllowance.ts index 32528bc9f4bb..28985f96e79c 100644 --- a/packages/client-runtime/src/state/subscriptionAllowance.ts +++ b/packages/client-runtime/src/state/subscriptionAllowance.ts @@ -211,6 +211,8 @@ export interface SubscriptionAllowanceSourceModel { readonly instanceId: string; readonly connectionLabel: string; readonly status: SubscriptionAllowance["status"]; + /** Retained provider data from before a failed refresh or passed reset. */ + readonly isStale: boolean; /** Derived from connection, availability, and freshness for UI presentation. */ readonly isCurrent: boolean; readonly isEffective: boolean; @@ -222,6 +224,8 @@ export interface SubscriptionAllowanceCardModel { readonly accountLabel: string | null; readonly status: SubscriptionAllowance["status"]; readonly message: string; + /** Retained provider data from before a failed refresh or passed reset. */ + readonly isStale: boolean; /** Derived from connection, availability, and freshness for UI presentation. */ readonly isCurrent: boolean; readonly updatedAt: string | null; @@ -267,6 +271,7 @@ export function presentSubscriptionAllowanceGroup( accountLabel: group.accountLabel, status: allowance.status, message: formatAllowanceUnavailableMessage(allowance.provider, allowance.message), + isStale: allowance.freshness === "stale", isCurrent: isSubscriptionAllowanceSourceCurrent(displayedSource), updatedAt: allowance.updatedAt ?? null, windows: allowance.windows, @@ -280,6 +285,7 @@ export function presentSubscriptionAllowanceGroup( instanceId: source.allowance.instanceId, connectionLabel: formatAllowanceConnectionPhase(source.connectionPhase), status: source.allowance.status, + isStale: source.allowance.freshness === "stale", isCurrent: isSubscriptionAllowanceSourceCurrent(source), isEffective: source === displayedSource, })), From 2cb08f11f01a9843bef8580837c1fa1075717570 Mon Sep 17 00:00:00 2001 From: naveed949 Date: Fri, 14 Aug 2026 16:46:29 +0500 Subject: [PATCH 21/29] fix(usage): address remaining allowance review findings --- .../src/usage/SubscriptionAllowanceService.test.ts | 8 +++----- .../src/usage/SubscriptionAllowanceService.ts | 9 +++++++++ apps/server/src/ws.ts | 5 +++-- apps/web/src/components/usage/UsagePage.test.tsx | 13 ++++++++++++- apps/web/src/components/usage/UsagePage.tsx | 8 +++++--- 5 files changed, 32 insertions(+), 11 deletions(-) diff --git a/apps/server/src/usage/SubscriptionAllowanceService.test.ts b/apps/server/src/usage/SubscriptionAllowanceService.test.ts index 1ef2a03ee3c0..cf736a254c1c 100644 --- a/apps/server/src/usage/SubscriptionAllowanceService.test.ts +++ b/apps/server/src/usage/SubscriptionAllowanceService.test.ts @@ -31,6 +31,7 @@ import { foldSubscriptionAllowance, make, markSubscriptionAllowanceSnapshotStale, + streamSubscriptionAllowanceSnapshots, type SubscriptionAllowanceProviderInstance, } from "./SubscriptionAllowanceService.ts"; @@ -195,7 +196,7 @@ describe("subscription allowance lifecycle helpers", () => { }); describe("SubscriptionAllowanceService", () => { - it.effect("acquires providers materialized before first demand", () => + it.effect("does not expose the pre-refresh empty snapshot", () => Effect.gen(function* () { const changes = yield* PubSub.unbounded(); const instances = yield* Ref.make>([]); @@ -215,10 +216,7 @@ describe("SubscriptionAllowanceService", () => { Effect.gen(function* () { const subscription = yield* service.subscribe; const published = Option.getOrThrow( - yield* subscription.changes.pipe( - Stream.filter((snapshot) => snapshot.allowances.length > 0), - Stream.runHead, - ), + yield* streamSubscriptionAllowanceSnapshots(subscription).pipe(Stream.runHead), ); expect(published.allowances).toHaveLength(1); diff --git a/apps/server/src/usage/SubscriptionAllowanceService.ts b/apps/server/src/usage/SubscriptionAllowanceService.ts index 987816aa7687..668a27f53ab9 100644 --- a/apps/server/src/usage/SubscriptionAllowanceService.ts +++ b/apps/server/src/usage/SubscriptionAllowanceService.ts @@ -204,6 +204,15 @@ export class SubscriptionAllowanceService extends Context.Service< } >()("t3/usage/SubscriptionAllowanceService") {} +export function streamSubscriptionAllowanceSnapshots(input: { + readonly latest: SubscriptionAllowanceSnapshot; + readonly changes: Stream.Stream; +}): Stream.Stream { + return input.latest.allowances.length === 0 + ? input.changes + : Stream.concat(Stream.make(input.latest), input.changes); +} + export const make = Effect.gen(function* () { const registry = yield* ProviderInstanceRegistry; const providerService = yield* ProviderService; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5b87bcad043f..8907f5700def 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2294,8 +2294,9 @@ const makeWsRpcLayer = ( observeRpcStream( WS_METHODS.subscribeSubscriptionAllowance, Stream.unwrap( - Effect.map(subscriptionAllowance.subscribe, ({ latest, changes }) => - Stream.concat(Stream.make(latest), changes), + Effect.map( + subscriptionAllowance.subscribe, + SubscriptionAllowanceService.streamSubscriptionAllowanceSnapshots, ), ), { "rpc.aggregate": "server" }, diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 46d36733ea0d..10a6e54fbfd4 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -1,11 +1,12 @@ import { Children, type ReactElement, type ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_USAGE_VIEW, type UsageView, } from "@t3tools/client-runtime/state/subscription-allowance"; -import { UsageViewTabs } from "./UsagePage"; +import { UsageSkeleton, UsageViewTabs } from "./UsagePage"; describe("UsagePage defaults", () => { it("opens on the Subscription view", () => { @@ -34,3 +35,13 @@ describe("UsageViewTabs", () => { expect(selected).toEqual(["historical"]); }); }); + +describe("UsageSkeleton", () => { + it("matches the chart resolution while historical usage is loading", () => { + const hourlyMarkup = renderToStaticMarkup(); + const dailyMarkup = renderToStaticMarkup(); + + expect(hourlyMarkup).toContain("Hourly cost"); + expect(dailyMarkup).toContain("Daily cost"); + }); +}); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 2fee854d0b42..3286a637beec 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -234,7 +234,7 @@ function HistoricalUsagePage({ {settling ? ( <> {environments.length > 1 ? : null} - + ) : ( <> @@ -971,7 +971,7 @@ const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44 * chart and metrics strip. No shimmer; blocks fill in exactly once when the * last device answers. */ -function UsageSkeleton() { +export function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) { return ( <>
@@ -1000,7 +1000,9 @@ function UsageSkeleton() {
-

Daily cost

+

+ {resolution === "hour" ? "Hourly" : "Daily"} cost +

{/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a relayout when the real chart swaps in. */}
From e4c8f3df72363f36c3fc8d858af4ff9f3be39f4c Mon Sep 17 00:00:00 2001 From: naveed949 Date: Fri, 14 Aug 2026 19:03:14 +0500 Subject: [PATCH 22/29] fix(usage): deliver completed empty allowance snapshots --- apps/server/src/server.test.ts | 7 ++- .../SubscriptionAllowanceService.test.ts | 27 ++++++++ .../src/usage/SubscriptionAllowanceService.ts | 61 +++++++++++++++---- docs/user/usage.md | 4 +- 4 files changed, 85 insertions(+), 14 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 0df348d4bbda..dd85f10b2dbf 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -835,6 +835,7 @@ const buildAppUnderTest = (options?: { Layer.mock(SubscriptionAllowanceService.SubscriptionAllowanceService)({ subscribe: Effect.succeed({ latest: { readAt: "1970-01-01T00:00:00.000Z", allowances: [] }, + hasCompletedRefresh: true, changes: Stream.empty, }), refresh: Effect.succeed({ @@ -4702,7 +4703,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { subscriptionAllowance: { - subscribe: Effect.succeed({ latest: snapshot, changes: Stream.empty }), + subscribe: Effect.succeed({ + latest: snapshot, + hasCompletedRefresh: true, + changes: Stream.empty, + }), }, }, }); diff --git a/apps/server/src/usage/SubscriptionAllowanceService.test.ts b/apps/server/src/usage/SubscriptionAllowanceService.test.ts index cf736a254c1c..b8d22d65f41e 100644 --- a/apps/server/src/usage/SubscriptionAllowanceService.test.ts +++ b/apps/server/src/usage/SubscriptionAllowanceService.test.ts @@ -196,6 +196,33 @@ describe("subscription allowance lifecycle helpers", () => { }); describe("SubscriptionAllowanceService", () => { + it.effect("replays a completed empty refresh to a later subscriber", () => + Effect.gen(function* () { + const registryLayer = Layer.succeed(ProviderInstanceRegistry, { + getInstance: () => Effect.succeed(undefined), + listInstances: Effect.succeed([]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }); + const service = yield* makeService(registryLayer); + yield* service.refresh; + + yield* Effect.scoped( + Effect.gen(function* () { + const subscription = yield* service.subscribe; + const delivered = Option.getOrThrow( + yield* streamSubscriptionAllowanceSnapshots(subscription).pipe(Stream.runHead), + ); + + expect(delivered.allowances).toEqual([]); + }), + ); + }), + ); + it.effect("does not expose the pre-refresh empty snapshot", () => Effect.gen(function* () { const changes = yield* PubSub.unbounded(); diff --git a/apps/server/src/usage/SubscriptionAllowanceService.ts b/apps/server/src/usage/SubscriptionAllowanceService.ts index 668a27f53ab9..49e66d194822 100644 --- a/apps/server/src/usage/SubscriptionAllowanceService.ts +++ b/apps/server/src/usage/SubscriptionAllowanceService.ts @@ -45,6 +45,7 @@ type ReadableSubscriptionAllowanceProviderInstance = SubscriptionAllowanceProvid interface AllowanceServiceState { readonly snapshot: SubscriptionAllowanceSnapshot; + readonly hasCompletedRefresh: boolean; readonly instances: ReadonlyMap; readonly demandCount: number; readonly liveScope: Option.Option; @@ -52,6 +53,11 @@ interface AllowanceServiceState { type RefreshFlight = Deferred.Deferred>; +interface SubscriptionAllowanceSnapshotDelivery { + readonly snapshot: SubscriptionAllowanceSnapshot; + readonly hasCompletedRefresh: boolean; +} + const unavailableAllowance = (input: { readonly instanceId: ProviderInstanceId; readonly provider: SubscriptionAllowance["provider"]; @@ -195,6 +201,7 @@ export class SubscriptionAllowanceService extends Context.Service< { readonly latest: SubscriptionAllowanceSnapshot; readonly changes: Stream.Stream; + readonly hasCompletedRefresh: boolean; }, never, Scope.Scope @@ -207,10 +214,11 @@ export class SubscriptionAllowanceService extends Context.Service< export function streamSubscriptionAllowanceSnapshots(input: { readonly latest: SubscriptionAllowanceSnapshot; readonly changes: Stream.Stream; + readonly hasCompletedRefresh: boolean; }): Stream.Stream { - return input.latest.allowances.length === 0 - ? input.changes - : Stream.concat(Stream.make(input.latest), input.changes); + return input.hasCompletedRefresh || input.latest.allowances.length > 0 + ? Stream.concat(Stream.make(input.latest), input.changes) + : input.changes; } export const make = Effect.gen(function* () { @@ -218,11 +226,12 @@ export const make = Effect.gen(function* () { const providerService = yield* ProviderService; const stateMutex = yield* Semaphore.make(1); const liveMutex = yield* Semaphore.make(1); - const changes = yield* PubSub.sliding(8); + const changes = yield* PubSub.sliding(8); const initialReadAt = DateTime.formatIso(yield* DateTime.now); const initialInstances = yield* registry.listInstances; const state = yield* Ref.make({ snapshot: { readAt: initialReadAt, allowances: [] }, + hasCompletedRefresh: false, instances: new Map( initialInstances.map((instance) => [instance.instanceId, instance] as const), ), @@ -231,16 +240,24 @@ export const make = Effect.gen(function* () { }); const refreshFlight = yield* Ref.make>(Option.none()); - const publishUnlocked = (snapshot: SubscriptionAllowanceSnapshot) => + const publishUnlocked = (snapshot: SubscriptionAllowanceSnapshot, completesRefresh = false) => Effect.gen(function* () { const current = yield* Ref.get(state); const nextSnapshot = markSubscriptionAllowanceSnapshotStale( snapshot, yield* Clock.currentTimeMillis, ); - yield* Ref.set(state, { ...current, snapshot: nextSnapshot }); + const hasCompletedRefresh = current.hasCompletedRefresh || completesRefresh; + yield* Ref.set(state, { + ...current, + snapshot: nextSnapshot, + hasCompletedRefresh, + }); if (current.demandCount > 0) { - yield* PubSub.publish(changes, nextSnapshot); + yield* PubSub.publish(changes, { + snapshot: nextSnapshot, + hasCompletedRefresh, + }); } return nextSnapshot; }); @@ -339,7 +356,7 @@ export const make = Effect.gen(function* () { } return []; }); - return yield* publishUnlocked({ readAt, allowances: acceptedAllowances }); + return yield* publishUnlocked({ readAt, allowances: acceptedAllowances }, true); }), ); }); @@ -548,15 +565,33 @@ export const make = Effect.gen(function* () { if (nextSnapshot !== current.snapshot) { yield* Ref.set(state, { ...current, snapshot: nextSnapshot }); } - return nextSnapshot; + return { + snapshot: nextSnapshot, + hasCompletedRefresh: current.hasCompletedRefresh, + }; }), ); const subscribe = subscribeBeforeSnapshotWithoutMutex( changes, Effect.gen(function* () { yield* Effect.acquireRelease(acquireDemand, () => releaseDemand); - return markSnapshotDeliveredFromCache(yield* latest); + const delivery = yield* latest; + return { + ...delivery, + snapshot: markSnapshotDeliveredFromCache(delivery.snapshot), + }; }), + ).pipe( + Effect.map(({ latest, changes }) => ({ + latest: latest.snapshot, + hasCompletedRefresh: latest.hasCompletedRefresh, + changes: changes.pipe( + Stream.filter( + (delivery) => delivery.hasCompletedRefresh || delivery.snapshot.allowances.length > 0, + ), + Stream.map((delivery) => delivery.snapshot), + ), + })), ); return SubscriptionAllowanceService.of({ @@ -575,7 +610,11 @@ const emptySnapshot: SubscriptionAllowanceSnapshot = { export const layerTest = Layer.succeed( SubscriptionAllowanceService, SubscriptionAllowanceService.of({ - subscribe: Effect.succeed({ latest: emptySnapshot, changes: Stream.empty }), + subscribe: Effect.succeed({ + latest: emptySnapshot, + hasCompletedRefresh: true, + changes: Stream.empty, + }), refresh: Effect.succeed(emptySnapshot), }), ); diff --git a/docs/user/usage.md b/docs/user/usage.md index dbee38fc00b8..ac66e2ca14b6 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -13,8 +13,8 @@ a quota, reset, account status, or combined cross-provider total. Use **Refresh** on web or pull down on the mobile Usage screen to request a new provider observation. The **Updated** timestamp communicates the age of the displayed observation. Data retained after a -failed refresh or passed reset time is marked **Stale** while it remains visible. T3 Code does not -reset percentages locally. +failed refresh or passed reset time keeps its existing **Updated** timestamp while it remains +visible. T3 Code does not reset percentages locally. Leaving the Subscription view stops its live allowance updates; returning to it requests a fresh reading. If an environment is offline or reconnecting, its last known reading remains identified From 6b1aa6949785548dbe6bb2b23f265f555148c59f Mon Sep 17 00:00:00 2001 From: naveed949 Date: Sat, 15 Aug 2026 18:03:04 +0500 Subject: [PATCH 23/29] fix(usage): address remaining review findings --- .../src/features/usage/UsageRouteScreen.tsx | 10 +++-- .../src/provider/Layers/CodexAdapter.test.ts | 44 +++++++++++++++++++ .../src/provider/Layers/CodexAdapter.ts | 10 +++-- apps/web/src/components/usage/UsagePage.tsx | 22 +++++----- .../src/state/subscriptionAllowance.test.ts | 8 ++++ .../src/state/subscriptionAllowance.ts | 3 +- 6 files changed, 77 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index fa5c12df7edc..20ebf1755570 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -464,6 +464,10 @@ function AllowanceMetadata(props: { readonly model: SubscriptionAllowanceCardMod const credits = model.credits; const spendingControl = model.spendingControl; const extraUsage = model.extraUsage; + const spendingControlReset = + spendingControl?.resetsAt === undefined || spendingControl.resetsAt === null + ? null + : formatAllowanceResetAt(spendingControl.resetsAt); return ( <> @@ -494,10 +498,8 @@ function AllowanceMetadata(props: { readonly model: SubscriptionAllowanceCardMod {spendingControl.remainingPercent}% remaining ) : null} - {spendingControl.resetsAt !== undefined && spendingControl.resetsAt !== null ? ( - - Resets {formatAllowanceResetAt(spendingControl.resetsAt)} - + {spendingControlReset !== null ? ( + Resets {spendingControlReset} ) : null} {spendingControl.reached === true ? ( Limit reached diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 5358716aabe4..b8e5e8127ee2 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -47,6 +47,7 @@ import { type CodexThreadSnapshot, } from "./CodexSessionRuntime.ts"; import { makeCodexAdapter } from "./CodexAdapter.ts"; +import { mapCodexRateLimitsUpdate } from "./CodexAllowanceReader.ts"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); // Test-local service tag so the rest of the file can keep using `yield* CodexAdapter`. @@ -1152,6 +1153,49 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("unwraps Codex rate-limit notifications for allowance folding", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-codex-rate-limits-updated"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "account/rateLimits/updated", + payload: { + rateLimits: { + primary: { usedPercent: 37 }, + }, + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") return; + NodeAssert.equal(firstEvent.value.type, "account.rate-limits.updated"); + if (firstEvent.value.type !== "account.rate-limits.updated") return; + + NodeAssert.deepEqual(firstEvent.value.payload.rateLimits, { + primary: { usedPercent: 37 }, + }); + NodeAssert.deepEqual( + mapCodexRateLimitsUpdate({ + instanceId: ProviderInstanceId.make("codex"), + event: firstEvent.value, + }), + { + provider: "codex", + instanceId: ProviderInstanceId.make("codex"), + status: "available", + windows: [{ scope: "primary", usedPercent: 37 }], + }, + ); + }), + ); + // Production calls startSession from a request fiber that finishes as soon as // the session exists. `Effect.forkChild` made the runtime event consumer a // child of that fiber, and Effect interrupts a fiber's children when it diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 065156d36473..38add88769ee 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1393,15 +1393,17 @@ function mapToRuntimeEvents( } if (event.method === "account/rateLimits/updated") { - if (!readPayload(EffectCodexSchema.V2AccountRateLimitsUpdatedNotification, event.payload)) { - return []; - } + const payload = readPayload( + EffectCodexSchema.V2AccountRateLimitsUpdatedNotification, + event.payload, + ); + if (!payload) return []; return [ { type: "account.rate-limits.updated", ...runtimeEventBase(event, canonicalThreadId), payload: { - rateLimits: event.payload ?? {}, + rateLimits: payload.rateLimits, }, }, ]; diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index a3f0e7d2dfcf..126ffa458054 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -39,7 +39,6 @@ import { } from "@t3tools/shared/usageFormat"; import { Button } from "../ui/button"; import { ScrollArea } from "../ui/scroll-area"; -import { Button } from "../ui/button"; import { SidebarInset } from "../ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb"; @@ -511,7 +510,7 @@ function UsagePageFrame({ children }: { readonly children: ReactNode }) { {!isElectron && (
@@ -608,8 +607,8 @@ function SubscriptionUsagePage({ render={
+ + )} + + ); } -function UsagePageFrame({ children }: { readonly children: ReactNode }) { - const topbarContent = ( - - -

Usage

-
-
- ); - +const DEFAULT_USAGE_TOPBAR_CONTENT = ( + + +

Usage

+
+
+); + +function UsagePageFrame({ + children, + topbarContent = DEFAULT_USAGE_TOPBAR_CONTENT, +}: { + readonly children: ReactNode; + readonly topbarContent?: ReactNode; +}) { return (
@@ -531,7 +528,7 @@ export function UsageViewTabs({
{USAGE_VIEW_OPTIONS.map((option) => (
-

Subscription allowance

+

Subscription allowance

Usage limits and reset times from your provider.

@@ -588,8 +585,8 @@ function SubscriptionUsagePage({ render={ + ))} -
+ ); } From b8c451c98831e9b4273a9ca13dd6049c45fd9993 Mon Sep 17 00:00:00 2001 From: Naveed Iqbal Date: Sat, 22 Aug 2026 09:44:18 +0500 Subject: [PATCH 26/29] fix(usage): address remaining review feedback --- apps/web/src/components/usage/UsagePage.test.tsx | 12 ++++++++---- apps/web/src/components/usage/UsagePage.tsx | 14 ++++++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 633ab28cfaa9..24109bc9d7fb 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -45,11 +45,15 @@ describe("UsageViewTabs", () => { }); describe("UsageSkeleton", () => { - it("matches the chart resolution while historical usage is loading", () => { - const hourlyMarkup = renderToStaticMarkup(); - const dailyMarkup = renderToStaticMarkup(); + it("matches the chart resolution and metric while historical usage is loading", () => { + const hourlyMarkup = renderToStaticMarkup( + , + ); + const dailyMarkup = renderToStaticMarkup( + , + ); expect(hourlyMarkup).toContain("Hourly cost"); - expect(dailyMarkup).toContain("Daily cost"); + expect(dailyMarkup).toContain("Daily processed tokens"); }); }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 1df1ba6a97f2..ca473818d22e 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -262,7 +262,7 @@ function HistoricalUsagePage({ {settling ? ( <> {environments.length > 1 ? : null} - + ) : ( <> @@ -585,7 +585,6 @@ function SubscriptionUsagePage({ isRefreshing ? "Refreshing subscription usage" : "Refresh subscription usage" } aria-busy={isRefreshing} - disabled={isRefreshing} /> } > @@ -941,7 +940,13 @@ function UsageDeviceStrip({ * Static stand-in with the loaded page's shape. No shimmer; blocks fill in * exactly once when the last device answers. */ -export function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) { +export function UsageSkeleton({ + resolution, + metric, +}: { + readonly resolution: "day" | "hour"; + readonly metric: UsageChartMetric; +}) { return ( <>
@@ -971,7 +976,8 @@ export function UsageSkeleton({ resolution }: { readonly resolution: "day" | "ho

- {resolution === "hour" ? "Hourly" : "Daily"} cost + {resolution === "hour" ? "Hourly" : "Daily"}{" "} + {metric === "tokens" ? "processed tokens" : "cost"}

From 406cf9506d4382547bb5b1ab1cb6d780ea0f8234 Mon Sep 17 00:00:00 2001 From: Naveed Iqbal Date: Sat, 22 Aug 2026 10:01:27 +0500 Subject: [PATCH 27/29] fix(usage): format merged usage tests --- apps/web/src/components/usage/UsagePage.test.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 4e98b4837643..3c0a48ff7661 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -40,7 +40,9 @@ vi.mock("react", async (importOriginal) => { }); vi.mock("../../env", () => ({ isElectron: false })); -vi.mock("../../hooks/useNowMinute", () => ({ useNowMinute: () => "2026-08-11T12:37" })); +vi.mock("../../hooks/useNowMinute", () => ({ + useNowMinute: () => "2026-08-11T12:37", +})); vi.mock("../../state/subscriptionAllowance", () => ({ useSubscriptionAllowance: () => ({ groups: [], @@ -148,8 +150,14 @@ describe("UsageViewTabs", () => { expect(output.props.variant).toBe("segmented"); expect(output.props.value).toEqual(["subscription"]); expect(toggles.map((toggle) => toggle.type)).toEqual([Toggle, Toggle]); - expect(toggles.map((toggle) => toggle.props.children)).toEqual(["Subscription", "Historical"]); - expect(toggles.map((toggle) => toggle.props.value)).toEqual(["subscription", "historical"]); + expect(toggles.map((toggle) => toggle.props.children)).toEqual([ + "Subscription", + "Historical", + ]); + expect(toggles.map((toggle) => toggle.props.value)).toEqual([ + "subscription", + "historical", + ]); output.props.onValueChange(["historical"]); expect(selected).toEqual(["historical"]); From f8723f33cd821d48d2abaa48733b94f33bc8bab7 Mon Sep 17 00:00:00 2001 From: Naveed Iqbal Date: Sat, 22 Aug 2026 10:06:58 +0500 Subject: [PATCH 28/29] fix(usage): apply repository formatting --- .../src/components/usage/UsagePage.test.tsx | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 3c0a48ff7661..04686ed2b7bd 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -150,14 +150,8 @@ describe("UsageViewTabs", () => { expect(output.props.variant).toBe("segmented"); expect(output.props.value).toEqual(["subscription"]); expect(toggles.map((toggle) => toggle.type)).toEqual([Toggle, Toggle]); - expect(toggles.map((toggle) => toggle.props.children)).toEqual([ - "Subscription", - "Historical", - ]); - expect(toggles.map((toggle) => toggle.props.value)).toEqual([ - "subscription", - "historical", - ]); + expect(toggles.map((toggle) => toggle.props.children)).toEqual(["Subscription", "Historical"]); + expect(toggles.map((toggle) => toggle.props.value)).toEqual(["subscription", "historical"]); output.props.onValueChange(["historical"]); expect(selected).toEqual(["historical"]); @@ -166,12 +160,8 @@ describe("UsageViewTabs", () => { describe("UsageSkeleton", () => { it("matches the chart resolution and metric while historical usage is loading", () => { - const hourlyMarkup = renderToStaticMarkup( - , - ); - const dailyMarkup = renderToStaticMarkup( - , - ); + const hourlyMarkup = renderToStaticMarkup(); + const dailyMarkup = renderToStaticMarkup(); expect(hourlyMarkup).toContain("Hourly cost"); expect(dailyMarkup).toContain("Daily processed tokens"); From 40853acb1e350d8724a74f2905f34fd48c213ee1 Mon Sep 17 00:00:00 2001 From: Naveed Iqbal Date: Sat, 22 Aug 2026 10:38:43 +0500 Subject: [PATCH 29/29] fix(usage): track overlapping allowance refreshes --- .../mobile/src/state/subscriptionAllowance.ts | 9 ++++--- apps/web/src/state/subscriptionAllowance.ts | 9 ++++--- .../src/state/subscriptionAllowance.test.ts | 25 +++++++++++++++++++ .../src/state/subscriptionAllowance.ts | 18 +++++++++++++ 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/state/subscriptionAllowance.ts b/apps/mobile/src/state/subscriptionAllowance.ts index be158ad73f8c..e3a9197e52f9 100644 --- a/apps/mobile/src/state/subscriptionAllowance.ts +++ b/apps/mobile/src/state/subscriptionAllowance.ts @@ -7,6 +7,7 @@ */ import { useAtomValue } from "@effect/atom-react"; import { + createSubscriptionAllowanceRefreshTracker, isSubscriptionAllowanceCompatibilityCause, reconcileSubscriptionAllowances, type EnvironmentSubscriptionAllowanceStatus, @@ -64,18 +65,18 @@ export function useSubscriptionAllowance(): SubscriptionAllowanceView { reportFailure: false, }); const [isRefreshing, setIsRefreshing] = useState(false); + const [trackRefresh] = useState(() => createSubscriptionAllowanceRefreshTracker(setIsRefreshing)); const refresh = useCallback(() => { - setIsRefreshing(true); - void Promise.all( + void trackRefresh( projection.refreshEnvironmentIds.map((environmentId) => refreshAllowance({ environmentId, input: {}, }), ), - ).finally(() => setIsRefreshing(false)); - }, [projection.refreshEnvironmentIds, refreshAllowance]); + ); + }, [projection.refreshEnvironmentIds, refreshAllowance, trackRefresh]); return { ...projection, diff --git a/apps/web/src/state/subscriptionAllowance.ts b/apps/web/src/state/subscriptionAllowance.ts index f685de22c32e..9457d10b0ac0 100644 --- a/apps/web/src/state/subscriptionAllowance.ts +++ b/apps/web/src/state/subscriptionAllowance.ts @@ -1,6 +1,7 @@ import { useAtomCommand } from "../state/use-atom-command"; import { useAtomValue } from "@effect/atom-react"; import { + createSubscriptionAllowanceRefreshTracker, isSubscriptionAllowanceCompatibilityCause, reconcileSubscriptionAllowances, type EnvironmentSubscriptionAllowanceStatus, @@ -57,18 +58,18 @@ export function useSubscriptionAllowance(): SubscriptionAllowanceView { reportFailure: false, }); const [isRefreshing, setIsRefreshing] = useState(false); + const [trackRefresh] = useState(() => createSubscriptionAllowanceRefreshTracker(setIsRefreshing)); const refresh = useCallback(() => { - setIsRefreshing(true); - void Promise.all( + void trackRefresh( projection.refreshEnvironmentIds.map((environmentId) => refreshAllowance({ environmentId, input: {}, }), ), - ).finally(() => setIsRefreshing(false)); - }, [projection.refreshEnvironmentIds, refreshAllowance]); + ); + }, [projection.refreshEnvironmentIds, refreshAllowance, trackRefresh]); return { ...projection, diff --git a/packages/client-runtime/src/state/subscriptionAllowance.test.ts b/packages/client-runtime/src/state/subscriptionAllowance.test.ts index debb663ccd5c..9005f691daa5 100644 --- a/packages/client-runtime/src/state/subscriptionAllowance.test.ts +++ b/packages/client-runtime/src/state/subscriptionAllowance.test.ts @@ -2,6 +2,7 @@ import { EnvironmentId, ProviderInstanceId, type SubscriptionAllowance } from "@ import { describe, expect, it } from "vite-plus/test"; import { + createSubscriptionAllowanceRefreshTracker, formatAllowanceResetAt, isSubscriptionAllowanceSourceCurrent, reconcileSubscriptionAllowances, @@ -10,6 +11,30 @@ import { const readAt = "2026-08-11T12:00:00.000Z"; +describe("createSubscriptionAllowanceRefreshTracker", () => { + it("stays refreshing until every overlapping refresh settles", async () => { + const refreshingChanges: boolean[] = []; + const trackRefresh = createSubscriptionAllowanceRefreshTracker((isRefreshing) => { + refreshingChanges.push(isRefreshing); + }); + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + + const firstTracked = trackRefresh([first.promise]); + const secondTracked = trackRefresh([second.promise]); + + expect(refreshingChanges).toEqual([true]); + + second.resolve(); + await secondTracked; + expect(refreshingChanges).toEqual([true]); + + first.resolve(); + await firstTracked; + expect(refreshingChanges).toEqual([true, false]); + }); +}); + describe("formatAllowanceResetAt", () => { it("returns null instead of throwing for an invalid provider timestamp", () => { expect(formatAllowanceResetAt("not-an-iso-date")).toBeNull(); diff --git a/packages/client-runtime/src/state/subscriptionAllowance.ts b/packages/client-runtime/src/state/subscriptionAllowance.ts index 70a2089aa47c..c315c902f76b 100644 --- a/packages/client-runtime/src/state/subscriptionAllowance.ts +++ b/packages/client-runtime/src/state/subscriptionAllowance.ts @@ -22,6 +22,24 @@ export const USAGE_VIEW_OPTIONS = [ export type SubscriptionViewPhase = "loading" | "partial" | "ready"; +export function createSubscriptionAllowanceRefreshTracker( + onRefreshingChange: (isRefreshing: boolean) => void, +): (refreshes: readonly Promise[]) => Promise { + let pendingRefreshes = 0; + + return async (refreshes) => { + pendingRefreshes += 1; + if (pendingRefreshes === 1) onRefreshingChange(true); + + try { + await Promise.all(refreshes); + } finally { + pendingRefreshes -= 1; + if (pendingRefreshes === 0) onRefreshingChange(false); + } + }; +} + export function subscriptionViewPhase(input: { readonly isPending: boolean; readonly isPartial: boolean;