diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx
index 2f0ad0f89dfc0..d5588f742f582 100644
--- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx
+++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx
@@ -442,6 +442,32 @@ export function UsageLimitsSection({
{providerClients.get(pool.driver)?.label ?? pool.driver}
+ {pool.accounts
+ .filter(
+ (account) =>
+ account.limits.windows.length === 0 ||
+ account.limits.unavailable?.reason === "probeFailed",
+ )
+ .map((account) => (
+
+
+
+ {pool.accounts.indexOf(account) + 1}
+
+
+
+ {accountName(account)}
+
+
+ {account.limits.windows.length > 0
+ ? "Last known limits"
+ : "Limits unavailable"}
+
+
+ ))}
{windows.map((window) => {
const details =
pool.driver === "cursor" ? cursorUsageWindowDetails(window.id) : undefined;
diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts
index 2b88a45db8ff1..febcad004d5a7 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";
@@ -99,6 +100,62 @@ 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 failedInput = (windows: typeof limits.windows, includeHealthy = false) =>
+ new Map([
+ [
+ EnvironmentId.make("env"),
+ {
+ entry: { target: { label: "Remote" } },
+ serverConfig: {
+ providers: [],
+ usageLimitSources: [
+ {
+ ...source,
+ accounts: [
+ ...(includeHealthy ? source.accounts : []),
+ ...source.accounts.map((account) => ({
+ ...account,
+ id: "failed-account",
+ email: "failed@example.com",
+ usageLimits: {
+ checkedAt: windows.length > 0 ? checkedAt : "2026-09-05T11:00:00.000Z",
+ windows,
+ unavailable: { reason: "probeFailed" as const },
+ },
+ })),
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ ]);
+ const failed = buildSubscriptionUsageSnapshot(failedInput([]), deepLink);
+ expect(failed.providers).toEqual([
+ {
+ name: "Codex",
+ detail: "No limits available",
+ windows: [],
+ expiresAt: 0,
+ totalWindows: 0,
+ },
+ ]);
+ 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.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) => ({
@@ -186,7 +243,7 @@ describe("subscription widget expiry", () => {
deepLink,
);
expect(snapshot.providers[0]).toMatchObject({
- detail: "2 accounts · pooled",
+ detail: "Available subscription limits",
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 db99110c5d357..1e768477d45be 100644
--- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts
+++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts
@@ -57,7 +57,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,
@@ -69,7 +73,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",
@@ -77,7 +81,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) =>
@@ -98,19 +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.length > 1
- ? `${pool.accounts.length} accounts · pooled`
- : "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.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/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts
index 431af7b5971ff..2499270eb2d2f 100644
--- a/apps/server/src/usage/cliproxyApi.test.ts
+++ b/apps/server/src/usage/cliproxyApi.test.ts
@@ -1,10 +1,12 @@
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as HostProcess from "@t3tools/shared/HostProcess";
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";
@@ -60,11 +62,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 =
@@ -108,11 +112,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,
@@ -373,6 +385,161 @@ 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(
+ "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.each([
{
name: "spend budget",
diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts
index 030983f50e33d..244ae2e80cc1e 100644
--- a/apps/server/src/usage/cliproxyApi.ts
+++ b/apps/server/src/usage/cliproxyApi.ts
@@ -6,12 +6,17 @@ import {
type UsageLimitSourceConfig,
} from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
+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 Option from "effect/Option";
+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";
@@ -39,6 +44,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,
@@ -197,7 +224,10 @@ type AuthFile = typeof AuthFile.Type;
type HubCall = (
url: string,
data?: unknown,
-) => Effect.Effect;
+) => Effect.Effect<
+ string,
+ UsageLimitSourceError | HubProviderRateLimitedError | Schema.SchemaError
+>;
type HubReading = Omit;
interface HubProvider {
readonly driver: ProviderDriverKind;
@@ -210,7 +240,11 @@ interface HubProvider {
readonly account: AuthFile;
readonly call: HubCall;
readonly checkedAt: string;
- }) => Effect.Effect;
+ }) => Effect.Effect<
+ HubReading,
+ UsageLimitSourceError | HubProviderRateLimitedError | Schema.SchemaError,
+ Digest.Digest
+ >;
}
const listedIdentity = (account: AuthFile) => ({
@@ -453,6 +487,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}).`,
@@ -461,26 +496,91 @@ export const makeCliproxyApi = Effect.gen(function* () {
return response.body;
});
+ const usageReads = yield* Ref.make>(new Map());
+ const usageReadLock = yield* KeyedLock.make();
+ const usageReadKey = (hub: CliproxyHub, account: AuthFile) =>
+ JSON.stringify([
+ hub.url,
+ account.id,
+ account.provider,
+ account.email ?? "",
+ account.id_token?.chatgpt_account_id ?? "",
+ account.project_id ?? "",
+ ]);
+
const readAccount = Effect.fn("CliproxyApi.readAccount")(function* (
hub: CliproxyHub,
account: AuthFile,
provider: HubProvider,
) {
const checkedAt = DateTime.formatIso(yield* DateTime.now);
+ const base = { id: account.id, driver: provider.driver, ...listedIdentity(account) };
const call: HubCall = (url, data) =>
apiCall(hub, account, provider.header(account, platform, architecture), url, data);
- const reading = yield* provider.read({ account, call, checkedAt }).pipe(
+ const read = provider.read({ account, call, checkedAt }).pipe(
Effect.provideService(Digest.Digest, digest),
- Effect.orElseSucceed(() => ({
- ...listedIdentity(account),
- usageLimits: makeUnavailableUsageLimits({
- checkedAt,
- reason: "probeFailed",
- message: "The hub could not read this account's usage.",
- }),
- })),
+ Effect.map((reading) => ({ id: account.id, driver: provider.driver, ...reading })),
+ );
+ 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,
+ }),
+ };
+ }),
);
- return { id: account.id, driver: provider.driver, ...reading };
});
const readAccounts = Effect.fn("CliproxyApi.readAccounts")(function* (
@@ -508,13 +608,8 @@ 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.",
- });
- }
+ // The enclosing CliproxyApi.consume span covers this inline helper.
+ const redeem = Effect.fnUntraced(function* (account: AuthFile) {
const body = yield* apiCall(
hub,
account,
@@ -551,12 +646,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/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/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx
index f2d5790ece53b..9ff651c9b6589 100644
--- a/apps/web/src/components/usage/UsageLimitsPooled.tsx
+++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx
@@ -587,6 +587,31 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu
/>
{label}
+ {pool.accounts
+ .filter(
+ (account) =>
+ account.limits.windows.length === 0 ||
+ account.limits.unavailable?.reason === "probeFailed",
+ )
+ .map((account) => (
+
+
+
+ {account.email ? (
+
+ ) : null}
+
+
+ {account.limits.windows.length > 0 ? "Last known limits" : "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 e6b3a46d5a14e..79309983a0a00 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
@@ -178,7 +179,10 @@ environment that will connect to the hub and enter its URL and management key.
The hub's Codex, Claude, xAI (shown as Grok) and Antigravity accounts appear under **Usage → Limits**;
accounts for other providers are not read. 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 50cd2792389fd..39ed45e4de593 100644
--- a/packages/shared/src/usageLimits.test.ts
+++ b/packages/shared/src/usageLimits.test.ts
@@ -687,6 +687,96 @@ 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: "2026-09-03T11:01:00.000Z",
+ 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" }],
+ },
+ ],
+ },
+ });
+ 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 · Claude: ${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", () => {
const seat = {
id: "claude-team-seat.json",
diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts
index a75d1fc88a4d5..1f882b0cc8b56 100644
--- a/packages/shared/src/usageLimits.ts
+++ b/packages/shared/src/usageLimits.ts
@@ -307,7 +307,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(
@@ -369,7 +376,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,
@@ -416,6 +427,14 @@ export function collectLimitNotices(presentations: LimitPresentations): readonly
} else if (source.accounts.length === 0) {
notices.add(`${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} · ${PROVIDER_DISPLAY_NAMES[account.driver] ?? account.driver}`;
+ notices.add(`${label(environmentLabel, name)}: ${notice}`);
+ }
+ }
}
}
return [...notices];