From cc343300d324cd3464b3455a1e2b846f0e97f9da Mon Sep 17 00:00:00 2001
From: Corey Baines
Date: Tue, 8 Sep 2026 11:09:44 +1000
Subject: [PATCH 1/2] feat(usage): redeem Codex resets from provider settings
and profile
---
apps/server/src/auth/RpcAuthorization.ts | 1 +
.../ProviderResetCredits.test.ts | 349 ++++++++++++++++++
.../src/providerUsage/ProviderUsageService.ts | 225 +++++++++++
apps/server/src/ws.ts | 9 +
.../PathwayConnectSidebarSignIn.test.tsx | 4 +
.../clerk/PathwayConnectSidebarSignIn.tsx | 14 +-
.../usage/ProviderResetCredits.test.tsx | 222 +++++++++++
.../components/usage/ProviderResetCredits.tsx | 300 +++++++++++++++
.../components/usage/ProviderUsage.test.tsx | 41 ++
.../src/components/usage/ProviderUsage.tsx | 69 ++--
.../usage/providerUsageAccounts.test.ts | 92 +++++
.../components/usage/providerUsageAccounts.ts | 37 +-
docs/user/provider-limits.md | 8 +
packages/client-runtime/src/state/server.ts | 9 +
packages/contracts/src/providerUsage.test.ts | 13 +
packages/contracts/src/providerUsage.ts | 37 +-
packages/contracts/src/rpc.ts | 20 +-
17 files changed, 1423 insertions(+), 27 deletions(-)
create mode 100644 apps/server/src/providerUsage/ProviderResetCredits.test.ts
create mode 100644 apps/web/src/components/usage/ProviderResetCredits.test.tsx
create mode 100644 apps/web/src/components/usage/ProviderResetCredits.tsx
diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts
index e1f9345f7..4cb3f4fb7 100644
--- a/apps/server/src/auth/RpcAuthorization.ts
+++ b/apps/server/src/auth/RpcAuthorization.ts
@@ -59,6 +59,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope,
[WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope,
[WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope,
+ [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope,
[WS_METHODS.serverGetProviderUsage]: AuthOrchestrationReadScope,
[WS_METHODS.serverSubscribeProviderUsage]: AuthOrchestrationReadScope,
[WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope,
diff --git a/apps/server/src/providerUsage/ProviderResetCredits.test.ts b/apps/server/src/providerUsage/ProviderResetCredits.test.ts
new file mode 100644
index 000000000..9eefc27f6
--- /dev/null
+++ b/apps/server/src/providerUsage/ProviderResetCredits.test.ts
@@ -0,0 +1,349 @@
+// @effect-diagnostics nodeBuiltinImport:off -- isolated credential fixtures.
+import * as Effect from "effect/Effect";
+import * as Fiber from "effect/Fiber";
+import * as NodeCrypto from "node:crypto";
+import * as NodeFSP from "node:fs/promises";
+import * as NodeOS from "node:os";
+import * as NodePath from "node:path";
+import { ProviderInstanceId } from "@spiritdevs/contracts";
+import { afterEach, vi } from "vite-plus/test";
+import { describe, expect, it } from "@effect/vitest";
+import { ServerSettingsService } from "../serverSettings.ts";
+import {
+ consumeProviderResetCredit,
+ getProviderUsage,
+ ingestPushedSnapshot,
+ mapCodexRateLimitsUpdated,
+ parseCodexResetCredits,
+ providerUsageTestKit,
+ resetCreditRequestId,
+ resetProviderUsageCache,
+} from "./ProviderUsageService.ts";
+
+const nowMs = Date.parse("2026-09-08T00:00:00Z");
+const instanceId = ProviderInstanceId.make("codex-reset-test");
+const accountKey = NodeCrypto.createHash("sha256").update("account-a").digest("hex");
+const credit = { instanceId, accountKey, creditId: "credit-a" };
+const directories: string[] = [];
+async function context() {
+ const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pathway-reset-test-"));
+ directories.push(home);
+ await NodeFSP.writeFile(
+ NodePath.join(home, "auth.json"),
+ JSON.stringify({ tokens: { access_token: "token", account_id: "account-a" } }),
+ );
+ return { instanceId, provider: "codex" as const, nowMs, providerHomePath: home, homeDir: home };
+}
+afterEach(async () => {
+ vi.unstubAllGlobals();
+ resetProviderUsageCache();
+ await Promise.all(
+ directories
+ .splice(0)
+ .map((directory) => NodeFSP.rm(directory, { recursive: true, force: true })),
+ );
+});
+
+describe("Codex reset credits", () => {
+ it("lists only available, unexpired Codex credits in expiry order", () => {
+ const row = {
+ id: "later",
+ status: "available",
+ reset_type: "codex_rate_limits",
+ expires_at: "2026-09-10T00:00:00Z",
+ };
+ expect(
+ parseCodexResetCredits(
+ {
+ credits: [
+ row,
+ { ...row, id: "soon", expires_at: "2026-09-09T00:00:00Z" },
+ { ...row, status: "redeemed" },
+ { ...row, reset_type: "other" },
+ { ...row, expires_at: "2020-01-01" },
+ { ...row, expires_at: "invalid" },
+ ],
+ },
+ nowMs,
+ ),
+ ).toEqual({
+ availableCount: 2,
+ credits: [
+ { id: "soon", expiresAt: "2026-09-09T00:00:00.000Z" },
+ { id: "later", expiresAt: "2026-09-10T00:00:00.000Z" },
+ ],
+ nextExpiresAt: "2026-09-09T00:00:00.000Z",
+ });
+ expect(parseCodexResetCredits({}, nowMs)).toBeUndefined();
+ expect(parseCodexResetCredits({ credits: [] }, nowMs)).toEqual({
+ availableCount: 0,
+ credits: [],
+ });
+ });
+
+ it("keeps quota available when the credits endpoint fails", async () => {
+ const ctx = await context();
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (url: string) =>
+ url.endsWith("/usage")
+ ? Response.json({ rate_limit: { primary_window: { used_percent: 20 } } })
+ : new Response("unavailable", { status: 500 }),
+ ),
+ );
+ const result = await providerUsageTestKit.fetchCodex(ctx);
+ expect(result.snapshot.status).toBe("ok");
+ expect(result.snapshot.limits[0]?.usedPercent).toBe(20);
+ expect(result.snapshot.resetCredits).toBeUndefined();
+ });
+
+ it.effect("preserves reset credits when a streamed update only changes usage windows", () =>
+ Effect.gen(function* () {
+ const resetCredits = {
+ availableCount: 1,
+ credits: [{ id: "credit-a", expiresAt: "2026-09-10T00:00:00.000Z" }],
+ };
+ yield* Effect.promise(() =>
+ providerUsageTestKit.resolve({ instanceId, provider: "codex", nowMs }, async () => ({
+ snapshot: {
+ instanceId,
+ provider: "codex",
+ accountKey,
+ updatedAt: "2026-09-08T00:00:00.000Z",
+ status: "ok",
+ source: "test",
+ limits: [],
+ usageLines: [],
+ resetCredits,
+ },
+ })),
+ );
+ providerUsageTestKit.setCodexAccountKeyReader(instanceId, async () => accountKey);
+ yield* ingestPushedSnapshot(
+ mapCodexRateLimitsUpdated({
+ instanceId,
+ rateLimits: { primary: { usedPercent: 50, windowDurationMins: 300 } },
+ }),
+ nowMs + 1,
+ );
+ const result = yield* Effect.promise(() =>
+ providerUsageTestKit.resolve(
+ { instanceId, provider: "codex", nowMs: nowMs + 2 },
+ async () => {
+ throw new Error("should stay cached");
+ },
+ ),
+ );
+ expect(result.resetCredits).toEqual(resetCredits);
+ }),
+ );
+
+ it.effect("rejects disabled and missing instances without contacting Codex", () =>
+ Effect.gen(function* () {
+ const fetch = vi.fn();
+ vi.stubGlobal("fetch", fetch);
+ for (const providerInstances of [{}, { [instanceId]: { driver: "codex", enabled: false } }]) {
+ const result = yield* consumeProviderResetCredit(credit).pipe(
+ Effect.provide(ServerSettingsService.layerTest({ providerInstances })),
+ Effect.result,
+ );
+ expect(result._tag).toBe("Failure");
+ }
+ expect(fetch).not.toHaveBeenCalled();
+ }),
+ );
+
+ it.effect("returns a confirmed outcome with a warning when refreshing usage fails", () =>
+ Effect.gen(function* () {
+ const ctx = yield* Effect.promise(context);
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (url: string) =>
+ url.endsWith("/consume")
+ ? Response.json({ code: "reset" })
+ : new Response("unavailable", { status: 500 }),
+ ),
+ );
+ const result = yield* consumeProviderResetCredit(credit).pipe(
+ Effect.provide(
+ ServerSettingsService.layerTest({
+ providerInstances: {
+ [instanceId]: { driver: "codex", config: { homePath: ctx.providerHomePath } },
+ },
+ }),
+ ),
+ );
+ expect(result.outcome).toBe("reset");
+ expect(result.warning).toContain("latest usage");
+ }),
+ );
+
+ it.effect("publishes refreshed credits and limits after redemption", () =>
+ Effect.gen(function* () {
+ const ctx = yield* Effect.promise(context);
+ let spent = false;
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (url: string) => {
+ if (url.endsWith("/consume")) {
+ spent = true;
+ return Response.json({ code: "reset" });
+ }
+ if (url.endsWith("/usage"))
+ return Response.json({
+ rate_limit: { primary_window: { used_percent: spent ? 0 : 90 } },
+ });
+ return Response.json({
+ credits: spent
+ ? []
+ : [
+ {
+ id: "credit-a",
+ status: "available",
+ reset_type: "codex_rate_limits",
+ expires_at: "2099-09-10T00:00:00Z",
+ },
+ ],
+ });
+ }),
+ );
+ yield* Effect.gen(function* () {
+ expect(
+ (yield* getProviderUsage({ instanceId, provider: "codex" })).resetCredits?.availableCount,
+ ).toBe(1);
+ const result = yield* consumeProviderResetCredit(credit);
+ expect(result).toEqual({ outcome: "reset" });
+ const snapshot = yield* getProviderUsage({ instanceId, provider: "codex" });
+ expect(snapshot.resetCredits?.availableCount).toBe(0);
+ expect(snapshot.limits[0]?.usedPercent).toBe(0);
+ }).pipe(
+ Effect.provide(
+ ServerSettingsService.layerTest({
+ providerInstances: {
+ [instanceId]: { driver: "codex", config: { homePath: ctx.providerHomePath } },
+ },
+ }),
+ ),
+ );
+ }),
+ );
+
+ it.effect("does not let a pre-redemption forced read overwrite the refreshed account", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const ctx = yield* Effect.promise(context);
+ const oldResponse = Promise.withResolvers();
+ const oldStarted = Promise.withResolvers();
+ let usageReads = 0;
+ let spent = false;
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (url: string) => {
+ if (url.endsWith("/consume")) {
+ spent = true;
+ return Response.json({ code: "reset" });
+ }
+ if (url.endsWith("/usage")) {
+ usageReads += 1;
+ if (usageReads === 2) {
+ oldStarted.resolve();
+ return oldResponse.promise;
+ }
+ return Response.json({
+ rate_limit: { primary_window: { used_percent: spent ? 0 : 90 } },
+ });
+ }
+ return Response.json({
+ credits: spent
+ ? []
+ : [
+ {
+ id: "credit-a",
+ status: "available",
+ reset_type: "codex_rate_limits",
+ expires_at: "2099-09-10T00:00:00Z",
+ },
+ ],
+ });
+ }),
+ );
+ yield* Effect.gen(function* () {
+ yield* getProviderUsage({ instanceId, provider: "codex" });
+ const pending = yield* getProviderUsage({
+ instanceId,
+ provider: "codex",
+ forceRefresh: true,
+ }).pipe(Effect.forkScoped);
+ yield* Effect.promise(() => oldStarted.promise);
+ expect(yield* consumeProviderResetCredit(credit)).toEqual({ outcome: "reset" });
+ oldResponse.resolve(
+ Response.json({ rate_limit: { primary_window: { used_percent: 90 } } }),
+ );
+ yield* Fiber.join(pending);
+ const snapshot = yield* getProviderUsage({ instanceId, provider: "codex" });
+ expect(snapshot.limits[0]?.usedPercent).toBe(0);
+ expect(snapshot.resetCredits?.availableCount).toBe(0);
+ }).pipe(
+ Effect.provide(
+ ServerSettingsService.layerTest({
+ providerInstances: {
+ [instanceId]: { driver: "codex", config: { homePath: ctx.providerHomePath } },
+ },
+ }),
+ ),
+ );
+ }),
+ ),
+ );
+
+ it("rejects an account switch before sending redemption", async () => {
+ const ctx = await context();
+ const fetch = vi.fn();
+ vi.stubGlobal("fetch", fetch);
+ await expect(
+ providerUsageTestKit.redeemCodex(ctx, { ...credit, accountKey: "other-account" }),
+ ).rejects.toThrow("account changed");
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it("reuses the exact account-and-credit request id after an ambiguous failure", async () => {
+ const ctx = await context();
+ const fetch = vi
+ .fn()
+ .mockRejectedValueOnce(new Error("connection lost"))
+ .mockResolvedValueOnce(Response.json({ code: "already_redeemed" }));
+ vi.stubGlobal("fetch", fetch);
+ await expect(providerUsageTestKit.redeemCodex(ctx, credit)).rejects.toThrow("connection lost");
+ await expect(providerUsageTestKit.redeemCodex(ctx, credit)).resolves.toEqual({
+ outcome: "alreadyRedeemed",
+ });
+ const bodies = fetch.mock.calls.map((call) => JSON.parse(call[1].body));
+ expect(bodies[0]).toEqual(bodies[1]);
+ expect(bodies[0]).toEqual({
+ credit_id: credit.creditId,
+ redeem_request_id: resetCreditRequestId(accountKey, credit.creditId),
+ });
+ expect(resetCreditRequestId("another-account", credit.creditId)).not.toBe(
+ bodies[0].redeem_request_id,
+ );
+ });
+
+ it("rejects a different pending credit on the same account", async () => {
+ const ctx = await context();
+ const response = Promise.withResolvers();
+ const started = Promise.withResolvers();
+ const fetch = vi.fn(() => {
+ started.resolve();
+ return response.promise;
+ });
+ vi.stubGlobal("fetch", fetch);
+ const first = providerUsageTestKit.redeemCodex(ctx, credit);
+ await started.promise;
+ await expect(
+ providerUsageTestKit.redeemCodex(ctx, { ...credit, creditId: "credit-b" }),
+ ).rejects.toThrow("already being redeemed");
+ response.resolve(Response.json({ code: "reset" }));
+ expect(await first).toEqual({ outcome: "reset" });
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/server/src/providerUsage/ProviderUsageService.ts b/apps/server/src/providerUsage/ProviderUsageService.ts
index 163827528..b7969c033 100644
--- a/apps/server/src/providerUsage/ProviderUsageService.ts
+++ b/apps/server/src/providerUsage/ProviderUsageService.ts
@@ -16,6 +16,9 @@ import * as NodePath from "node:path";
import * as NodeUtil from "node:util";
import type {
+ ProviderConsumeResetCreditInput,
+ ProviderConsumeResetCreditResult,
+ ServerProviderResetCredits,
ProviderInstanceEnvironment,
ProviderUsageDriver,
ServerGetProviderUsageInput,
@@ -26,6 +29,7 @@ import type {
ServerProviderUsageSnapshot,
} from "@spiritdevs/contracts";
import {
+ ProviderResetCreditError,
defaultInstanceIdForDriver,
ProviderDriverKind,
ProviderInstanceId,
@@ -925,9 +929,140 @@ async function fetchCodexUsage(ctx: ProviderContext): Promise undefined);
+ return {
+ ...result,
+ snapshot: { ...result.snapshot, accountKey, ...(resetCredits ? { resetCredits } : {}) },
+ };
+ }
return accountKey ? { ...result, snapshot: { ...result.snapshot, accountKey } } : result;
}
+const isProviderResetCreditError = Schema.is(ProviderResetCreditError);
+const CODEX_RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
+
+function codexResetHeaders(auth: CodexAuth): Record {
+ return {
+ Authorization: `Bearer ${auth.accessToken}`,
+ "Content-Type": "application/json",
+ "OpenAI-Beta": "codex-1",
+ Originator: "Codex Desktop",
+ ...(auth.accountId ? { "ChatGPT-Account-Id": auth.accountId } : {}),
+ };
+}
+
+export function parseCodexResetCredits(
+ json: unknown,
+ nowMs: number,
+): ServerProviderResetCredits | undefined {
+ const rows = asRecord(json)?.credits;
+ if (!Array.isArray(rows)) return undefined;
+ const credits = rows
+ .flatMap((row) => {
+ const record = asRecord(row);
+ const id = asString(record?.id);
+ const expiry = asString(record?.expires_at);
+ if (
+ !id ||
+ !expiry ||
+ record?.status !== "available" ||
+ record.reset_type !== "codex_rate_limits" ||
+ !(Date.parse(expiry) > nowMs)
+ )
+ return [];
+ return [{ id, expiresAt: new Date(expiry).toISOString() }];
+ })
+ .sort((a, b) => Date.parse(a.expiresAt) - Date.parse(b.expiresAt));
+ return {
+ availableCount: credits.length,
+ credits,
+ ...(credits[0] ? { nextExpiresAt: credits[0].expiresAt } : {}),
+ };
+}
+
+async function fetchCodexResetCredits(auth: CodexAuth, nowMs: number) {
+ const result = await fetchJson({
+ url: CODEX_RESET_CREDITS_URL,
+ headers: codexResetHeaders(auth),
+ });
+ return result.ok ? parseCodexResetCredits(result.json, nowMs) : undefined;
+}
+
+/** Stable across instances, environments and retries of the displayed credit. */
+export function resetCreditRequestId(accountKey: string, creditId: string): string {
+ const bytes = NodeCrypto.createHash("sha256")
+ .update(JSON.stringify(["pathway-reset-credit", accountKey, creditId]))
+ .digest()
+ .subarray(0, 16);
+ bytes[6] = (bytes[6]! & 0x0f) | 0x50;
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80;
+ const hex = bytes.toString("hex");
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
+}
+
+const resetCreditRequests = new Map<
+ string,
+ { creditId: string; promise: Promise }
+>();
+async function redeemCodexResetCredit(
+ ctx: ProviderContext,
+ input: ProviderConsumeResetCreditInput,
+): Promise {
+ const auth = await resolveCodexAuth(ctx);
+ if (!auth || auth === "api-key" || codexAccountKey(auth) !== input.accountKey) {
+ throw new ProviderResetCreditError({
+ detail: "The Codex account changed. Refresh usage before redeeming a reset.",
+ });
+ }
+ const key = input.accountKey;
+ const pending = resetCreditRequests.get(key);
+ if (pending) {
+ if (pending.creditId !== input.creditId)
+ throw new ProviderResetCreditError({
+ detail: "A reset is already being redeemed for this account.",
+ });
+ return pending.promise;
+ }
+ const request = (async (): Promise => {
+ const response = await fetchJson({
+ url: `${CODEX_RESET_CREDITS_URL}/consume`,
+ method: "POST",
+ headers: codexResetHeaders(auth),
+ body: {
+ credit_id: input.creditId,
+ redeem_request_id: resetCreditRequestId(input.accountKey, input.creditId),
+ },
+ });
+ if (!response.ok)
+ throw new ProviderResetCreditError({
+ detail: `Codex could not redeem the reset credit (HTTP ${response.status}).`,
+ });
+ const code = asString(asRecord(response.json)?.code);
+ switch (code) {
+ case "reset":
+ return { outcome: "reset" };
+ case "nothing_to_reset":
+ return { outcome: "nothingToReset" };
+ case "no_credit":
+ return { outcome: "noCredit" };
+ case "already_redeemed":
+ return { outcome: "alreadyRedeemed" };
+ default:
+ throw new ProviderResetCreditError({
+ detail:
+ "Codex returned an unexpected reset response. Retry to check the same credit safely.",
+ });
+ }
+ })();
+ resetCreditRequests.set(key, { creditId: input.creditId, promise: request });
+ try {
+ return await request;
+ } finally {
+ if (resetCreditRequests.get(key)?.promise === request) resetCreditRequests.delete(key);
+ }
+}
+
function codexAccountKey(auth: CodexAuth | "api-key" | null): string | undefined {
return auth && auth !== "api-key" && auth.accountId
? NodeCrypto.createHash("sha256").update(auth.accountId).digest("hex")
@@ -1504,6 +1639,7 @@ function mergePushedSnapshot(
return {
...snapshotInput,
...(current.accountKey ? { accountKey: current.accountKey } : {}),
+ ...(current.resetCredits ? { resetCredits: current.resetCredits } : {}),
limits,
usageLines: updatesUsageLines === true ? snapshotInput.usageLines : current.usageLines,
...(planName === undefined ? {} : { planName }),
@@ -1845,6 +1981,90 @@ export const getProviderUsage = Effect.fn("ProviderUsage.get")(function* (
);
});
+export const consumeProviderResetCredit = Effect.fn("ProviderUsage.consumeResetCredit")(function* (
+ input: ProviderConsumeResetCreditInput,
+) {
+ const settings = yield* (yield* ServerSettingsService).getSettings;
+ const instance = settings.providerInstances[input.instanceId];
+ const legacyCodex =
+ !instance &&
+ input.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("codex")) &&
+ settings.providers.codex !== undefined;
+ if (!legacyCodex && (!instance || instance.enabled === false || instance.driver !== "codex")) {
+ return yield* new ProviderResetCreditError({
+ detail: "This Codex provider is missing or disabled.",
+ });
+ }
+ const environment = yield* HostProcessEnvironment;
+ const platform = yield* HostProcessPlatform;
+ const ctx = buildContext(
+ settings,
+ { instanceId: input.instanceId, provider: "codex" },
+ environment,
+ platform,
+ null,
+ );
+ const result = yield* Effect.tryPromise({
+ try: () => redeemCodexResetCredit(ctx, input),
+ catch: (cause) =>
+ isProviderResetCreditError(cause)
+ ? cause
+ : new ProviderResetCreditError({
+ detail: "Could not confirm redemption. Retry to check the same credit safely.",
+ }),
+ });
+ const targets = new Set([input.instanceId]);
+ const targetKey = cacheKeyFor({ instanceId: input.instanceId, provider: "codex" });
+ inFlightFetches.delete(targetKey);
+ retryAfterGates.delete(targetKey);
+ cancelScheduledRateLimitRefresh(targetKey);
+ for (const [key, cached] of snapshotCache) {
+ if (cached.snapshot.provider !== "codex" || cached.snapshot.accountKey !== input.accountKey)
+ continue;
+ targets.add(cached.snapshot.instanceId);
+ // Detach reads started before redemption so they cannot republish spent credits.
+ inFlightFetches.delete(key);
+ retryAfterGates.delete(key);
+ cancelScheduledRateLimitRefresh(key);
+ const credits = cached.snapshot.resetCredits?.credits.filter(
+ (credit) => credit.id !== input.creditId,
+ );
+ const resetCredits =
+ credits && result.outcome !== "nothingToReset"
+ ? {
+ availableCount: credits.length,
+ credits,
+ ...(credits[0] ? { nextExpiresAt: credits[0].expiresAt } : {}),
+ }
+ : cached.snapshot.resetCredits;
+ snapshotCache.set(key, {
+ ...cached,
+ fetchedAtMs: null,
+ snapshot: { ...cached.snapshot, stale: true, ...(resetCredits ? { resetCredits } : {}) },
+ });
+ }
+ yield* PubSub.publish(snapshotChanges, undefined);
+ const refreshed = yield* Effect.forEach(
+ [...targets],
+ (instanceId) =>
+ getProviderUsage({ instanceId, provider: "codex", forceRefresh: true }).pipe(Effect.result),
+ { concurrency: 4 },
+ );
+ const confirmedResult = refreshed.find(
+ (snapshot) => snapshot._tag === "Success" && snapshot.success.instanceId === input.instanceId,
+ );
+ const confirmed = confirmedResult?._tag === "Success" ? confirmedResult.success : undefined;
+ return {
+ ...result,
+ ...(confirmed?.status === "ok" && !confirmed.stale && confirmed.resetCredits !== undefined
+ ? {}
+ : {
+ warning:
+ "Codex responded, but the latest usage could not be loaded. Refresh usage to check.",
+ }),
+ };
+});
+
function isProviderUsageDriver(driver: string): driver is ProviderUsageDriver {
return driver === "codex" || driver === "claudeAgent" || driver === "cursor";
}
@@ -1984,6 +2204,10 @@ function testingContext(input: {
}
export const providerUsageTestKit = {
+ redeemCodex: (
+ input: Parameters[0],
+ credit: ProviderConsumeResetCreditInput,
+ ) => redeemCodexResetCredit(testingContext(input), credit),
setCodexAccountKeyReader: (
instanceId: ProviderInstanceId,
read: () => Promise,
@@ -2030,6 +2254,7 @@ export const providerUsageTestKit = {
export function resetProviderUsageCache(): void {
for (const scheduled of scheduledRateLimitRefreshes.values()) scheduled.controller.abort();
readKeychainPassword = defaultReadKeychainPassword;
+ resetCreditRequests.clear();
contextIdentities.clear();
pushSemaphores.clear();
codexAccountKeyReaders.clear();
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index 654f39222..e99bd77e1 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -152,6 +152,7 @@ import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
import * as UsageService from "./usage/UsageService.ts";
import {
getProviderUsage,
+ consumeProviderResetCredit,
subscribeProviderUsage,
makeSharedProviderUsageSubscription,
} from "./providerUsage/ProviderUsageService.ts";
@@ -1718,6 +1719,14 @@ const makeWsRpcLayer = (
observeRpcEffect(WS_METHODS.serverGetUsageSummary, usage.readSummary(input), {
"rpc.aggregate": "server",
}),
+ [WS_METHODS.providerConsumeResetCredit]: (input) =>
+ observeRpcEffect(
+ WS_METHODS.providerConsumeResetCredit,
+ consumeProviderResetCredit(input),
+ {
+ "rpc.aggregate": "server",
+ },
+ ),
[WS_METHODS.serverGetProviderUsage]: (input) =>
observeRpcEffect(WS_METHODS.serverGetProviderUsage, getProviderUsage(input), {
"rpc.aggregate": "server",
diff --git a/apps/web/src/components/clerk/PathwayConnectSidebarSignIn.test.tsx b/apps/web/src/components/clerk/PathwayConnectSidebarSignIn.test.tsx
index 3789cdeec..6f9efe48c 100644
--- a/apps/web/src/components/clerk/PathwayConnectSidebarSignIn.test.tsx
+++ b/apps/web/src/components/clerk/PathwayConnectSidebarSignIn.test.tsx
@@ -75,6 +75,10 @@ vi.mock("../usage/ProviderUsage", () => ({
ConnectedProviderUsageMenu: () => Connected provider limits
,
}));
+vi.mock("../usage/ProviderResetCredits", () => ({
+ AccountResetCredits: () => Redeem usage reset
,
+}));
+
import { PathwayConnectProfileButton } from "./PathwayConnectSidebarSignIn";
describe("PathwayConnectProfileButton", () => {
diff --git a/apps/web/src/components/clerk/PathwayConnectSidebarSignIn.tsx b/apps/web/src/components/clerk/PathwayConnectSidebarSignIn.tsx
index 58b9d9eb6..acca6ca74 100644
--- a/apps/web/src/components/clerk/PathwayConnectSidebarSignIn.tsx
+++ b/apps/web/src/components/clerk/PathwayConnectSidebarSignIn.tsx
@@ -10,7 +10,7 @@ import {
SmartphoneIcon,
UserRoundIcon,
} from "lucide-react";
-import type { ReactNode } from "react";
+import { useState, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { useNavigate } from "@tanstack/react-router";
@@ -33,6 +33,7 @@ import {
DropdownMenuTrigger,
} from "../ui/menu";
import { ConnectedProviderUsageMenu } from "../usage/ProviderUsage";
+import { AccountResetCredits, type ResetCreditSelection } from "../usage/ProviderResetCredits";
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage";
import { usePathwayConnectAuthPrompt } from "./usePathwayConnectAuthPrompt";
@@ -75,6 +76,7 @@ export function PathwayConnectProfileButton() {
}
function ConfiguredPathwayConnectProfileButton() {
+ const [resetSelection, setResetSelection] = useState(null);
const { openUserProfile, signOut } = useClerk();
const { isLoaded, isSignedIn, user } = useUser();
const navigate = useNavigate();
@@ -195,7 +197,7 @@ function ConfiguredPathwayConnectProfileButton() {
Provider usage
-
+
+ {resetSelection ? (
+ setResetSelection(null)}
+ />
+ ) : null}
>
);
}
diff --git a/apps/web/src/components/usage/ProviderResetCredits.test.tsx b/apps/web/src/components/usage/ProviderResetCredits.test.tsx
new file mode 100644
index 000000000..4feecf414
--- /dev/null
+++ b/apps/web/src/components/usage/ProviderResetCredits.test.tsx
@@ -0,0 +1,222 @@
+import type { ReactElement } from "react";
+import { EnvironmentId, ProviderDriverKind, ProviderInstanceId } from "@spiritdevs/contracts";
+import * as Cause from "effect/Cause";
+import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
+import { reactHookHarness as hooks } from "../../test/reactHookHarness";
+import { visitElements } from "../../test/reactElementTree";
+import type { ConnectedProviderUsageAccount } from "./providerUsageAccounts";
+
+const state = vi.hoisted(() => ({ redeem: vi.fn(), toast: vi.fn() }));
+vi.mock("react", async (importOriginal) => {
+ const actual = await importOriginal();
+ const { reactHookHarness } = await import("../../test/reactHookHarness");
+ return { ...actual, useState: reactHookHarness.useState, useRef: reactHookHarness.useRef };
+});
+vi.mock("react/compiler-runtime", async () => {
+ const { reactHookHarness } = await import("../../test/reactHookHarness");
+ return { c: reactHookHarness.useMemoCache };
+});
+vi.mock("../../state/server", () => ({
+ serverEnvironment: { consumeResetCredit: Symbol("redeem") },
+}));
+vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.redeem }));
+vi.mock("../../state/environments", () => ({ usePrimaryEnvironmentId: () => null }));
+vi.mock("../../state/session", () => ({ useEnvironmentSessionState: vi.fn() }));
+vi.mock("../../environments/primary", () => ({ usePrimarySessionState: vi.fn() }));
+vi.mock("../ui/toast", () => ({
+ stackedThreadToast: (input: unknown) => input,
+ toastManager: { add: state.toast },
+}));
+
+import { ProviderResetCreditList, ProviderResetCredits } from "./ProviderResetCredits";
+
+const account: ConnectedProviderUsageAccount = {
+ key: "account-key",
+ environmentId: EnvironmentId.make("remote-environment"),
+ environmentLabel: "Work Mac",
+ displayName: "Work Codex",
+ receivedAt: 1,
+ provider: {
+ instanceId: ProviderInstanceId.make("work-codex"),
+ driver: ProviderDriverKind.make("codex"),
+ displayName: "Work Codex",
+ enabled: true,
+ installed: true,
+ auth: { status: "authenticated", email: "work@example.test" },
+ } as ConnectedProviderUsageAccount["provider"],
+ snapshot: {
+ provider: "codex",
+ instanceId: ProviderInstanceId.make("work-codex"),
+ accountKey: "pinned-account",
+ status: "ok",
+ source: "provider",
+ updatedAt: "2026-09-08T00:00:00.000Z",
+ limits: [],
+ usageLines: [],
+ resetCredits: {
+ availableCount: 3,
+ credits: [
+ { id: "later", expiresAt: "2099-12-01T00:00:00.000Z" },
+ { id: "expired", expiresAt: "2020-01-01T00:00:00.000Z" },
+ { id: "sooner", expiresAt: "2099-11-01T00:00:00.000Z" },
+ ],
+ },
+ },
+};
+function render(overrides: Partial[0]> = {}) {
+ hooks.beginRender();
+ return ProviderResetCreditList({
+ account,
+ operateAccess: "granted",
+ ...overrides,
+ }) as ReactElement>;
+}
+function button(tree: ReactElement>, label: string) {
+ const found = visitElements(tree, (element) => element.props.children === label);
+ expect(found).not.toBeNull();
+ return found!;
+}
+function open(overrides: Partial[0]> = {}) {
+ const tree = render(overrides);
+ (button(tree, "Redeem").props.onClick as (event: unknown) => void)({
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ });
+ return render(overrides);
+}
+async function flush() {
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+describe("provider reset credits", () => {
+ beforeEach(() => {
+ hooks.reset();
+ state.redeem.mockReset();
+ state.toast.mockReset();
+ });
+
+ it("shows each unexpired credit in expiry order and hides unsupported accounts", () => {
+ const tree = render();
+ expect(visitElements(tree, (element) => element.type === "time")?.props.dateTime).toBe(
+ "2099-11-01T00:00:00.000Z",
+ );
+ expect(
+ visitElements(tree, (element) => element.props.dateTime === "2020-01-01T00:00:00.000Z"),
+ ).toBeNull();
+ expect(ProviderResetCredits({ accounts: [{ ...account, snapshot: null }] })).toBeNull();
+ });
+
+ it.each(["pending", "denied"] as const)(
+ "disables redemption for %s operate access",
+ (operateAccess) => {
+ expect(button(render({ operateAccess }), "Redeem").props.disabled).toBe(true);
+ },
+ );
+
+ it("disables redemption for a stale snapshot", () => {
+ expect(
+ button(
+ render({ account: { ...account, snapshot: { ...account.snapshot!, stale: true } } }),
+ "Redeem",
+ ).props.disabled,
+ ).toBe(true);
+ });
+
+ it("requires confirmation and targets the credit's environment, instance and account", async () => {
+ state.redeem.mockResolvedValue({ _tag: "Success", value: { outcome: "reset" } });
+ const confirmation = open();
+ expect(state.redeem).not.toHaveBeenCalled();
+ (button(confirmation, "Redeem reset").props.onClick as () => void)();
+ await flush();
+ expect(state.redeem).toHaveBeenCalledExactlyOnceWith({
+ environmentId: account.environmentId,
+ input: {
+ instanceId: account.provider.instanceId,
+ accountKey: "pinned-account",
+ creditId: "sooner",
+ },
+ });
+ expect(state.toast).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "success", title: "Usage reset redeemed" }),
+ );
+ });
+
+ it("allows cancel without consuming a credit", () => {
+ const confirmation = open();
+ const dialog = visitElements(confirmation, (element) => element.props.open === true)!;
+ (dialog.props.onOpenChange as (open: boolean) => void)(false);
+ expect(visitElements(render(), (element) => element.props.open === true)).toBeNull();
+ expect(state.redeem).not.toHaveBeenCalled();
+ });
+
+ it("prevents repeat submission and dialog dismissal while a reset is pending", async () => {
+ let resolve!: (result: unknown) => void;
+ state.redeem.mockImplementation(
+ () =>
+ new Promise((done) => {
+ resolve = done;
+ }),
+ );
+ const confirmation = open();
+ const submit = button(confirmation, "Redeem reset").props.onClick as () => void;
+ submit();
+ submit();
+ expect(state.redeem).toHaveBeenCalledTimes(1);
+ const pending = render();
+ expect(button(pending, "Redeeming…").props.disabled).toBe(true);
+ const dialog = visitElements(pending, (element) => element.props.open === true)!;
+ (dialog.props.onOpenChange as (open: boolean) => void)(false);
+ expect(visitElements(render(), (element) => element.props.open === true)).not.toBeNull();
+ resolve({ _tag: "Success", value: { outcome: "reset" } });
+ await flush();
+ });
+
+ it("keeps failed redemption open with an error and no success claim", async () => {
+ state.redeem.mockResolvedValue({
+ _tag: "Failure",
+ cause: Cause.fail(new Error("Connection lost")),
+ });
+ (button(open(), "Redeem reset").props.onClick as () => void)();
+ await flush();
+ expect(
+ visitElements(render(), (element) => element.props.role === "alert")?.props.children,
+ ).toBe("Connection lost");
+ expect(state.toast).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ["nothingToReset", "Your usage does not need a reset"],
+ ["noCredit", "This reset is no longer available"],
+ ["alreadyRedeemed", "This reset has already been redeemed"],
+ ])("reports %s without claiming a reset", async (outcome, title) => {
+ state.redeem.mockResolvedValue({ _tag: "Success", value: { outcome } });
+ (button(open(), "Redeem reset").props.onClick as () => void)();
+ await flush();
+ expect(state.toast).toHaveBeenCalledWith(expect.objectContaining({ type: "info", title }));
+ });
+
+ it("hands the menu's selected account to a dialog host outside the menu without redeeming", () => {
+ const onRequestRedeem = vi.fn();
+ open({ onRequestRedeem });
+ expect(onRequestRedeem).toHaveBeenCalledWith({ account, creditId: "sooner" });
+ expect(state.redeem).not.toHaveBeenCalled();
+ expect(
+ visitElements(render({ onRequestRedeem }), (element) => element.props.open === true),
+ ).toBeNull();
+ });
+ it("does not redeem a credit that expires while confirmation is open", async () => {
+ const confirmation = open();
+ const clock = vi.spyOn(Date, "now").mockReturnValue(Date.parse("2100-01-01T00:00:00.000Z"));
+ try {
+ (button(confirmation, "Redeem reset").props.onClick as () => void)();
+ await flush();
+ expect(state.redeem).not.toHaveBeenCalled();
+ expect(
+ visitElements(render(), (element) => element.props.role === "alert")?.props.children,
+ ).toContain("expired");
+ } finally {
+ clock.mockRestore();
+ }
+ });
+});
diff --git a/apps/web/src/components/usage/ProviderResetCredits.tsx b/apps/web/src/components/usage/ProviderResetCredits.tsx
new file mode 100644
index 000000000..e0e76e15a
--- /dev/null
+++ b/apps/web/src/components/usage/ProviderResetCredits.tsx
@@ -0,0 +1,300 @@
+import {
+ isAtomCommandInterrupted,
+ squashAtomCommandFailure,
+} from "@spiritdevs/client-runtime/state/runtime";
+import { useRef, useState } from "react";
+
+import { isElectron } from "../../env";
+import { usePrimarySessionState } from "../../environments/primary";
+import { usePrimaryEnvironmentId } from "../../state/environments";
+import { useEnvironmentSessionState } from "../../state/session";
+import { serverEnvironment } from "../../state/server";
+import { useAtomCommand } from "../../state/use-atom-command";
+import {
+ resolvePrimaryOperateAccess,
+ resolveRemoteOperateAccess,
+ type ProviderOperateAccess,
+} from "../settings/ProviderSettingsPanel.logic";
+import {
+ AlertDialog,
+ AlertDialogClose,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogPopup,
+ AlertDialogTitle,
+} from "../ui/alert-dialog";
+import { Button } from "../ui/button";
+import { stackedThreadToast, toastManager } from "../ui/toast";
+import type { ConnectedProviderUsageAccount } from "./providerUsageAccounts";
+
+export interface ResetCreditSelection {
+ readonly account: ConnectedProviderUsageAccount;
+ readonly creditId: string;
+}
+
+interface ResetCreditDialogProps {
+ readonly selection?: ResetCreditSelection;
+ readonly onClose?: () => void;
+ readonly onRequestRedeem?: ((selection: ResetCreditSelection) => void) | undefined;
+}
+
+export function ProviderResetCredits({
+ accounts,
+ onRequestRedeem,
+}: {
+ accounts: ReadonlyArray;
+ onRequestRedeem?: ((selection: ResetCreditSelection) => void) | undefined;
+}) {
+ const supported = accounts.filter(
+ (account) =>
+ account.provider.driver === "codex" && account.snapshot?.resetCredits !== undefined,
+ );
+ if (supported.length === 0) return null;
+ return (
+
+ Usage resets
+ {supported.map((account) => (
+
+ ))}
+
+ );
+}
+
+export function AccountResetCredits({
+ account,
+ ...dialogProps
+}: { account: ConnectedProviderUsageAccount } & ResetCreditDialogProps) {
+ const primaryEnvironmentId = usePrimaryEnvironmentId();
+ if (account.environmentId === primaryEnvironmentId) {
+ return isElectron ? (
+
+ ) : (
+
+ );
+ }
+ return ;
+}
+
+function PrimaryResetCredits({
+ account,
+ ...dialogProps
+}: { account: ConnectedProviderUsageAccount } & ResetCreditDialogProps) {
+ const session = usePrimarySessionState();
+ return (
+
+ );
+}
+
+function RemoteResetCredits({
+ account,
+ ...dialogProps
+}: { account: ConnectedProviderUsageAccount } & ResetCreditDialogProps) {
+ const session = useEnvironmentSessionState(account.environmentId);
+ return (
+
+ );
+}
+
+export function ProviderResetCreditList({
+ account,
+ operateAccess,
+ selection,
+ onClose,
+ onRequestRedeem,
+}: {
+ account: ConnectedProviderUsageAccount;
+ operateAccess: ProviderOperateAccess;
+} & ResetCreditDialogProps) {
+ const consumeResetCredit = useAtomCommand(serverEnvironment.consumeResetCredit, {
+ reportFailure: false,
+ });
+ const [selectedCreditId, setSelectedCreditId] = useState(
+ selection?.creditId ?? null,
+ );
+ const [pending, setPending] = useState(false);
+ const [error, setError] = useState(null);
+ const inFlight = useRef(false);
+ const snapshot = account.snapshot;
+ const credits = snapshot?.resetCredits;
+ const selectedCredit = credits?.credits.find((credit) => credit.id === selectedCreditId);
+ const canRedeem =
+ operateAccess === "granted" &&
+ snapshot?.status === "ok" &&
+ !snapshot.stale &&
+ !!snapshot.accountKey;
+ const accountLabel = [account.displayName, account.provider.auth.email, account.environmentLabel]
+ .filter(Boolean)
+ .join(" · ");
+
+ async function redeem() {
+ if (inFlight.current || !canRedeem || !selectedCredit || !snapshot?.accountKey) return;
+ if (Date.parse(selectedCredit.expiresAt) <= Date.now()) {
+ setError("This reset has expired. Refresh usage to see available resets.");
+ return;
+ }
+ inFlight.current = true;
+ setPending(true);
+ setError(null);
+ try {
+ const result = await consumeResetCredit({
+ environmentId: account.environmentId,
+ input: {
+ instanceId: account.provider.instanceId,
+ accountKey: snapshot.accountKey,
+ creditId: selectedCredit.id,
+ },
+ });
+ if (result._tag !== "Success") {
+ if (!isAtomCommandInterrupted(result)) {
+ const failure = squashAtomCommandFailure(result);
+ setError(failure instanceof Error ? failure.message : "Could not redeem this reset.");
+ }
+ return;
+ }
+ const messages = {
+ reset: "Usage reset redeemed",
+ nothingToReset: "Your usage does not need a reset",
+ noCredit: "This reset is no longer available",
+ alreadyRedeemed: "This reset has already been redeemed",
+ };
+ toastManager.add(
+ stackedThreadToast({
+ type: result.value.outcome === "reset" ? "success" : "info",
+ title: messages[result.value.outcome],
+ description: result.value.warning ?? accountLabel,
+ }),
+ );
+ setSelectedCreditId(null);
+ onClose?.();
+ } finally {
+ inFlight.current = false;
+ setPending(false);
+ }
+ }
+
+ if (!credits) return null;
+ const now = Date.now();
+ const available = credits.credits
+ .filter((credit) => Date.parse(credit.expiresAt) > now)
+ .toSorted((left, right) => Date.parse(left.expiresAt) - Date.parse(right.expiresAt));
+ return (
+
+ {!selection ? (
+ <>
+
+
{accountLabel}
+
+ {available.length} {available.length === 1 ? "reset available" : "resets available"}
+
+
+ {available.map((credit) => (
+
+
+ Expires{" "}
+
+
+
+
+ ))}
+ {available.length > 0 && !canRedeem ? (
+
+ {operateAccess === "pending"
+ ? "Checking access…"
+ : operateAccess === "denied"
+ ? "Operate access is required to redeem a reset."
+ : "Refresh usage before redeeming a reset."}
+
+ ) : null}
+ >
+ ) : null}
+
{
+ if (!open && !inFlight.current) {
+ setSelectedCreditId(null);
+ onClose?.();
+ }
+ }}
+ >
+
+
+ Redeem a usage reset?
+
+ Use one reset for {accountLabel} to reset its Codex usage limits. This consumes the
+ reset immediately and cannot be undone.
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {!selectedCredit ? (
+
+ This reset is no longer available.
+
+ ) : null}
+
+ }>
+ Cancel
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/usage/ProviderUsage.test.tsx b/apps/web/src/components/usage/ProviderUsage.test.tsx
index d6b8827e0..047904d14 100644
--- a/apps/web/src/components/usage/ProviderUsage.test.tsx
+++ b/apps/web/src/components/usage/ProviderUsage.test.tsx
@@ -112,6 +112,8 @@ import {
ProviderUsageSettingsSection,
} from "./ProviderUsage";
+import { ProviderResetCredits } from "./ProviderResetCredits";
+
const environmentId = EnvironmentId.make("usage-environment");
const codexId = ProviderInstanceId.make("codex");
const claudeId = ProviderInstanceId.make("claudeAgent");
@@ -586,4 +588,43 @@ describe("provider usage panel refresh", () => {
expect(visitElements(menu, (element) => element.props.children === "Studio")).toBeNull();
expect(visitElements(menu, (element) => element.props.children === "Laptop")).toBeNull();
});
+ it("places reset credits below all meters in settings and the profile submenu", () => {
+ setConnectedProviders([
+ { environmentId, label: "Studio", providers: [provider("codex", codexId)] },
+ ]);
+ testState.queries.set(String(codexId), {
+ ...snapshot(codexId, "codex", 20),
+ accountKey: "account",
+ resetCredits: {
+ availableCount: 1,
+ credits: [{ id: "reset", expiresAt: "2099-01-01T00:00:00.000Z" }],
+ },
+ });
+ const onRequestRedeem = vi.fn();
+ hooks.beginRender();
+ const menu = ConnectedProviderUsageMenu({ onRequestRedeem }) as ReactElement<
+ Record
+ >;
+ const menuOrder: string[] = [];
+ visitElements(menu, (element) => {
+ if (element.props.account) menuOrder.push("usage");
+ if (element.type === ProviderResetCredits) {
+ menuOrder.push("resets");
+ expect(element.props.onRequestRedeem).toBe(onRequestRedeem);
+ expect(element.props.accounts).toHaveLength(1);
+ }
+ return false;
+ });
+ expect(menuOrder).toEqual(["usage", "resets"]);
+ hooks.reset();
+ hooks.beginRender();
+ const settings = ProviderUsageSettingsSection() as ReactElement>;
+ const settingsOrder: string[] = [];
+ visitElements(settings, (element) => {
+ if (Array.isArray(element.props.accounts))
+ settingsOrder.push(element.type === ProviderResetCredits ? "resets" : "usage");
+ return false;
+ });
+ expect(settingsOrder).toEqual(["usage", "resets"]);
+ });
});
diff --git a/apps/web/src/components/usage/ProviderUsage.tsx b/apps/web/src/components/usage/ProviderUsage.tsx
index 4b9c4dea4..4823d9a8d 100644
--- a/apps/web/src/components/usage/ProviderUsage.tsx
+++ b/apps/web/src/components/usage/ProviderUsage.tsx
@@ -45,10 +45,13 @@ import {
import {
createProviderUsageArrivalTracker,
deriveConnectedProviderUsageAccounts,
+ deriveConnectedProviderResetCreditAccounts,
isProviderUsageDriver,
type ConnectedProviderUsageAccount,
} from "./providerUsageAccounts";
+import { ProviderResetCredits, type ResetCreditSelection } from "./ProviderResetCredits";
+
const SLOW_REFRESH_SPIN_CLASS = "animate-spin [animation-duration:2s] motion-reduce:animate-none";
function providerName(provider: ProviderUsageDriver): string {
@@ -793,21 +796,27 @@ function useConnectedProviderUsageAccounts() {
);
const usage = useAtomValue(usageAtom);
const trackArrivals = useMemo(createProviderUsageArrivalTracker, []);
- const accounts = useMemo(
+ const trackedEnvironments = useMemo(
() =>
- deriveConnectedProviderUsageAccounts(
- trackArrivals(
- connected.map((environment) => ({
- environmentId: environment.environmentId,
- environmentLabel: environment.label,
- providers: serverConfigs.get(environment.environmentId)?.providers ?? null,
- usage: usage.get(environment.environmentId)?.data ?? [],
- receivedAt: usage.get(environment.environmentId)?.receivedAt ?? 0,
- })),
- ),
+ trackArrivals(
+ connected.map((environment) => ({
+ environmentId: environment.environmentId,
+ environmentLabel: environment.label,
+ providers: serverConfigs.get(environment.environmentId)?.providers ?? null,
+ usage: usage.get(environment.environmentId)?.data ?? [],
+ receivedAt: usage.get(environment.environmentId)?.receivedAt ?? 0,
+ })),
),
[connected, serverConfigs, usage, trackArrivals],
);
+ const accounts = useMemo(
+ () => deriveConnectedProviderUsageAccounts(trackedEnvironments),
+ [trackedEnvironments],
+ );
+ const resetCreditAccounts = useMemo(
+ () => deriveConnectedProviderResetCreditAccounts(trackedEnvironments),
+ [trackedEnvironments],
+ );
const loading =
!isReady ||
connected.some(
@@ -815,12 +824,20 @@ function useConnectedProviderUsageAccounts() {
!serverConfigs.has(environment.environmentId) ||
usage.get(environment.environmentId)?.loading,
);
- return { accounts: loading ? [] : accounts, connectedCount: connected.length, loading };
+ return {
+ accounts: loading ? [] : accounts,
+ resetCreditAccounts: loading ? [] : resetCreditAccounts,
+ connectedCount: connected.length,
+ loading,
+ };
}
-export function ConnectedProviderUsageMenu() {
+export function ConnectedProviderUsageMenu({
+ onRequestRedeem,
+}: { onRequestRedeem?: (selection: ResetCreditSelection) => void } = {}) {
const {
accounts: connectedAccounts,
+ resetCreditAccounts,
connectedCount,
loading,
} = useConnectedProviderUsageAccounts();
@@ -850,6 +867,7 @@ export function ConnectedProviderUsageMenu() {
{accounts.map((account) => (
))}
+
)}
@@ -857,16 +875,22 @@ export function ConnectedProviderUsageMenu() {
}
export function ProviderUsageSettingsSection() {
- const { accounts, loading } = useConnectedProviderUsageAccounts();
+ const { accounts, resetCreditAccounts, loading } = useConnectedProviderUsageAccounts();
const refreshTargets = useMemo(
- () =>
- accounts.map((account) => ({
- environmentId: account.environmentId,
- instanceId: account.provider.instanceId,
- provider: account.provider.driver as ProviderUsageDriver,
- label: account.displayName,
- })),
- [accounts],
+ () => [
+ ...new Map(
+ [...accounts, ...resetCreditAccounts].map((account) => [
+ JSON.stringify([account.environmentId, account.provider.instanceId]),
+ {
+ environmentId: account.environmentId,
+ instanceId: account.provider.instanceId,
+ provider: account.provider.driver as ProviderUsageDriver,
+ label: account.displayName,
+ },
+ ]),
+ ).values(),
+ ],
+ [accounts, resetCreditAccounts],
);
const usageRefresh = useForcedProviderUsageRefresh(refreshTargets);
@@ -896,6 +920,7 @@ export function ProviderUsageSettingsSection() {
Pathway reads each CLI's stored sign-in on its connected environment and sends only
diff --git a/apps/web/src/components/usage/providerUsageAccounts.test.ts b/apps/web/src/components/usage/providerUsageAccounts.test.ts
index 087db7722..07c210f78 100644
--- a/apps/web/src/components/usage/providerUsageAccounts.test.ts
+++ b/apps/web/src/components/usage/providerUsageAccounts.test.ts
@@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
createProviderUsageArrivalTracker,
+ deriveConnectedProviderResetCreditAccounts,
deriveConnectedProviderUsageAccounts,
} from "./providerUsageAccounts";
@@ -132,6 +133,97 @@ function usage(
};
}
+describe("connected reset credit accounts", () => {
+ const codex = provider({ driver: "codex", instanceId: "codex", displayName: "Codex" });
+ const creditSnapshot: ServerProviderUsageSnapshot = {
+ ...usage("codex", "personal"),
+ resetCredits: {
+ availableCount: 1,
+ credits: [{ id: "credit-1", expiresAt: IsoDateTime.make("2026-10-01T00:00:00Z") }],
+ },
+ };
+
+ it("keeps the credit-reporting environment when a newer usage-only snapshot arrives", () => {
+ const accounts = deriveConnectedProviderResetCreditAccounts([
+ { environmentId: studioId, providers: [codex], usage: [creditSnapshot], receivedAt: 1 },
+ {
+ environmentId: laptopId,
+ providers: [codex],
+ usage: [usage("codex", "personal")],
+ receivedAt: 2,
+ },
+ ]);
+ expect(accounts).toHaveLength(1);
+ expect(accounts[0]?.environmentId).toBe(studioId);
+ expect(accounts[0]?.snapshot?.resetCredits?.credits[0]?.id).toBe("credit-1");
+ });
+
+ it("allows a newer empty balance to replace older available credits", () => {
+ const accounts = deriveConnectedProviderResetCreditAccounts([
+ { environmentId: studioId, providers: [codex], usage: [creditSnapshot], receivedAt: 1 },
+ {
+ environmentId: laptopId,
+ providers: [codex],
+ usage: [{ ...creditSnapshot, resetCredits: { availableCount: 0, credits: [] } }],
+ receivedAt: 2,
+ },
+ ]);
+ expect(accounts).toHaveLength(1);
+ expect(accounts[0]?.environmentId).toBe(laptopId);
+ expect(accounts[0]?.snapshot?.resetCredits?.availableCount).toBe(0);
+ });
+
+ it("does not restore a spent credit when another environment pushes only usage windows", () => {
+ const track = createProviderUsageArrivalTracker();
+ const first = {
+ environmentId: studioId,
+ providers: [codex],
+ usage: [creditSnapshot],
+ receivedAt: 1,
+ };
+ const second = {
+ environmentId: laptopId,
+ providers: [codex],
+ usage: [{ ...creditSnapshot, resetCredits: { availableCount: 0, credits: [] } }],
+ receivedAt: 2,
+ };
+ expect(
+ deriveConnectedProviderResetCreditAccounts(track([first, second]))[0]?.environmentId,
+ ).toBe(laptopId);
+ const pushed = {
+ ...first,
+ receivedAt: 3,
+ usage: [
+ {
+ ...creditSnapshot,
+ updatedAt: IsoDateTime.make("2026-09-08T00:01:00Z"),
+ limits: [{ window: "Weekly", usedPercent: 25 }],
+ },
+ ],
+ };
+ const tracked = track([pushed, second]);
+ expect(deriveConnectedProviderUsageAccounts(tracked)[0]?.environmentId).toBe(studioId);
+ expect(deriveConnectedProviderResetCreditAccounts(tracked)[0]?.environmentId).toBe(laptopId);
+ expect(
+ deriveConnectedProviderResetCreditAccounts(tracked)[0]?.snapshot?.resetCredits
+ ?.availableCount,
+ ).toBe(0);
+ });
+
+ it("does not offer credit routes for unsupported or disabled instances", () => {
+ expect(
+ deriveConnectedProviderResetCreditAccounts([
+ {
+ environmentId: studioId,
+ providers: [{ ...codex, enabled: false }],
+ usage: [creditSnapshot],
+ },
+ { environmentId: laptopId, providers: [codex], usage: [usage("codex", "personal")] },
+ ]),
+ ).toEqual([]);
+ });
+});
+
describe("grouping Codex subscriptions", () => {
const personal = provider({
driver: "codex",
diff --git a/apps/web/src/components/usage/providerUsageAccounts.ts b/apps/web/src/components/usage/providerUsageAccounts.ts
index ce5d09a1e..af84b1743 100644
--- a/apps/web/src/components/usage/providerUsageAccounts.ts
+++ b/apps/web/src/components/usage/providerUsageAccounts.ts
@@ -19,6 +19,7 @@ export interface ConnectedProviderUsageEnvironment {
readonly environmentLabel?: string;
readonly receivedAt?: number;
readonly snapshotReceivedAt?: ReadonlyMap;
+ readonly resetCreditsReceivedAt?: ReadonlyMap;
readonly usage?: ReadonlyArray;
readonly providers: ReadonlyArray | null;
}
@@ -36,10 +37,12 @@ export interface ConnectedProviderUsageAccount {
/** Keep unchanged quota values at their original client arrival time across list broadcasts. */
export function createProviderUsageArrivalTracker() {
const observed = new Map();
+ const observedCredits = new Map();
return (environments: ReadonlyArray) => {
const active = new Set();
const result = environments.map((environment) => {
const snapshotReceivedAt = new Map();
+ const resetCreditsReceivedAt = new Map();
for (const snapshot of environment.usage ?? []) {
const key = JSON.stringify([environment.environmentId, snapshot.instanceId]);
active.add(key);
@@ -51,10 +54,19 @@ export function createProviderUsageArrivalTracker() {
: (environment.receivedAt ?? 0);
observed.set(key, { fingerprint, receivedAt });
snapshotReceivedAt.set(snapshot.instanceId, receivedAt);
+ const creditFingerprint = JSON.stringify([snapshot.accountKey, snapshot.resetCredits]);
+ const previousCredits = observedCredits.get(key);
+ const creditsReceivedAt =
+ previousCredits?.fingerprint === creditFingerprint
+ ? previousCredits.receivedAt
+ : (environment.receivedAt ?? 0);
+ observedCredits.set(key, { fingerprint: creditFingerprint, receivedAt: creditsReceivedAt });
+ resetCreditsReceivedAt.set(snapshot.instanceId, creditsReceivedAt);
}
- return { ...environment, snapshotReceivedAt };
+ return { ...environment, snapshotReceivedAt, resetCreditsReceivedAt };
});
for (const key of observed.keys()) if (!active.has(key)) observed.delete(key);
+ for (const key of observedCredits.keys()) if (!active.has(key)) observedCredits.delete(key);
return result;
};
}
@@ -102,4 +114,27 @@ export function deriveConnectedProviderUsageAccounts(
return [...accounts.values()];
}
+/** Route credits through an environment that reports them, including an empty balance. */
+export function deriveConnectedProviderResetCreditAccounts(
+ environments: ReadonlyArray,
+): ReadonlyArray {
+ return deriveConnectedProviderUsageAccounts(
+ environments.map((environment) => ({
+ ...environment,
+ ...(environment.resetCreditsReceivedAt
+ ? { snapshotReceivedAt: environment.resetCreditsReceivedAt }
+ : {}),
+ providers:
+ environment.providers?.filter((provider) =>
+ environment.usage?.some(
+ (snapshot) =>
+ snapshot.instanceId === provider.instanceId &&
+ snapshot.provider === "codex" &&
+ snapshot.resetCredits !== undefined,
+ ),
+ ) ?? null,
+ })),
+ );
+}
+
export { isProviderUsageDriver };
diff --git a/docs/user/provider-limits.md b/docs/user/provider-limits.md
index de55cd47d..cece13037 100644
--- a/docs/user/provider-limits.md
+++ b/docs/user/provider-limits.md
@@ -19,3 +19,11 @@ Thread details and the profile usage menu hide Claude usage when that account is
Spark quotas remain visible in Settings → Providers. Thread details show them only while a Spark model is selected; the profile usage menu hides them.
The Lunar Reserve quota appears only in Settings → Providers. It is hidden from thread details and the profile usage menu.
+
+## Usage reset credits
+
+Codex accounts with available usage reset credits show them below the usage meters in **Settings → Providers** and at the bottom of the profile menu's **Provider usage** submenu. Each credit shows when it expires.
+
+Select **Redeem** beside a credit, then confirm to use it on that account. Redeeming spends the credit and resets the account's current usage limits. It cannot be undone. Pathway refreshes the account's usage and available credits afterward. If the provider cannot confirm the result, Pathway shows an error; retrying the same credit does not spend a different one.
+
+Credits belong to the provider account shown beside them. For a remote environment, Pathway sends the redemption to that environment. Viewing usage does not require permission to operate the environment, but redeeming a credit does. Providers that do not report reset credits do not show a redemption action.
diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts
index cc207f4b1..1165fc265 100644
--- a/packages/client-runtime/src/state/server.ts
+++ b/packages/client-runtime/src/state/server.ts
@@ -970,6 +970,15 @@ export function createServerEnvironmentAtoms(
tag: WS_METHODS.serverGetUsageSummary,
staleTimeMs: 60_000,
}),
+ consumeResetCredit: createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:server:consume-reset-credit",
+ tag: WS_METHODS.providerConsumeResetCredit,
+ concurrency: {
+ mode: "singleFlight",
+ key: ({ environmentId, input }) =>
+ JSON.stringify([environmentId, input.accountKey, input.creditId]),
+ },
+ }),
providerUsage: createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:server:provider-usage",
tag: WS_METHODS.serverGetProviderUsage,
diff --git a/packages/contracts/src/providerUsage.test.ts b/packages/contracts/src/providerUsage.test.ts
index 1c5aa0155..0fd316aa5 100644
--- a/packages/contracts/src/providerUsage.test.ts
+++ b/packages/contracts/src/providerUsage.test.ts
@@ -16,6 +16,19 @@ const legacySnapshot = {
};
describe("provider usage contracts", () => {
+ it("decodes reset credit expiries and rejects invalid counts", () => {
+ const resetCredits = {
+ availableCount: 1,
+ credits: [{ id: "credit-a", expiresAt: "2026-09-10T00:00:00.000Z" }],
+ };
+ expect(decodeSnapshot({ ...legacySnapshot, resetCredits }).resetCredits).toEqual(resetCredits);
+ for (const availableCount of [-1, 1.5]) {
+ expect(() =>
+ decodeSnapshot({ ...legacySnapshot, resetCredits: { ...resetCredits, availableCount } }),
+ ).toThrow();
+ }
+ });
+
it("continues to decode snapshots without v2 metadata", () => {
expect(decodeSnapshot(legacySnapshot)).toEqual(legacySnapshot);
});
diff --git a/packages/contracts/src/providerUsage.ts b/packages/contracts/src/providerUsage.ts
index 84b75388f..a233f9d6e 100644
--- a/packages/contracts/src/providerUsage.ts
+++ b/packages/contracts/src/providerUsage.ts
@@ -1,6 +1,6 @@
import * as Schema from "effect/Schema";
-import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts";
+import { IsoDateTime, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
import { ProviderInstanceId } from "./providerInstance.ts";
/** Provider drivers with a stable first-party quota endpoint. */
@@ -30,6 +30,13 @@ export type ServerProviderUsageLine = typeof ServerProviderUsageLine.Type;
export const ProviderUsageStatus = Schema.Literals(["ok", "needs-auth", "unsupported", "error"]);
export type ProviderUsageStatus = typeof ProviderUsageStatus.Type;
+export const ServerProviderResetCredits = Schema.Struct({
+ availableCount: NonNegativeInt,
+ credits: Schema.Array(Schema.Struct({ id: TrimmedNonEmptyString, expiresAt: IsoDateTime })),
+ nextExpiresAt: Schema.optional(IsoDateTime),
+});
+export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type;
+
export const ServerProviderUsageSnapshot = Schema.Struct({
instanceId: ProviderInstanceId,
provider: ProviderUsageDriver,
@@ -41,6 +48,7 @@ export const ServerProviderUsageSnapshot = Schema.Struct({
status: ProviderUsageStatus,
/** Stable provider account identity, hashed by the environment for cross-environment grouping. */
accountKey: Schema.optional(TrimmedNonEmptyString),
+ resetCredits: Schema.optional(ServerProviderResetCredits),
planName: Schema.optional(TrimmedNonEmptyString),
detail: Schema.optional(TrimmedNonEmptyString),
rateLimitedUntil: Schema.optional(IsoDateTime),
@@ -54,3 +62,30 @@ export const ServerGetProviderUsageInput = Schema.Struct({
forceRefresh: Schema.optional(Schema.Boolean),
});
export type ServerGetProviderUsageInput = typeof ServerGetProviderUsageInput.Type;
+
+export const ProviderConsumeResetCreditInput = Schema.Struct({
+ instanceId: ProviderInstanceId,
+ accountKey: TrimmedNonEmptyString,
+ creditId: TrimmedNonEmptyString,
+});
+export type ProviderConsumeResetCreditInput = typeof ProviderConsumeResetCreditInput.Type;
+export const ProviderConsumeResetCreditOutcome = Schema.Literals([
+ "reset",
+ "nothingToReset",
+ "noCredit",
+ "alreadyRedeemed",
+]);
+export type ProviderConsumeResetCreditOutcome = typeof ProviderConsumeResetCreditOutcome.Type;
+export const ProviderConsumeResetCreditResult = Schema.Struct({
+ outcome: ProviderConsumeResetCreditOutcome,
+ warning: Schema.optional(TrimmedNonEmptyString),
+});
+export type ProviderConsumeResetCreditResult = typeof ProviderConsumeResetCreditResult.Type;
+export class ProviderResetCreditError extends Schema.TaggedErrorClass()(
+ "ProviderResetCreditError",
+ { detail: Schema.String },
+) {
+ override get message(): string {
+ return this.detail;
+ }
+}
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index c896a766d..9bd9da442 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -202,7 +202,13 @@ import {
IssueImportRpcError,
} from "./issueImport.ts";
import { ProviderInstanceId } from "./providerInstance.ts";
-import { ServerGetProviderUsageInput, ServerProviderUsageSnapshot } from "./providerUsage.ts";
+import {
+ ProviderConsumeResetCreditInput,
+ ProviderConsumeResetCreditResult,
+ ProviderResetCreditError,
+ ServerGetProviderUsageInput,
+ ServerProviderUsageSnapshot,
+} from "./providerUsage.ts";
import {
PullRequestActionInput,
PullRequestActivity,
@@ -454,6 +460,7 @@ export const WS_METHODS = {
serverReportHostPowerState: "server.reportHostPowerState",
serverGetBackgroundPolicy: "server.getBackgroundPolicy",
serverGetUsageSummary: "server.getUsageSummary",
+ providerConsumeResetCredit: "provider.consumeResetCredit",
serverGetProviderUsage: "server.getProviderUsage",
serverSubscribeProviderUsage: "server.subscribeProviderUsage",
@@ -657,6 +664,16 @@ export const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSumm
error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]),
});
+export const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeResetCredit, {
+ payload: ProviderConsumeResetCreditInput,
+ success: ProviderConsumeResetCreditResult,
+ error: Schema.Union([
+ EnvironmentAuthorizationError,
+ ServerSettingsError,
+ ProviderResetCreditError,
+ ]),
+});
+
export const WsServerGetProviderUsageRpc = Rpc.make(WS_METHODS.serverGetProviderUsage, {
payload: ServerGetProviderUsageInput,
success: ServerProviderUsageSnapshot,
@@ -1905,6 +1922,7 @@ export const WsRpcGroup = RpcGroup.make(
WsServerGetResourceTelemetryHistoryRpc,
WsServerRetryResourceTelemetryRpc,
WsServerGetUsageSummaryRpc,
+ WsProviderConsumeResetCreditRpc,
WsServerGetProviderUsageRpc,
WsServerSubscribeProviderUsageRpc,
WsServerSignalProcessRpc,
From d72154b5851f6e4c71c4c0f64b78beb8bfe2d1c4 Mon Sep 17 00:00:00 2001
From: Corey Baines
Date: Tue, 8 Sep 2026 11:42:08 +1000
Subject: [PATCH 2/2] fix(usage): guard disabled Codex and retain reset
balances
---
.../ProviderResetCredits.test.ts | 80 +++++++++++++++++++
.../src/providerUsage/ProviderUsageService.ts | 17 +++-
.../usage/ProviderResetCredits.test.tsx | 17 ++++
.../components/usage/ProviderResetCredits.tsx | 1 +
.../usage/providerUsageAccounts.test.ts | 23 ++++++
.../components/usage/providerUsageAccounts.ts | 7 +-
docs/user/provider-limits.md | 2 +
packages/contracts/src/providerUsage.ts | 1 +
8 files changed, 143 insertions(+), 5 deletions(-)
diff --git a/apps/server/src/providerUsage/ProviderResetCredits.test.ts b/apps/server/src/providerUsage/ProviderResetCredits.test.ts
index 9eefc27f6..78f0be8cb 100644
--- a/apps/server/src/providerUsage/ProviderResetCredits.test.ts
+++ b/apps/server/src/providerUsage/ProviderResetCredits.test.ts
@@ -178,6 +178,86 @@ describe("Codex reset credits", () => {
}),
);
+ it.effect("rejects disabled legacy Codex before reading credentials or sending requests", () =>
+ Effect.gen(function* () {
+ const fetch = vi.fn();
+ vi.stubGlobal("fetch", fetch);
+ const result = yield* consumeProviderResetCredit({
+ ...credit,
+ instanceId: ProviderInstanceId.make("codex"),
+ }).pipe(
+ Effect.provide(
+ ServerSettingsService.layerTest({ providers: { codex: { enabled: false } } }),
+ ),
+ Effect.result,
+ );
+ expect(result._tag).toBe("Failure");
+ if (result._tag === "Failure")
+ expect(String(result.failure)).toContain("missing or disabled");
+ expect(fetch).not.toHaveBeenCalled();
+ }),
+ );
+
+ it.effect("retains the empty balance when only the post-redemption credit refresh fails", () =>
+ Effect.gen(function* () {
+ const ctx = yield* Effect.promise(context);
+ let spent = false;
+ let creditRefreshFails = true;
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (url: string) => {
+ if (url.endsWith("/consume")) {
+ spent = true;
+ return Response.json({ code: "reset" });
+ }
+ if (url.endsWith("/usage"))
+ return Response.json({
+ rate_limit: { primary_window: { used_percent: spent ? 0 : 90 } },
+ });
+ if (spent && creditRefreshFails) return new Response("unavailable", { status: 500 });
+ return Response.json({
+ credits: spent
+ ? []
+ : [
+ {
+ id: "credit-a",
+ status: "available",
+ reset_type: "codex_rate_limits",
+ expires_at: "2099-09-10T00:00:00Z",
+ },
+ ],
+ });
+ }),
+ );
+ yield* Effect.gen(function* () {
+ yield* getProviderUsage({ instanceId, provider: "codex" });
+ const result = yield* consumeProviderResetCredit(credit);
+ expect(result.outcome).toBe("reset");
+ expect(result.warning).toContain("latest usage");
+ const snapshot = yield* getProviderUsage({ instanceId, provider: "codex" });
+ expect(snapshot.status).toBe("ok");
+ expect(snapshot.stale).not.toBe(true);
+ expect(snapshot.limits[0]?.usedPercent).toBe(0);
+ expect(snapshot.resetCredits).toEqual({ availableCount: 0, credits: [], stale: true });
+ creditRefreshFails = false;
+ const recovered = yield* getProviderUsage({
+ instanceId,
+ provider: "codex",
+ forceRefresh: true,
+ });
+ expect(recovered.resetCredits).toEqual({ availableCount: 0, credits: [] });
+ }).pipe(
+ Effect.provide(
+ ServerSettingsService.layerTest({
+ providerInstances: {
+ [instanceId]: { driver: "codex", config: { homePath: ctx.providerHomePath } },
+ },
+ }),
+ ),
+ );
+ }),
+ );
+
it.effect("publishes refreshed credits and limits after redemption", () =>
Effect.gen(function* () {
const ctx = yield* Effect.promise(context);
diff --git a/apps/server/src/providerUsage/ProviderUsageService.ts b/apps/server/src/providerUsage/ProviderUsageService.ts
index b7969c033..5032b947b 100644
--- a/apps/server/src/providerUsage/ProviderUsageService.ts
+++ b/apps/server/src/providerUsage/ProviderUsageService.ts
@@ -1866,10 +1866,16 @@ async function resolveProviderUsage(
};
}
if (result.status === "ok" && ctx.provider === "codex" && latest) {
- // Credits and plan type arrive independently of limit lanes. Only fields
- // actually pushed after this request began may override its HTTP result.
+ // A credit endpoint failure retains this account's last balance independently
+ // of fresh meters. Pushed usage metadata only wins if it arrived during this read.
resolved = {
...resolved,
+ ...(result.resetCredits === undefined &&
+ result.accountKey !== undefined &&
+ result.accountKey === latest.snapshot.accountKey &&
+ latest.snapshot.resetCredits !== undefined
+ ? { resetCredits: { ...latest.snapshot.resetCredits, stale: true } }
+ : {}),
...((latest.pushedMetadataAt?.usageLines ?? -Infinity) > ctx.nowMs
? { usageLines: latest.snapshot.usageLines }
: {}),
@@ -1989,7 +1995,7 @@ export const consumeProviderResetCredit = Effect.fn("ProviderUsage.consumeResetC
const legacyCodex =
!instance &&
input.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("codex")) &&
- settings.providers.codex !== undefined;
+ settings.providers.codex.enabled !== false;
if (!legacyCodex && (!instance || instance.enabled === false || instance.driver !== "codex")) {
return yield* new ProviderResetCreditError({
detail: "This Codex provider is missing or disabled.",
@@ -2056,7 +2062,10 @@ export const consumeProviderResetCredit = Effect.fn("ProviderUsage.consumeResetC
const confirmed = confirmedResult?._tag === "Success" ? confirmedResult.success : undefined;
return {
...result,
- ...(confirmed?.status === "ok" && !confirmed.stale && confirmed.resetCredits !== undefined
+ ...(confirmed?.status === "ok" &&
+ !confirmed.stale &&
+ confirmed.resetCredits !== undefined &&
+ !confirmed.resetCredits.stale
? {}
: {
warning:
diff --git a/apps/web/src/components/usage/ProviderResetCredits.test.tsx b/apps/web/src/components/usage/ProviderResetCredits.test.tsx
index 4feecf414..d76d691ad 100644
--- a/apps/web/src/components/usage/ProviderResetCredits.test.tsx
+++ b/apps/web/src/components/usage/ProviderResetCredits.test.tsx
@@ -123,6 +123,23 @@ describe("provider reset credits", () => {
).toBe(true);
});
+ it("disables stale credits even when usage meters are fresh", () => {
+ expect(
+ button(
+ render({
+ account: {
+ ...account,
+ snapshot: {
+ ...account.snapshot!,
+ resetCredits: { ...account.snapshot!.resetCredits!, stale: true },
+ },
+ },
+ }),
+ "Redeem",
+ ).props.disabled,
+ ).toBe(true);
+ });
+
it("requires confirmation and targets the credit's environment, instance and account", async () => {
state.redeem.mockResolvedValue({ _tag: "Success", value: { outcome: "reset" } });
const confirmation = open();
diff --git a/apps/web/src/components/usage/ProviderResetCredits.tsx b/apps/web/src/components/usage/ProviderResetCredits.tsx
index e0e76e15a..c6092cd45 100644
--- a/apps/web/src/components/usage/ProviderResetCredits.tsx
+++ b/apps/web/src/components/usage/ProviderResetCredits.tsx
@@ -144,6 +144,7 @@ export function ProviderResetCreditList({
operateAccess === "granted" &&
snapshot?.status === "ok" &&
!snapshot.stale &&
+ !credits?.stale &&
!!snapshot.accountKey;
const accountLabel = [account.displayName, account.provider.auth.email, account.environmentLabel]
.filter(Boolean)
diff --git a/apps/web/src/components/usage/providerUsageAccounts.test.ts b/apps/web/src/components/usage/providerUsageAccounts.test.ts
index 07c210f78..8a3417271 100644
--- a/apps/web/src/components/usage/providerUsageAccounts.test.ts
+++ b/apps/web/src/components/usage/providerUsageAccounts.test.ts
@@ -208,6 +208,29 @@ describe("connected reset credit accounts", () => {
deriveConnectedProviderResetCreditAccounts(tracked)[0]?.snapshot?.resetCredits
?.availableCount,
).toBe(0);
+ const creditRefreshFailed = track([
+ {
+ ...pushed,
+ receivedAt: 4,
+ usage: [
+ { ...pushed.usage[0]!, resetCredits: { ...creditSnapshot.resetCredits!, stale: true } },
+ ],
+ },
+ {
+ ...second,
+ receivedAt: 3,
+ usage: [
+ { ...second.usage[0]!, resetCredits: { availableCount: 0, credits: [], stale: true } },
+ ],
+ },
+ ]);
+ expect(deriveConnectedProviderResetCreditAccounts(creditRefreshFailed)[0]?.environmentId).toBe(
+ laptopId,
+ );
+ expect(
+ deriveConnectedProviderResetCreditAccounts(creditRefreshFailed)[0]?.snapshot?.resetCredits
+ ?.availableCount,
+ ).toBe(0);
});
it("does not offer credit routes for unsupported or disabled instances", () => {
diff --git a/apps/web/src/components/usage/providerUsageAccounts.ts b/apps/web/src/components/usage/providerUsageAccounts.ts
index af84b1743..4bc60ce30 100644
--- a/apps/web/src/components/usage/providerUsageAccounts.ts
+++ b/apps/web/src/components/usage/providerUsageAccounts.ts
@@ -54,7 +54,12 @@ export function createProviderUsageArrivalTracker() {
: (environment.receivedAt ?? 0);
observed.set(key, { fingerprint, receivedAt });
snapshotReceivedAt.set(snapshot.instanceId, receivedAt);
- const creditFingerprint = JSON.stringify([snapshot.accountKey, snapshot.resetCredits]);
+ const creditFingerprint = JSON.stringify([
+ snapshot.accountKey,
+ snapshot.resetCredits?.availableCount,
+ snapshot.resetCredits?.credits,
+ snapshot.resetCredits?.nextExpiresAt,
+ ]);
const previousCredits = observedCredits.get(key);
const creditsReceivedAt =
previousCredits?.fingerprint === creditFingerprint
diff --git a/docs/user/provider-limits.md b/docs/user/provider-limits.md
index cece13037..bc321f3c9 100644
--- a/docs/user/provider-limits.md
+++ b/docs/user/provider-limits.md
@@ -27,3 +27,5 @@ Codex accounts with available usage reset credits show them below the usage mete
Select **Redeem** beside a credit, then confirm to use it on that account. Redeeming spends the credit and resets the account's current usage limits. It cannot be undone. Pathway refreshes the account's usage and available credits afterward. If the provider cannot confirm the result, Pathway shows an error; retrying the same credit does not spend a different one.
Credits belong to the provider account shown beside them. For a remote environment, Pathway sends the redemption to that environment. Viewing usage does not require permission to operate the environment, but redeeming a credit does. Providers that do not report reset credits do not show a redemption action.
+
+If the credit balance cannot be refreshed, Pathway keeps the last balance and disables redemption until a successful refresh. Usage meters can still update while credit information is unavailable.
diff --git a/packages/contracts/src/providerUsage.ts b/packages/contracts/src/providerUsage.ts
index a233f9d6e..2e796be40 100644
--- a/packages/contracts/src/providerUsage.ts
+++ b/packages/contracts/src/providerUsage.ts
@@ -34,6 +34,7 @@ export const ServerProviderResetCredits = Schema.Struct({
availableCount: NonNegativeInt,
credits: Schema.Array(Schema.Struct({ id: TrimmedNonEmptyString, expiresAt: IsoDateTime })),
nextExpiresAt: Schema.optional(IsoDateTime),
+ stale: Schema.optional(Schema.Boolean),
});
export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type;