From 2550e7e86b9bcd663e252f953a0da87455053b48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Pr=C3=B3spero?= <130605901+DJProspero1@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:47:57 +0000 Subject: [PATCH 01/10] fix(usage): report failed hub account limit reads --- packages/shared/src/usageLimits.test.ts | 48 +++++++++++++++++++++++++ packages/shared/src/usageLimits.ts | 10 +++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 236a8baa13a26..85b453faa930c 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -647,6 +647,54 @@ describe("pools", () => { ]); }); + it("reports unreadable hub accounts alongside usable limits", () => { + const account = { + id: "claude.json", + driver: claude, + email: "account@example.com", + workspaceId: "org-a", + usageLimits: { checkedAt, windows: [window] }, + }; + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [], + usageLimitSources: [{ ...source, accounts: [account] }], + }, + }, + ], + ]); + expect(collectLimitNotices(input)).toEqual([]); + for (const unavailable of [ + { reason: "probeFailed" as const, message: "The hub could not read this account's usage." }, + { reason: "probeFailed" as const }, + ]) { + const failed = { + ...account, + usageLimits: { checkedAt, windows: [], unavailable }, + }; + input.set(EnvironmentId.make("env-b"), { + entry: { target: { label: "Desktop" } }, + serverConfig: { + providers: [], + usageLimitSources: [ + { + ...source, + accounts: [failed, { ...failed, id: "other.json", workspaceId: "org-b" }], + }, + ], + }, + }); + expect(collectLimitAccounts(input)).toHaveLength(1); + expect(collectLimitNotices(input)).toEqual([ + `Desktop · hub · account@example.com: ${unavailable.message ?? "Could not read limits."}`, + ]); + } + }); + it("keys a hub account without an email by hub, so two environments on one hub share it", () => { const seat = { id: "claude-team-seat.json", diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 1707deb735a49..af06a9cb533d2 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -407,9 +407,17 @@ export function collectLimitNotices(presentations: LimitPresentations): readonly } else if (source.accounts.length === 0) { notices.push(`${label(environmentLabel, source.label)}: No accounts reported.`); } + for (const account of source.accounts) { + if (account.usageLimits.unavailable?.reason === "unsupported") continue; + const notice = limitsNotice(account.usageLimits); + if (notice) { + const name = `${source.label} · ${account.email || hubAccountName(account)}`; + notices.push(`${label(environmentLabel, name)}: ${notice}`); + } + } } } - return notices; + return [...new Set(notices)]; } export interface LimitPoolMember { From 70a843abf5b6082548b5211b3ad6304d2aa4f185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Pr=C3=B3spero?= <130605901+DJProspero1@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:57:26 +0000 Subject: [PATCH 02/10] fix(usage): retain hub accounts through failed quota refreshes Apply account probe pacing, backoff and retention from upstream PR #19820, then keep failed account identities visible when retained readings expire. Mark retained readings as stale and render unavailable rows on mobile/web. Pacing and reset-credit cache logic adapted from JustYannicc's 860aa817f86a842e7d4ec102e06b9787e2b68207. Co-authored-by: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> --- .../src/features/usage/UsageLimitsPooled.tsx | 14 + apps/server/src/usage/cliproxyApi.test.ts | 245 +++++++++++++++++- apps/server/src/usage/cliproxyApi.ts | 146 +++++++++-- .../components/usage/UsageLimitsPooled.tsx | 8 + docs/user/usage.md | 8 +- packages/shared/src/usageLimits.test.ts | 46 +++- packages/shared/src/usageLimits.ts | 15 +- 7 files changed, 455 insertions(+), 27 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index a9780d9954ae7..8e5887c5c40ce 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -447,6 +447,20 @@ export function UsageLimitsSection({ {DRIVER_LABEL[pool.driver] ?? pool.driver} + {pool.accounts + .filter((account) => account.limits.windows.length === 0) + .map((account) => ( + + {account.email ? : null} + + {accountName(account)} + + Limits unavailable + + ))} {windows.map((window) => { const details = pool.driver === "cursor" ? cursorUsageWindowDetails(window.id) : undefined; diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index b9f357522bb7b..9a414eea1008d 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -1,9 +1,11 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; -import { HttpClient, HttpClientError, HttpClientResponse } from "effect/http"; +import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; import { makeCliproxyApi } from "./cliproxyApi.ts"; @@ -56,11 +58,13 @@ function fixture( >; upstream?: (request: RequestBody) => { status: number; body: unknown }; cooldownStatus?: number; + beforeCooldownReset?: Effect.Effect; + beforeUpstreamResponse?: (request: RequestBody) => Effect.Effect; } = {}, ) { const requests: Array<{ path: string; body?: RequestBody }> = []; - const http = HttpClient.make((request) => - Effect.sync(() => { + const respond = (request: HttpClientRequest.HttpClientRequest) => + Effect.gen(function* () { expect(request.headers.authorization).toBe("Bearer management-secret"); const path = new URL(request.url).pathname; const body = @@ -104,11 +108,19 @@ function fixture( }, }, }; + if (body && options.beforeUpstreamResponse) yield* options.beforeUpstreamResponse(body); return HttpClientResponse.fromWeb( request, Response.json({ status_code: upstream.status, body: encodeJson(upstream.body) }), ); - }), + }); + const http = HttpClient.make((request) => + Effect.andThen( + request.url.endsWith("/reset-quota") + ? (options.beforeCooldownReset ?? Effect.void) + : Effect.void, + respond(request), + ), ); return { requests, @@ -369,6 +381,231 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + it.effect( + "paces usage probes, retains recent readings through 429s, and expires them during backoff", + () => + Effect.gen(function* () { + let limited = false; + let probes = 0; + const test = fixture({ + accounts: [{ ...accounts[0]!, provider: "claude" }], + upstream: () => { + probes++; + return limited + ? { status: 429, body: {} } + : { status: 200, body: { five_hour: { utilization: probes * 10, resets_at: null } } }; + }, + }); + const api = yield* test.api; + const [initial] = yield* api.readAccounts(config); + expect(initial?.usageLimits.windows[0]?.usedPercent).toBe(10); + limited = true; + for (const minutes of [1, 2, 4, 8]) { + const previousProbes = probes; + yield* TestClock.adjust(minutes * 60_000 - 1); + expect((yield* api.readAccounts(config))[0]?.usageLimits).toMatchObject( + initial!.usageLimits, + ); + expect(probes).toBe(previousProbes); + yield* TestClock.adjust(1); + const [account] = yield* api.readAccounts(config); + expect(probes).toBe(previousProbes + 1); + if (minutes < 8) { + expect(account?.usageLimits).toMatchObject(initial!.usageLimits); + expect(account?.usageLimits.unavailable?.message).toContain("last known"); + } else { + expect(account?.usageLimits.windows).toEqual([]); + expect(account?.usageLimits.unavailable).toMatchObject({ + reason: "probeFailed", + message: expect.stringContaining("rate limiting"), + }); + } + } + limited = false; + yield* TestClock.adjust(8 * 60_000 - 1); + expect((yield* api.readAccounts(config))[0]?.usageLimits.unavailable?.message).toContain( + "rate limiting", + ); + expect(probes).toBe(5); + yield* TestClock.adjust(1); + expect((yield* api.readAccounts(config))[0]?.usageLimits.windows[0]?.usedPercent).toBe(60); + yield* TestClock.adjust("1 minute"); + expect((yield* api.readAccounts(config))[0]?.usageLimits.windows[0]?.usedPercent).toBe(70); + }), + ); + + it.effect("isolates cached readings by hub, file, provider, email, and workspace", () => + Effect.gen(function* () { + const original = accounts[0]!; + const listed = [{ ...original }]; + let probes = 0; + const test = fixture({ + accounts: listed, + upstream: (request) => { + if (request.url?.endsWith("/rate-limit-reset-credits")) + return { status: 200, body: { credits: [] } }; + const percent = ++probes * 10; + return { + status: 200, + body: { + five_hour: { utilization: percent, resets_at: null }, + rate_limit: { primary_window: { used_percent: percent } }, + }, + }; + }, + }); + const api = yield* test.api; + const baseline = yield* api.readAccounts(config); + const identities = [ + { url: "http://other-hub.test:8317", account: original }, + { url: config.url, account: { ...original, id: "replacement.json" } }, + { url: config.url, account: { ...original, provider: "claude" } }, + { url: config.url, account: { ...original, email: "replacement@example.com" } }, + { + url: config.url, + account: { ...original, id_token: { chatgpt_account_id: "other-workspace" } }, + }, + ]; + for (const [index, identity] of identities.entries()) { + listed[0] = identity.account; + const [account] = yield* api.readAccounts({ ...config, url: identity.url }); + expect(account).toMatchObject({ + id: identity.account.id, + email: identity.account.email, + workspaceId: identity.account.id_token.chatgpt_account_id, + driver: identity.account.provider === "claude" ? "claudeAgent" : "codex", + }); + expect(account?.usageLimits.windows[0]?.usedPercent).toBe((index + 2) * 10); + } + listed[0] = original; + expect(yield* api.readAccounts(config)).toEqual(baseline); + expect(probes).toBe(6); + }), + ); + + it.effect( + "shares concurrent reads without blocking other identities or retaining a pre-reset reading", + () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const spent = yield* Deferred.make(); + let probes = 0; + const test = fixture({ + accounts: [accounts[0]!], + upstream: (request) => ({ + status: 200, + body: request.url?.endsWith("/consume") + ? { code: "reset" } + : request.url?.endsWith("/rate-limit-reset-credits") + ? { credits: [] } + : { rate_limit: { primary_window: { used_percent: ++probes * 10 } } }, + }), + beforeUpstreamResponse: (request) => + request.url?.endsWith("/wham/usage") && probes === 1 + ? Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release))) + : Effect.void, + beforeCooldownReset: Deferred.succeed(spent, undefined).pipe(Effect.asVoid), + }); + const api = yield* test.api; + const first = yield* Effect.forkChild(api.readAccounts(config)); + yield* Deferred.await(started); + const second = yield* Effect.forkChild(api.readAccounts(config), { + startImmediately: true, + }); + const [other] = yield* api.readAccounts({ ...config, url: "http://other-hub.test:8317" }); + expect(other?.usageLimits.windows[0]?.usedPercent).toBe(20); + const redeem = yield* Effect.forkChild(api.consume(config, "first.json", "credit")); + yield* Deferred.await(spent); + yield* Deferred.succeed(release, undefined); + expect((yield* Fiber.join(first))[0]?.usageLimits.windows[0]?.usedPercent).toBe(10); + expect((yield* Fiber.join(second))[0]?.usageLimits.windows[0]?.usedPercent).toBe(10); + expect((yield* Fiber.join(redeem)).outcome).toBe("reset"); + const [fresh] = yield* api.readAccounts(config); + expect(fresh?.usageLimits.windows[0]?.usedPercent).toBe(30); + expect(probes).toBe(3); + }), + ); + + it.effect( + "retains pacing after a rejected reset but invalidates only the attempted account after success or uncertain failure", + () => + Effect.gen(function* () { + let resetStatus = 429; + let limited = false; + let percent = 12; + const test = fixture({ + upstream: (request) => + request.url?.endsWith("/consume") + ? { status: resetStatus, body: { code: "reset" } } + : limited + ? { status: 429, body: {} } + : { + status: 200, + body: request.url?.endsWith("/rate-limit-reset-credits") + ? { credits: [] } + : { rate_limit: { primary_window: { used_percent: percent } } }, + }, + }); + const api = yield* test.api; + const usageProbes = () => + test.requests.filter((request) => request.body?.url?.endsWith("/wham/usage")).length; + const initial = yield* api.readAccounts(config); + limited = true; + yield* TestClock.adjust("1 minute"); + expect(yield* api.readAccounts(config)).toMatchObject(initial); + expect(usageProbes()).toBe(4); + expect((yield* Effect.flip(api.consume(config, "first.json", "credit"))).detail).toContain( + "rate limiting", + ); + yield* TestClock.adjust("1 minute"); + expect(yield* api.readAccounts(config)).toMatchObject(initial); + expect(usageProbes()).toBe(4); + + resetStatus = 200; + limited = false; + percent = 0; + expect((yield* api.consume(config, "first.json", "credit")).outcome).toBe("reset"); + const afterReset = yield* api.readAccounts(config); + expect(afterReset.map((account) => account.usageLimits.windows[0]?.usedPercent)).toEqual([ + 0, 12, + ]); + expect(usageProbes()).toBe(5); + + resetStatus = 503; + limited = true; + expect((yield* Effect.result(api.consume(config, "first.json", "next-credit")))._tag).toBe( + "Failure", + ); + const afterFailure = yield* api.readAccounts(config); + expect(afterFailure[0]?.usageLimits.windows).toEqual([]); + expect(afterFailure[0]?.usageLimits.unavailable?.message).toContain("rate limiting"); + expect(afterFailure[1]).toMatchObject(initial[1]!); + expect(usageProbes()).toBe(6); + }), + ); + + it.effect("invalidates an account when reset is interrupted after the credit is spent", () => + Effect.gen(function* () { + const spent = yield* Deferred.make(); + const test = fixture({ + beforeCooldownReset: Deferred.succeed(spent, undefined).pipe(Effect.andThen(Effect.never)), + }); + const api = yield* test.api; + yield* api.readAccounts(config); + const redeem = yield* Effect.forkChild(api.consume(config, "second.json", "credit-b")); + yield* Deferred.await(spent); + yield* Fiber.interrupt(redeem); + yield* api.readAccounts(config); + expect( + test.requests + .filter((request) => request.body?.url?.endsWith("/wham/usage")) + .map((request) => request.body?.auth_index) + .sort(), + ).toEqual(["a", "b", "b"]); + }), + ); + it.effect.each([ { name: "spend budget", diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 87ec1e05b8f77..3d9b79468811f 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -5,11 +5,16 @@ import { type UsageLimitSourceAccount, type UsageLimitSourceConfig, } from "@t3tools/contracts"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Hex from "effect/encoding/Hex"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; @@ -24,6 +29,28 @@ export interface CliproxyHub { readonly managementKey: string; } +class HubProviderRateLimitedError extends Schema.TaggedError()( + "HubProviderRateLimitedError", + {}, +) { + override get message(): string { + return "The provider is rate limiting requests from the hub. Try again in a few minutes."; + } +} +const isHubProviderRateLimitedError = Schema.is(HubProviderRateLimitedError); + +const USAGE_READ_INTERVAL_MS = 60_000; +const USAGE_READ_MAX_BACKOFF_MS = 8 * 60_000; +const USAGE_READ_RETENTION_MS = 15 * 60_000; + +interface UsageReadState { + readonly nextReadAt: number; + readonly intervalMs: number; + readonly last: { readonly value: UsageLimitSourceAccount; readonly at: number } | undefined; + readonly message: string; + readonly failed: boolean; +} + const AuthFile = Schema.Struct({ id: Schema.String, auth_index: Schema.String, @@ -244,6 +271,7 @@ export const makeCliproxyApi = Effect.gen(function* () { ...(data === undefined ? {} : { data: yield* encodeJson(data) }), }); const response = yield* decodeApiResponse(raw); + if (response.status_code === 429) return yield* new HubProviderRateLimitedError(); if (response.status_code < 200 || response.status_code >= 300) { return yield* new UsageLimitSourceError({ detail: `The provider refused the hub request (HTTP ${response.status_code}).`, @@ -269,6 +297,17 @@ export const makeCliproxyApi = Effect.gen(function* () { .toSorted((a, b) => Date.parse(a.expires_at) - Date.parse(b.expires_at)); }); + const usageReads = yield* Ref.make>(new Map()); + const usageReadLock = yield* KeyedLock.make(); + const usageReadKey = (hub: CliproxyHub, account: typeof AuthFile.Type) => + JSON.stringify([ + hub.url, + account.id, + account.provider, + account.email ?? "", + account.id_token?.chatgpt_account_id ?? "", + ]); + const readAccount = Effect.fn("CliproxyApi.readAccount")(function* ( hub: CliproxyHub, account: typeof AuthFile.Type, @@ -360,15 +399,65 @@ export const makeCliproxyApi = Effect.gen(function* () { }, }; }); - return yield* read.pipe( - Effect.orElseSucceed(() => ({ - ...base, - usageLimits: makeUnavailableUsageLimits({ - checkedAt, - reason: "probeFailed", - message: "The hub could not read this account's usage.", - }), - })), + const key = usageReadKey(hub, account); + return yield* usageReadLock.withLock( + key, + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const previous = (yield* Ref.get(usageReads)).get(key); + let entry = previous; + if (!entry || now >= entry.nextReadAt) { + const result = yield* Effect.result(read); + const intervalMs = Result.isSuccess(result) + ? USAGE_READ_INTERVAL_MS + : Math.min( + (entry?.intervalMs ?? USAGE_READ_INTERVAL_MS) * 2, + USAGE_READ_MAX_BACKOFF_MS, + ); + entry = { + nextReadAt: now + intervalMs, + intervalMs, + last: Result.isSuccess(result) ? { value: result.success, at: now } : entry?.last, + failed: Result.isFailure(result), + message: + Result.isFailure(result) && isHubProviderRateLimitedError(result.failure) + ? result.failure.message + : "The hub could not read this account's usage.", + }; + } + // A slow failed probe can cross the retention boundary. + const finishedAt = yield* Clock.currentTimeMillis; + if (entry.last && finishedAt - entry.last.at >= USAGE_READ_RETENTION_MS) { + entry = { ...entry, last: undefined }; + } + if (entry !== previous) { + const next = entry; + yield* Ref.update(usageReads, (current) => new Map(current).set(key, next)); + } + if (entry.last) { + const account = entry.last.value; + return entry.failed + ? { + ...account, + usageLimits: { + ...account.usageLimits, + unavailable: { + reason: "probeFailed" as const, + message: `${entry.message} Showing the last known limits.`, + }, + }, + } + : account; + } + return { + ...base, + usageLimits: makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + message: entry.message, + }), + }; + }), ); }); @@ -397,13 +486,7 @@ export const makeCliproxyApi = Effect.gen(function* () { accountId: string, creditId: string, ): Effect.fn.Return { - const operation = Effect.gen(function* () { - const account = (yield* authFiles(hub)).find((account) => account.id === accountId); - if (!account || account.disabled || account.provider !== "codex") { - return yield* new UsageLimitSourceError({ - detail: "The Codex hub account is missing or disabled.", - }); - } + const redeem = Effect.fnUntraced(function* (account: typeof AuthFile.Type) { const body = yield* apiCall(hub, account, `${CREDIT_URL}/consume`, { redeem_request_id: yield* creditRedeemRequestId( account.id_token?.chatgpt_account_id ?? account.id, @@ -434,12 +517,41 @@ export const makeCliproxyApi = Effect.gen(function* () { : {}), } as const; }); + const operation = Effect.gen(function* () { + const account = (yield* authFiles(hub)).find((account) => account.id === accountId); + if (!account || account.disabled || account.provider !== "codex") { + return yield* new UsageLimitSourceError({ + detail: "The Codex hub account is missing or disabled.", + }); + } + const key = usageReadKey(hub, account); + return yield* redeem(account).pipe( + // Only an explicit provider 429 guarantees that no credit was spent. + Effect.onExit((exit) => { + const failure = Exit.findError(exit); + if (Result.isSuccess(failure) && isHubProviderRateLimitedError(failure.success)) { + return Effect.void; + } + // Wait for older probes before dropping their result. + return usageReadLock.withLock( + key, + Ref.update(usageReads, (current) => { + const next = new Map(current); + next.delete(key); + return next; + }), + ); + }), + ); + }); return yield* operation.pipe( Effect.mapError((error) => isUsageLimitSourceError(error) ? error : new UsageLimitSourceError({ - detail: "The hub returned an unexpected reset-credit response.", + detail: isHubProviderRateLimitedError(error) + ? error.message + : "The hub returned an unexpected reset-credit response.", }), ), ); diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 00f437246584f..4dbbb2645390b 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -585,6 +585,14 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu /> {label} + {pool.accounts + .filter((account) => account.limits.windows.length === 0) + .map((account) => ( +
+ + Limits unavailable +
+ ))} {windows.map((window) => { const details = pool.driver === "cursor" ? cursorUsageWindowDetails(window.id) : undefined; return ( diff --git a/docs/user/usage.md b/docs/user/usage.md index d7e0dad901bef..c45e737ff3454 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -136,7 +136,8 @@ model-specific weekly allowances keep their reported values. Opening Limits checks the selected connected environments automatically. Each client waits at least five minutes between automatic checks of an environment, including after a failed check. -If a window still looks stale, refresh Limits to re-check every provider and hub. +If a window still looks stale, refresh Limits to re-check every provider and hub. Hub accounts may +reuse their last reading while a refresh interval or failure backoff is active. Pick `/usage-limits` from the composer's command menu, or send it as a message, to check the current model's limits without leaving the conversation. The result opens above the composer and @@ -174,7 +175,10 @@ To see pooled accounts, open **Settings → Providers → Usage providers → Ad environment that will connect to the hub and enter its URL and management key. The accounts appear under **Usage → Limits**. Codex accounts show banked reset credits; select an -account and choose **Use reset** to redeem one. No hub plugin is required. +account and choose **Use reset** to redeem one. No hub plugin is required. Hub readings refresh at +most once a minute, with longer pauses after failures; if a read fails, the last successful reading +stays visible until it is fifteen minutes old, with a warning that it could not be refreshed. +Accounts stay listed as unavailable when there is no recent reading. This connection supplies usage information; configure the provider separately to send agent requests through the hub. Remove the hub from the same diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 85b453faa930c..bd4fc855176d1 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -674,7 +674,11 @@ describe("pools", () => { ]) { const failed = { ...account, - usageLimits: { checkedAt, windows: [], unavailable }, + usageLimits: { + checkedAt: "2026-09-03T11:01:00.000Z", + windows: [], + unavailable, + }, }; input.set(EnvironmentId.make("env-b"), { entry: { target: { label: "Desktop" } }, @@ -688,11 +692,49 @@ describe("pools", () => { ], }, }); - expect(collectLimitAccounts(input)).toHaveLength(1); + const collected = collectLimitAccounts(input); + expect(collected).toHaveLength(2); + expect(collected.map((entry) => entry.limits.windows.length)).toEqual([1, 0]); + expect(collectLimitPools(collected, Date.parse(checkedAt))[0]?.accounts).toHaveLength(2); expect(collectLimitNotices(input)).toEqual([ `Desktop · hub · account@example.com: ${unavailable.message ?? "Could not read limits."}`, ]); + input.delete(EnvironmentId.make("env-a")); + expect(collectLimitAccounts(input)).toHaveLength(2); + expect( + collectLimitPools(collectLimitAccounts(input), Date.parse(checkedAt))[0]?.windows, + ).toEqual([]); + input.set(EnvironmentId.make("env-a"), { + ...laptop, + serverConfig: { + providers: [], + usageLimitSources: [{ ...source, accounts: [account] }], + }, + }); } + input.clear(); + const stale = { + ...account, + usageLimits: { + ...account.usageLimits, + unavailable: { reason: "probeFailed" as const }, + }, + }; + input.set(EnvironmentId.make("env-a"), { + ...laptop, + serverConfig: { + providers: [], + usageLimitSources: [ + { + ...source, + accounts: [stale], + }, + ], + }, + }); + const retained = collectLimitPools(collectLimitAccounts(input), Date.parse(checkedAt)); + expect(retained[0]?.windows[0]?.members[0]?.window).toMatchObject(window); + expect(collectLimitNotices(input)).toHaveLength(1); }); it("keys a hub account without an email by hub, so two environments on one hub share it", () => { diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index af06a9cb533d2..cd56e175d77d9 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -298,7 +298,14 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl !previous.environments.some((seen) => seen.environmentId === candidate.environmentId), ), ]; - const winner = fresher ? next : previous; + const winner = + Boolean(next.limits.windows.length) !== Boolean(previous.limits.windows.length) + ? next.limits.windows.length + ? next + : previous + : fresher + ? next + : previous; const creditSource = creditSources.get(key); // A hub e-mail names the account better than the driver's default instance name. const hubEmail = [previous, next].some( @@ -360,7 +367,11 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl ? `${presentation.entry.target.label} · ${source.label}` : source.label; for (const account of source.accounts) { - if (limitsNotice(account.usageLimits) !== null) continue; + if ( + limitsNotice(account.usageLimits) !== null && + account.usageLimits.unavailable?.reason !== "probeFailed" + ) + continue; const key = `${source.id}:${account.id}${account.workspaceId ? `:${account.workspaceId}` : ""}`; merge(resolveKey(account) ?? key, { key, From 0f355e7fe77cce2057c48155f27eed04fe0cef8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Pr=C3=B3spero?= <130605901+DJProspero1@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:45:46 +0000 Subject: [PATCH 03/10] fix(mobile): show unavailable limits for empty widget pools --- .../widgets/subscriptionUsageSnapshot.test.ts | 37 +++++++++++++++++++ .../src/widgets/subscriptionUsageSnapshot.ts | 2 +- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts index abc4fb0ed0c95..98d7a814be932 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts @@ -99,6 +99,43 @@ describe("subscription widget snapshots", () => { const snapshot = buildSubscriptionUsageSnapshot(input, deepLink); expect(snapshot.providers[0]?.name).toBe("Codex"); expect(JSON.stringify(snapshot)).not.toContain("example.com"); + const source = input.get(EnvironmentId.make("env"))!.serverConfig.usageLimitSources[0]!; + const failed = buildSubscriptionUsageSnapshot( + new Map([ + [ + EnvironmentId.make("env"), + { + entry: { target: { label: "Remote" } }, + serverConfig: { + providers: [], + usageLimitSources: [ + { + ...source, + accounts: source.accounts.map((account) => ({ + ...account, + usageLimits: { + checkedAt, + windows: [], + unavailable: { reason: "probeFailed" as const }, + }, + })), + }, + ], + }, + }, + ], + ]), + deepLink, + ); + expect(failed.providers).toEqual([ + { + name: "Codex", + detail: "No limits available", + windows: [], + expiresAt: 0, + totalWindows: 0, + }, + ]); }); it("bounds OS storage and puts the most constrained windows first", () => { const windows = Array.from({ length: 20 }, (_, index) => ({ diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts index b96b5c919ec30..d6e7037dee927 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts @@ -64,7 +64,7 @@ function subscriptionUsageProps( .map((driver) => { const pool = pools.find((candidate) => candidate.driver === driver); const name = driver === "codex" ? "Codex" : "Claude"; - if (!pool) + if (!pool || pool.windows.length === 0) return { name, detail: "No limits available", From 9572ae4ccda08c77efcf5d8bd633c52ef4c142f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Pr=C3=B3spero?= <130605901+DJProspero1@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:56:49 +0000 Subject: [PATCH 04/10] fix(mobile): distinguish retained widget quota readings --- .../widgets/subscriptionUsageSnapshot.test.ts | 39 +++++++++++++------ .../src/widgets/subscriptionUsageSnapshot.ts | 18 +++++++-- 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts index 98d7a814be932..f5c3c20fd890e 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts @@ -100,7 +100,7 @@ describe("subscription widget snapshots", () => { expect(snapshot.providers[0]?.name).toBe("Codex"); expect(JSON.stringify(snapshot)).not.toContain("example.com"); const source = input.get(EnvironmentId.make("env"))!.serverConfig.usageLimitSources[0]!; - const failed = buildSubscriptionUsageSnapshot( + const failedInput = (windows: typeof limits.windows, includeHealthy = false) => new Map([ [ EnvironmentId.make("env"), @@ -111,22 +111,26 @@ describe("subscription widget snapshots", () => { usageLimitSources: [ { ...source, - accounts: source.accounts.map((account) => ({ - ...account, - usageLimits: { - checkedAt, - windows: [], - unavailable: { reason: "probeFailed" as const }, - }, - })), + accounts: [ + ...(includeHealthy ? source.accounts : []), + ...source.accounts.map((account) => ({ + ...account, + id: "failed-account", + email: "failed@example.com", + usageLimits: { + checkedAt, + windows, + unavailable: { reason: "probeFailed" as const }, + }, + })), + ], }, ], }, }, ], - ]), - deepLink, - ); + ]); + const failed = buildSubscriptionUsageSnapshot(failedInput([]), deepLink); expect(failed.providers).toEqual([ { name: "Codex", @@ -136,6 +140,17 @@ describe("subscription widget snapshots", () => { totalWindows: 0, }, ]); + const partial = buildSubscriptionUsageSnapshot(failedInput([], true), deepLink); + expect(partial.providers[0]?.detail).toBe("Available subscription limits"); + expect(partial.providers[0]?.windows[0]?.remaining).toBe(60); + const retained = buildSubscriptionUsageSnapshot(failedInput(limits.windows), deepLink); + expect(retained.providers[0]?.detail).toBe("Last known limits"); + expect(retained.providers[0]?.windows[0]).toMatchObject({ + label: "Stale · 5 hours", + remaining: 60, + }); + expect(retained.checkedAt).toBe(now); + expect(retained.providers[0]?.expiresAt).toBe(now + 10 * 60_000); }); it("bounds OS storage and puts the most constrained windows first", () => { const windows = Array.from({ length: 20 }, (_, index) => ({ diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts index d6e7037dee927..a5a4117a29da3 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts @@ -95,15 +95,25 @@ function subscriptionUsageProps( name, detail: !fresh ? "Open T3 to refresh" - : pool.accounts.length > 1 - ? `${pool.accounts.length} accounts · pooled` - : "Subscription remaining", + : pool.accounts.some( + (account) => + account.limits.windows.length > 0 && + account.limits.unavailable?.reason === "probeFailed", + ) + ? "Last known limits" + : pool.accounts.length > 1 + ? "Available subscription limits" + : "Subscription remaining", expiresAt: fresh ? expiresAt : 0, totalWindows: fresh ? pool.windows.length : 0, windows: fresh ? selectedWindows.map((window) => ({ kind: window.kind, - label: window.label, + label: window.members.some( + (member) => member.account.limits.unavailable?.reason === "probeFailed", + ) + ? `Stale · ${window.label}` + : window.label, remaining: Math.round(window.remainingPercent), reset: window.resets[0] ? `Next reset ${new Date(window.resets[0].at).toLocaleString(undefined, { From e415b3cc1b8ba3e573b91105434f33393ac45f63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Pr=C3=B3spero?= <130605901+DJProspero1@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:58:10 +0000 Subject: [PATCH 05/10] fix(mobile): preserve current widget limits beside failed accounts --- apps/mobile/src/features/usage/UsageLimitsPooled.tsx | 6 +++++- .../src/widgets/subscriptionUsageSnapshot.test.ts | 6 +++++- apps/mobile/src/widgets/subscriptionUsageSnapshot.ts | 12 ++++++++++-- apps/server/src/usage/cliproxyApi.ts | 1 + 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index fe32c67120eae..81702a9713247 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -449,7 +449,11 @@ export function UsageLimitsSection({ key={account.key} className="flex-row items-center gap-3 rounded-[24px] border-continuous bg-grouped-card p-4" > - {account.email ? : null} + + + {pool.accounts.indexOf(account) + 1} + + {accountName(account)} diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts index f5c3c20fd890e..f731c817c7a9c 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts @@ -8,6 +8,7 @@ import { import { describe, expect, it, vi } from "vite-plus/test"; import { buildSubscriptionUsageSnapshot, + subscriptionUsageTimeline, createWidgetRefresher, WIDGET_REFRESH_INTERVAL, } from "./subscriptionUsageSnapshot"; @@ -118,7 +119,7 @@ describe("subscription widget snapshots", () => { id: "failed-account", email: "failed@example.com", usageLimits: { - checkedAt, + checkedAt: windows.length > 0 ? checkedAt : "2026-09-05T11:00:00.000Z", windows, unavailable: { reason: "probeFailed" as const }, }, @@ -143,6 +144,9 @@ describe("subscription widget snapshots", () => { const partial = buildSubscriptionUsageSnapshot(failedInput([], true), deepLink); expect(partial.providers[0]?.detail).toBe("Available subscription limits"); expect(partial.providers[0]?.windows[0]?.remaining).toBe(60); + expect(partial.checkedAt).toBe(now); + expect(partial.providers[0]?.expiresAt).toBe(now + 10 * 60_000); + expect(subscriptionUsageTimeline(partial, now)[0]?.props.providers[0]?.windows).toHaveLength(1); const retained = buildSubscriptionUsageSnapshot(failedInput(limits.windows), deepLink); expect(retained.providers[0]?.detail).toBe("Last known limits"); expect(retained.providers[0]?.windows[0]).toMatchObject({ diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts index a5a4117a29da3..8c5f5ca868f1d 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts @@ -52,7 +52,11 @@ function subscriptionUsageProps( ): SubscriptionUsageSnapshot { const pools = collectLimitPools(accounts, now); const checked = accounts - .filter((account) => account.driver === "codex" || account.driver === "claudeAgent") + .filter( + (account) => + account.limits.windows.length > 0 && + (account.driver === "codex" || account.driver === "claudeAgent"), + ) .map((account) => Date.parse(account.limits.checkedAt)); return { checkedAt: checked.length > 0 && checked.every(Number.isFinite) ? Math.min(...checked) : 0, @@ -72,7 +76,11 @@ function subscriptionUsageProps( expiresAt: 0, totalWindows: 0, }; - const checkedAt = Math.min(...pool.accounts.map((a) => Date.parse(a.limits.checkedAt))); + const checkedAt = Math.min( + ...pool.accounts + .filter((account) => account.limits.windows.length > 0) + .map((account) => Date.parse(account.limits.checkedAt)), + ); const expiresAt = Math.min( checkedAt + SNAPSHOT_MAX_AGE, ...pool.windows.flatMap((window) => window.resets.map((reset) => reset.at)), diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index bf3b694d12982..244ae2e80cc1e 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -608,6 +608,7 @@ export const makeCliproxyApi = Effect.gen(function* () { accountId: string, creditId: string, ): Effect.fn.Return { + // The enclosing CliproxyApi.consume span covers this inline helper. const redeem = Effect.fnUntraced(function* (account: AuthFile) { const body = yield* apiCall( hub, From b4c1ea3ff34ba835aa5c281f26863884e3893836 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:40:29 +0000 Subject: [PATCH 06/10] test(usage): drop hub quota tests that repeat covered paths AI-assisted; verified with the tests and tools listed above. --- apps/server/src/usage/cliproxyApi.test.ts | 71 ----------------------- 1 file changed, 71 deletions(-) diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 83664df76aff3..2499270eb2d2f 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -438,56 +438,6 @@ describe("CLIProxyAPI built-in management API", () => { }), ); - it.effect("isolates cached readings by hub, file, provider, email, workspace, and project", () => - Effect.gen(function* () { - const original = { ...accounts[0]!, project_id: "project-a" }; - const listed = [{ ...original }]; - let probes = 0; - const test = fixture({ - accounts: listed, - upstream: (request) => { - if (request.url?.endsWith("/rate-limit-reset-credits")) - return { status: 200, body: { credits: [] } }; - const percent = ++probes * 10; - return { - status: 200, - body: { - five_hour: { utilization: percent, resets_at: null }, - rate_limit: { primary_window: { used_percent: percent } }, - }, - }; - }, - }); - const api = yield* test.api; - const baseline = yield* api.readAccounts(config); - const identities = [ - { url: "http://other-hub.test:8317", account: original }, - { url: config.url, account: { ...original, id: "replacement.json" } }, - { url: config.url, account: { ...original, provider: "claude" } }, - { url: config.url, account: { ...original, email: "replacement@example.com" } }, - { url: config.url, account: { ...original, project_id: "project-b" } }, - { - url: config.url, - account: { ...original, id_token: { chatgpt_account_id: "other-workspace" } }, - }, - ]; - for (const [index, identity] of identities.entries()) { - listed[0] = identity.account; - const [account] = yield* api.readAccounts({ ...config, url: identity.url }); - expect(account).toMatchObject({ - id: identity.account.id, - email: identity.account.email, - workspaceId: identity.account.id_token.chatgpt_account_id, - driver: identity.account.provider === "claude" ? "claudeAgent" : "codex", - }); - expect(account?.usageLimits.windows[0]?.usedPercent).toBe((index + 2) * 10); - } - listed[0] = original; - expect(yield* api.readAccounts(config)).toEqual(baseline); - expect(probes).toBe(7); - }), - ); - it.effect( "shares concurrent reads without blocking other identities or retaining a pre-reset reading", () => @@ -590,27 +540,6 @@ describe("CLIProxyAPI built-in management API", () => { }), ); - it.effect("invalidates an account when reset is interrupted after the credit is spent", () => - Effect.gen(function* () { - const spent = yield* Deferred.make(); - const test = fixture({ - beforeCooldownReset: Deferred.succeed(spent, undefined).pipe(Effect.andThen(Effect.never)), - }); - const api = yield* test.api; - yield* api.readAccounts(config); - const redeem = yield* Effect.forkChild(api.consume(config, "second.json", "credit-b")); - yield* Deferred.await(spent); - yield* Fiber.interrupt(redeem); - yield* api.readAccounts(config); - expect( - test.requests - .filter((request) => request.body?.url?.endsWith("/wham/usage")) - .map((request) => request.body?.auth_index) - .sort(), - ).toEqual(["a", "b", "b"]); - }), - ); - it.effect.each([ { name: "spend budget", From aab65155233cad7ff1e758e79a0022dc90deada3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Pr=C3=B3spero?= <130605901+DJProspero1@users.noreply.github.com> Date: Wed, 11 Nov 2026 04:00:29 +0000 Subject: [PATCH 07/10] fix(web): identify unavailable usage accounts with redacted emails --- apps/web/src/components/usage/UsageLimitsPooled.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 44699f0bd2847..69b4edb4512ff 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -591,7 +591,18 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu .filter((account) => account.limits.windows.length === 0) .map((account) => (
- +
+ + {account.email ? ( + + ) : null} +
Limits unavailable
))} From 10fafc49d819e55d2889e37e2774e5dd4990a947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Pr=C3=B3spero?= <130605901+DJProspero1@users.noreply.github.com> Date: Wed, 11 Nov 2026 04:13:53 +0000 Subject: [PATCH 08/10] fix(usage): keep failure notices private and reveals accessible --- .../RedactedSensitiveText.browser.test.tsx | 25 +++++++++++++++++++ .../settings/RedactedSensitiveText.tsx | 3 ++- packages/shared/src/usageLimits.test.ts | 2 +- packages/shared/src/usageLimits.ts | 2 +- 4 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/settings/RedactedSensitiveText.browser.test.tsx diff --git a/apps/web/src/components/settings/RedactedSensitiveText.browser.test.tsx b/apps/web/src/components/settings/RedactedSensitiveText.browser.test.tsx new file mode 100644 index 0000000000000..89381d5ecdfdb --- /dev/null +++ b/apps/web/src/components/settings/RedactedSensitiveText.browser.test.tsx @@ -0,0 +1,25 @@ +import { expect, it } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { render } from "../../../test/render"; +import { RedactedSensitiveText } from "./RedactedSensitiveText"; + +it("exposes the value to assistive technology only while revealed", async () => { + await using screen = await render( + , + ); + const button = screen.getByRole("button"); + await expect.element(button).toHaveAccessibleName("Toggle account email visibility"); + await expect.element(button).toHaveAttribute("aria-pressed", "false"); + await userEvent.click(button); + await expect.element(button).toHaveAccessibleName("account@example.com"); + await expect.element(button).toHaveAttribute("aria-pressed", "true"); + await userEvent.click(button); + await expect.element(button).toHaveAccessibleName("Toggle account email visibility"); + await expect.element(button).toHaveAttribute("aria-pressed", "false"); +}); diff --git a/apps/web/src/components/settings/RedactedSensitiveText.tsx b/apps/web/src/components/settings/RedactedSensitiveText.tsx index 9517a1844899a..0838e64b45438 100644 --- a/apps/web/src/components/settings/RedactedSensitiveText.tsx +++ b/apps/web/src/components/settings/RedactedSensitiveText.tsx @@ -49,7 +49,8 @@ export function RedactedSensitiveText(props: { props.className, )} onClick={() => setRevealed((current) => !current)} - aria-label={props.ariaLabel} + aria-label={revealed ? value : props.ariaLabel} + aria-pressed={revealed} > {revealed ? value : redacted} diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 2f406ee4e2226..5f12df061e266 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -736,7 +736,7 @@ describe("pools", () => { expect(collected.map((entry) => entry.limits.windows.length)).toEqual([1, 0]); expect(collectLimitPools(collected, Date.parse(checkedAt))[0]?.accounts).toHaveLength(2); expect(collectLimitNotices(input)).toEqual([ - `Desktop · hub · account@example.com: ${unavailable.message ?? "Could not read limits."}`, + `Desktop · hub · Claude: ${unavailable.message ?? "Could not read limits."}`, ]); input.delete(EnvironmentId.make("env-a")); expect(collectLimitAccounts(input)).toHaveLength(2); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 175057b42862a..fe7e14205a2dd 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -423,7 +423,7 @@ export function collectLimitNotices(presentations: LimitPresentations): readonly if (account.usageLimits.unavailable?.reason === "unsupported") continue; const notice = limitsNotice(account.usageLimits); if (notice) { - const name = `${source.label} · ${account.email || hubAccountName(account)}`; + const name = `${source.label} · ${PROVIDER_DISPLAY_NAMES[account.driver] ?? account.driver}`; notices.push(`${label(environmentLabel, name)}: ${notice}`); } } From 1441b2cccbc50c1b6cfa8a4d0dbdca55f696f833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Pr=C3=B3spero?= <130605901+DJProspero1@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:54:31 +0000 Subject: [PATCH 09/10] fix(usage): identify partial pools and retained account readings --- .../src/features/usage/UsageLimitsPooled.tsx | 12 +++++- .../widgets/subscriptionUsageSnapshot.test.ts | 3 +- .../src/widgets/subscriptionUsageSnapshot.ts | 37 ++++++++++++------- .../components/usage/UsageLimitsPooled.tsx | 10 ++++- 4 files changed, 43 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 81702a9713247..d5588f742f582 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -443,7 +443,11 @@ export function UsageLimitsSection({ {pool.accounts - .filter((account) => account.limits.windows.length === 0) + .filter( + (account) => + account.limits.windows.length === 0 || + account.limits.unavailable?.reason === "probeFailed", + ) .map((account) => ( {accountName(account)} - Limits unavailable + + {account.limits.windows.length > 0 + ? "Last known limits" + : "Limits unavailable"} + ))} {windows.map((window) => { diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts index 49fd93197685d..febcad004d5a7 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts @@ -142,7 +142,8 @@ describe("subscription widget snapshots", () => { }, ]); const partial = buildSubscriptionUsageSnapshot(failedInput([], true), deepLink); - expect(partial.providers[0]?.detail).toBe("Available subscription limits"); + expect(partial.providers[0]?.detail).toBe("Partial limits"); + expect(partial.providers[0]?.windows[0]?.label).toBe("Partial · 5 hours"); expect(partial.providers[0]?.windows[0]?.remaining).toBe(60); expect(partial.checkedAt).toBe(now); expect(partial.providers[0]?.expiresAt).toBe(now + 10 * 60_000); diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts index ecf05acadb7ac..1e768477d45be 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts @@ -106,29 +106,38 @@ function subscriptionUsageProps( .filter((window) => window !== undefined) .slice(0, maxWindowsPerProvider) .sort((a, b) => a.remainingPercent - b.remainingPercent); + const partial = pool.accounts.some( + (account) => + account.limits.windows.length === 0 && + account.limits.unavailable?.reason === "probeFailed", + ); return { name, detail: !fresh ? "Open T3 to refresh" - : pool.accounts.some( - (account) => - account.limits.windows.length > 0 && - account.limits.unavailable?.reason === "probeFailed", - ) - ? "Last known limits" - : pool.accounts.length > 1 - ? "Available subscription limits" - : "Subscription remaining", + : partial + ? "Partial limits" + : pool.accounts.some( + (account) => + account.limits.windows.length > 0 && + account.limits.unavailable?.reason === "probeFailed", + ) + ? "Last known limits" + : pool.accounts.length > 1 + ? "Available subscription limits" + : "Subscription remaining", expiresAt: fresh ? expiresAt : 0, totalWindows: fresh ? pool.windows.length : 0, windows: fresh ? selectedWindows.map((window) => ({ kind: window.kind, - label: window.members.some( - (member) => member.account.limits.unavailable?.reason === "probeFailed", - ) - ? `Stale · ${window.label}` - : window.label, + label: `${partial ? "Partial · " : ""}${ + window.members.some( + (member) => member.account.limits.unavailable?.reason === "probeFailed", + ) + ? `Stale · ${window.label}` + : window.label + }`, remaining: Math.round(window.remainingPercent), reset: window.resets[0] ? `${isUpcoming(window.resets[0]) ? "Next reset" : "Reset"} ${new Date( diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 69b4edb4512ff..9ff651c9b6589 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -588,7 +588,11 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu {label} {pool.accounts - .filter((account) => account.limits.windows.length === 0) + .filter( + (account) => + account.limits.windows.length === 0 || + account.limits.unavailable?.reason === "probeFailed", + ) .map((account) => (
@@ -603,7 +607,9 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu /> ) : null}
- Limits unavailable + + {account.limits.windows.length > 0 ? "Last known limits" : "Limits unavailable"} +
))} {windows.map((window) => { From b159106cd6f2256b5ef6c1a6aee7d5bf1695ee4e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 16:25:05 +0000 Subject: [PATCH 10/10] refactor(usage): trim failed-account presentation to the fix Keep the widget's pooled wording and window labels, drop the mobile row's account number that did not match segment numbering, and leave the redacted-text accessibility change for its own pull request. Co-Authored-By: Claude Opus 5.5 --- .../src/features/usage/UsageLimitsPooled.tsx | 10 +++----- .../widgets/subscriptionUsageSnapshot.test.ts | 8 ++---- .../src/widgets/subscriptionUsageSnapshot.ts | 24 +++++------------- .../RedactedSensitiveText.browser.test.tsx | 25 ------------------- .../settings/RedactedSensitiveText.tsx | 3 +-- 5 files changed, 13 insertions(+), 57 deletions(-) delete mode 100644 apps/web/src/components/settings/RedactedSensitiveText.browser.test.tsx diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 6cbd6d90285c1..5bf8fef6dd7ef 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -455,12 +455,10 @@ export function UsageLimitsSection({ key={account.key} className="flex-row items-center gap-3 rounded-[24px] border-continuous bg-grouped-card p-4" > - - - {pool.accounts.indexOf(account) + 1} - - - + {accountName(account)} diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts index febcad004d5a7..e667132e0072d 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts @@ -143,17 +143,13 @@ describe("subscription widget snapshots", () => { ]); const partial = buildSubscriptionUsageSnapshot(failedInput([], true), deepLink); expect(partial.providers[0]?.detail).toBe("Partial limits"); - expect(partial.providers[0]?.windows[0]?.label).toBe("Partial · 5 hours"); expect(partial.providers[0]?.windows[0]?.remaining).toBe(60); expect(partial.checkedAt).toBe(now); expect(partial.providers[0]?.expiresAt).toBe(now + 10 * 60_000); expect(subscriptionUsageTimeline(partial, now)[0]?.props.providers[0]?.windows).toHaveLength(1); const retained = buildSubscriptionUsageSnapshot(failedInput(limits.windows), deepLink); expect(retained.providers[0]?.detail).toBe("Last known limits"); - expect(retained.providers[0]?.windows[0]).toMatchObject({ - label: "Stale · 5 hours", - remaining: 60, - }); + expect(retained.providers[0]?.windows[0]?.remaining).toBe(60); expect(retained.checkedAt).toBe(now); expect(retained.providers[0]?.expiresAt).toBe(now + 10 * 60_000); }); @@ -243,7 +239,7 @@ describe("subscription widget expiry", () => { deepLink, ); expect(snapshot.providers[0]).toMatchObject({ - detail: "Available subscription limits", + detail: "2 accounts · pooled", expiresAt, windows: [{ remaining: 60, reset: expect.stringMatching(label) }], }); diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts index 1e768477d45be..2a243c9d8c9ef 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts @@ -106,38 +106,26 @@ function subscriptionUsageProps( .filter((window) => window !== undefined) .slice(0, maxWindowsPerProvider) .sort((a, b) => a.remainingPercent - b.remainingPercent); - const partial = pool.accounts.some( - (account) => - account.limits.windows.length === 0 && - account.limits.unavailable?.reason === "probeFailed", + const failed = pool.accounts.filter( + (account) => account.limits.unavailable?.reason === "probeFailed", ); return { name, detail: !fresh ? "Open T3 to refresh" - : partial + : failed.some((account) => account.limits.windows.length === 0) ? "Partial limits" - : pool.accounts.some( - (account) => - account.limits.windows.length > 0 && - account.limits.unavailable?.reason === "probeFailed", - ) + : failed.length > 0 ? "Last known limits" : pool.accounts.length > 1 - ? "Available subscription limits" + ? `${pool.accounts.length} accounts · pooled` : "Subscription remaining", expiresAt: fresh ? expiresAt : 0, totalWindows: fresh ? pool.windows.length : 0, windows: fresh ? selectedWindows.map((window) => ({ kind: window.kind, - label: `${partial ? "Partial · " : ""}${ - window.members.some( - (member) => member.account.limits.unavailable?.reason === "probeFailed", - ) - ? `Stale · ${window.label}` - : window.label - }`, + label: window.label, remaining: Math.round(window.remainingPercent), reset: window.resets[0] ? `${isUpcoming(window.resets[0]) ? "Next reset" : "Reset"} ${new Date( diff --git a/apps/web/src/components/settings/RedactedSensitiveText.browser.test.tsx b/apps/web/src/components/settings/RedactedSensitiveText.browser.test.tsx deleted file mode 100644 index 89381d5ecdfdb..0000000000000 --- a/apps/web/src/components/settings/RedactedSensitiveText.browser.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { expect, it } from "vite-plus/test"; -import { userEvent } from "vite-plus/test/browser"; - -import { render } from "../../../test/render"; -import { RedactedSensitiveText } from "./RedactedSensitiveText"; - -it("exposes the value to assistive technology only while revealed", async () => { - await using screen = await render( - , - ); - const button = screen.getByRole("button"); - await expect.element(button).toHaveAccessibleName("Toggle account email visibility"); - await expect.element(button).toHaveAttribute("aria-pressed", "false"); - await userEvent.click(button); - await expect.element(button).toHaveAccessibleName("account@example.com"); - await expect.element(button).toHaveAttribute("aria-pressed", "true"); - await userEvent.click(button); - await expect.element(button).toHaveAccessibleName("Toggle account email visibility"); - await expect.element(button).toHaveAttribute("aria-pressed", "false"); -}); diff --git a/apps/web/src/components/settings/RedactedSensitiveText.tsx b/apps/web/src/components/settings/RedactedSensitiveText.tsx index 0838e64b45438..9517a1844899a 100644 --- a/apps/web/src/components/settings/RedactedSensitiveText.tsx +++ b/apps/web/src/components/settings/RedactedSensitiveText.tsx @@ -49,8 +49,7 @@ export function RedactedSensitiveText(props: { props.className, )} onClick={() => setRevealed((current) => !current)} - aria-label={revealed ? value : props.ariaLabel} - aria-pressed={revealed} + aria-label={props.ariaLabel} > {revealed ? value : redacted}