();
const [deleteHistory, setDeleteHistory] = useState(false);
@@ -180,7 +181,13 @@ export function AccountsPage() {
.then((result) => exportDialog.show(result))
.catch(() => null);
}}
- onResetCredit={(accountId) => resetCreditDialog.show(accountId)}
+ onResetCredit={(accountId) => {
+ const account = accountsQuery.data?.find((item) => item.accountId === accountId);
+ resetCreditDialog.show({
+ accountId,
+ availableResetCredits: account?.availableResetCredits ?? 0,
+ });
+ }}
onLimitWarmupChange={(accountId, enabled) =>
void limitWarmupMutation.mutateAsync({ accountId, enabled })
}
@@ -241,7 +248,8 @@ export function AccountsPage() {
{resetCreditDialog.data ? (
) : null}
diff --git a/frontend/src/features/accounts/components/reset-credit-confirm-dialog.test.tsx b/frontend/src/features/accounts/components/reset-credit-confirm-dialog.test.tsx
index deecb5655..ba87e34a2 100644
--- a/frontend/src/features/accounts/components/reset-credit-confirm-dialog.test.tsx
+++ b/frontend/src/features/accounts/components/reset-credit-confirm-dialog.test.tsx
@@ -93,8 +93,10 @@ describe("ResetCreditConfirmDialog", () => {
await vi.waitFor(() =>
expect(toastSuccess).toHaveBeenCalledWith("Rate-limit window reset (1)"),
);
- expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["accounts"] });
- expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["dashboard"] });
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["accounts", "list"] });
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["accounts", "trends"] });
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["dashboard", "overview"] });
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["dashboard", "projections"] });
expect(onOpenChange).toHaveBeenCalledWith(false);
});
@@ -132,12 +134,75 @@ describe("ResetCreditConfirmDialog", () => {
await vi.waitFor(() =>
expect(toastError).toHaveBeenCalledWith("No reset credit available"),
);
- expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ["accounts"] });
- expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ["dashboard"] });
+ expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ["accounts", "list"] });
+ expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ["dashboard", "overview"] });
// Failure leaves the dialog open for retry.
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
+ it("shows a loading state while the reset-credit snapshot is fetching", () => {
+ server.use(
+ http.get(SNAPSHOT_URL, async () => {
+ await new Promise(() => {});
+ return snapshotResponse();
+ }),
+ );
+
+ renderWithClient(
+ ,
+ );
+
+ expect(screen.getByText("Loading reset credit details...")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Redeem credit" })).toBeDisabled();
+ });
+
+ it("shows an error message and keeps confirm disabled when the snapshot fetch fails", async () => {
+ server.use(
+ http.get(SNAPSHOT_URL, () =>
+ HttpResponse.json(
+ {
+ error: {
+ code: "service_unavailable",
+ message: "Reset credits unavailable",
+ },
+ },
+ { status: 503 },
+ ),
+ ),
+ );
+
+ renderWithClient(
+ ,
+ );
+
+ expect(await screen.findByText("Reset credits unavailable")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Redeem credit" })).toBeDisabled();
+ });
+
+ it("handles a null snapshot response without allowing redeem", async () => {
+ server.use(http.get(SNAPSHOT_URL, () => HttpResponse.json(null)));
+
+ renderWithClient(
+ ,
+ );
+
+ expect(await screen.findByText("0 free rate limit resets")).toBeInTheDocument();
+ expect(screen.getByText("Reset credit details are not available yet.")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Redeem credit" })).toBeDisabled();
+ });
+
it("allows redeeming an available credit when expiry is null", async () => {
const user = userEvent.setup();
const consumeCalled = vi.fn();
@@ -186,4 +251,36 @@ describe("ResetCreditConfirmDialog", () => {
await vi.waitFor(() => expect(consumeCalled).toHaveBeenCalledTimes(1));
});
+
+ it("enables redeem when GET cache is empty but summary reports available credits", async () => {
+ const user = userEvent.setup();
+ const consumeCalled = vi.fn();
+ server.use(
+ http.get(SNAPSHOT_URL, () => HttpResponse.json(null)),
+ http.post(CONSUME_URL, () => {
+ consumeCalled();
+ return HttpResponse.json({
+ code: "rate_limit_reset",
+ windowsReset: 1,
+ redeemedAt: "2026-01-01T12:00:00.000Z",
+ });
+ }),
+ );
+
+ renderWithClient(
+ ,
+ );
+
+ expect(await screen.findByText("2 free rate limit resets")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Redeem credit" })).toBeEnabled();
+
+ await user.click(screen.getByRole("button", { name: "Redeem credit" }));
+
+ await vi.waitFor(() => expect(consumeCalled).toHaveBeenCalledTimes(1));
+ });
});
diff --git a/frontend/src/features/accounts/components/reset-credit-confirm-dialog.tsx b/frontend/src/features/accounts/components/reset-credit-confirm-dialog.tsx
index c6a1becc4..b1de8c07c 100644
--- a/frontend/src/features/accounts/components/reset-credit-confirm-dialog.tsx
+++ b/frontend/src/features/accounts/components/reset-credit-confirm-dialog.tsx
@@ -5,12 +5,15 @@ import {
} from "@/features/accounts/hooks/use-accounts";
import type { RateLimitResetCreditItem } from "@/features/accounts/schemas";
import { cn } from "@/lib/utils";
+import { getErrorMessage } from "@/utils/errors";
import { formatLocalDateTimeSeconds, formatSingleUnitRemaining } from "@/utils/formatters";
export type ResetCreditConfirmDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
accountId: string | null;
+ /** Count from account summary when the per-account cache GET has not populated yet. */
+ summaryAvailableCount?: number;
};
function pickSoonestAvailableCredit(
@@ -52,7 +55,7 @@ function CreditExpiryLine({
return (
{label}{" "}
- {formatLocalDateTimeSeconds(expiresAt)}
+ {formatLocalDateTimeSeconds(expiresAt)}{" "}
c.status === "available" && c.id !== soonest?.id,
);
- const availableCount = snapshotQuery.data?.availableCount ?? 0;
+ const availableCount =
+ snapshotQuery.data != null
+ ? snapshotQuery.data.availableCount
+ : summaryAvailableCount;
const pending = resetCreditConsumeMutation.isPending;
+ const confirmDisabled =
+ pending || !accountId || snapshotLoading || snapshotError || availableCount <= 0;
const handleConfirm = () => {
if (!accountId || pending) {
@@ -111,36 +126,48 @@ export function ResetCreditConfirmDialog({
description="This redeems the soonest-expiring banked reset credit for this account."
confirmLabel={pending ? "Redeeming..." : "Redeem credit"}
cancelLabel="Cancel"
- confirmDisabled={pending || !accountId || !soonest}
+ confirmDisabled={confirmDisabled}
onOpenChange={handleOpenChange}
onConfirm={handleConfirm}
>
-
- {availableCount} free rate limit reset{availableCount !== 1 ? "s" : ""}
-
- {soonest ? (
-
-
- {otherCredits.map((credit) => (
-
- ))}
- {!soonest.expiresAt && otherCredits.length === 0 ? (
-
No upcoming expiry data available.
+ {snapshotLoading ? (
+
Loading reset credit details...
+ ) : snapshotError ? (
+
{snapshotErrorMessage}
+ ) : (
+ <>
+
+ {availableCount} free rate limit reset{availableCount !== 1 ? "s" : ""}
+
+ {soonest ? (
+
+
+ {otherCredits.map((credit) => (
+
+ ))}
+ {!soonest.expiresAt && otherCredits.length === 0 ? (
+
No upcoming expiry data available.
+ ) : null}
+
+ ) : availableCount > 0 ? (
+
No upcoming expiry data available.
+ ) : snapshotQuery.data === null ? (
+
+ Reset credit details are not available yet.
+
) : null}
-
- ) : availableCount > 0 ? (
-
No upcoming expiry data available.
- ) : null}
+ >
+ )}
);
diff --git a/frontend/src/features/accounts/hooks/use-accounts.ts b/frontend/src/features/accounts/hooks/use-accounts.ts
index 3672be4ec..616063bdf 100644
--- a/frontend/src/features/accounts/hooks/use-accounts.ts
+++ b/frontend/src/features/accounts/hooks/use-accounts.ts
@@ -182,12 +182,15 @@ export function useAccountMutations() {
const resetCreditConsumeMutation = useMutation({
mutationFn: (accountId: string) => consumeRateLimitResetCredit(accountId),
onSuccess: (data) => {
- const resetCount = data.windowsReset;
+ const resetCount = data.windowsReset ?? 0;
toast.success(
`Rate-limit window${resetCount === 1 ? "" : "s"} reset (${resetCount})`,
);
- void queryClient.invalidateQueries({ queryKey: ["accounts"] });
- void queryClient.invalidateQueries({ queryKey: ["dashboard"] });
+ void queryClient.invalidateQueries({ queryKey: ["accounts", "list"] });
+ void queryClient.invalidateQueries({ queryKey: ["accounts", "trends"] });
+ void queryClient.invalidateQueries({ queryKey: ["accounts", "reset-credits"] });
+ void queryClient.invalidateQueries({ queryKey: ["dashboard", "overview"] });
+ void queryClient.invalidateQueries({ queryKey: ["dashboard", "projections"] });
},
onError: (error: Error) => {
toast.error(error.message || "Reset credit redeem failed");
diff --git a/frontend/src/features/accounts/schemas.test.ts b/frontend/src/features/accounts/schemas.test.ts
index 900755b00..014586d91 100644
--- a/frontend/src/features/accounts/schemas.test.ts
+++ b/frontend/src/features/accounts/schemas.test.ts
@@ -214,52 +214,25 @@ describe("RateLimitResetCreditsSnapshotSchema", () => {
});
describe("ConsumeRateLimitResetCreditResponseSchema", () => {
- it("parses successful consume responses with optional redeemedAt", () => {
+ it("parses consume responses when nullable backend fields are omitted or null", () => {
expect(
ConsumeRateLimitResetCreditResponseSchema.parse({
- code: "rate_limit_reset",
- windowsReset: 1,
redeemedAt: ISO,
}),
).toMatchObject({
- code: "rate_limit_reset",
- windowsReset: 1,
redeemedAt: ISO,
});
expect(
ConsumeRateLimitResetCreditResponseSchema.parse({
- code: "rate_limit_reset",
- windowsReset: 1,
+ code: null,
+ windowsReset: null,
redeemedAt: null,
}),
).toMatchObject({
- code: "rate_limit_reset",
- windowsReset: 1,
+ code: null,
+ windowsReset: null,
redeemedAt: null,
});
-
- expect(
- ConsumeRateLimitResetCreditResponseSchema.parse({
- code: "rate_limit_reset",
- windowsReset: 1,
- }),
- ).toMatchObject({
- code: "rate_limit_reset",
- windowsReset: 1,
- });
-
- expect(() =>
- ConsumeRateLimitResetCreditResponseSchema.parse({
- redeemedAt: ISO,
- }),
- ).toThrow();
-
- expect(() =>
- ConsumeRateLimitResetCreditResponseSchema.parse({
- code: null,
- windowsReset: null,
- }),
- ).toThrow();
});
});
diff --git a/frontend/src/features/accounts/schemas.ts b/frontend/src/features/accounts/schemas.ts
index a62af06e4..378b9cca3 100644
--- a/frontend/src/features/accounts/schemas.ts
+++ b/frontend/src/features/accounts/schemas.ts
@@ -114,9 +114,9 @@ export const RateLimitResetCreditsSnapshotSchema = z.object({
});
export const ConsumeRateLimitResetCreditResponseSchema = z.object({
- code: z.string(),
- windowsReset: z.number(),
- redeemedAt: z.iso.datetime({ offset: true }).nullable().optional(),
+ code: z.string().nullable().optional(),
+ windowsReset: z.number().nullable().optional(),
+ redeemedAt: z.iso.datetime({ offset: true }).nullable(),
});
export const AccountTrendsResponseSchema = z.object({
diff --git a/frontend/src/features/auth/components/auth-gate.test.tsx b/frontend/src/features/auth/components/auth-gate.test.tsx
index 41133d17c..1251f2688 100644
--- a/frontend/src/features/auth/components/auth-gate.test.tsx
+++ b/frontend/src/features/auth/components/auth-gate.test.tsx
@@ -1,5 +1,5 @@
import { render, screen, waitFor } from "@testing-library/react";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { AuthGate } from "@/features/auth/components/auth-gate";
import { useAuthStore } from "@/features/auth/hooks/use-auth";
@@ -24,11 +24,16 @@ function setAuthState(
describe("AuthGate", () => {
beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
setAuthState({
refreshSession: vi.fn().mockResolvedValue(undefined),
});
});
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
it("shows login form when unauthenticated", async () => {
const refreshSession = vi.fn().mockResolvedValue(undefined);
setAuthState({
diff --git a/frontend/src/features/dashboard/components/account-card.test.tsx b/frontend/src/features/dashboard/components/account-card.test.tsx
index ad3c47c1d..06cae0835 100644
--- a/frontend/src/features/dashboard/components/account-card.test.tsx
+++ b/frontend/src/features/dashboard/components/account-card.test.tsx
@@ -1,5 +1,6 @@
import { act, render, screen } from "@testing-library/react";
-import { afterEach, describe, expect, it } from "vitest";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { AccountCard } from "@/features/dashboard/components/account-card";
import { usePrivacyStore } from "@/hooks/use-privacy";
@@ -127,4 +128,24 @@ describe("AccountCard", () => {
expect(screen.queryByRole("button", { name: /Reset \(/ })).not.toBeInTheDocument();
});
+
+ it("disables reset action for paused accounts", async () => {
+ const user = userEvent.setup();
+ const onAction = vi.fn();
+ const account = createAccountSummary({
+ accountId: "acc-paused",
+ displayName: "Paused Account",
+ status: "paused",
+ availableResetCredits: 1,
+ resetCreditNearestExpiresAt: "2026-01-03T12:00:00.000Z",
+ });
+
+ render();
+
+ const resetButton = screen.getByRole("button", { name: "Reset (1)" });
+ expect(resetButton).toBeDisabled();
+
+ await user.click(resetButton);
+ expect(onAction).not.toHaveBeenCalledWith(account, "reset-credit");
+ });
});
diff --git a/frontend/src/features/dashboard/components/account-card.tsx b/frontend/src/features/dashboard/components/account-card.tsx
index 99adcb867..9e9cb19b3 100644
--- a/frontend/src/features/dashboard/components/account-card.tsx
+++ b/frontend/src/features/dashboard/components/account-card.tsx
@@ -113,9 +113,20 @@ export function AccountCard({ account, showAccountId = false, readOnly = false,
: "No attempts";
const availableResetCredits = account.availableResetCredits ?? 0;
const hasResetCredits = availableResetCredits > 0;
+ const resetCreditDisabled =
+ readOnly || status === "paused" || status === "reauth" || status === "deactivated";
const resetCountdown = account.resetCreditNearestExpiresAt
? formatSingleUnitRemaining(account.resetCreditNearestExpiresAt)
: null;
+ const resetButtonTitle = resetCreditDisabled
+ ? status === "paused"
+ ? "Resume account to redeem reset credits"
+ : status === "reauth" || status === "deactivated"
+ ? "Re-authenticate account to redeem reset credits"
+ : "Reset credits unavailable"
+ : resetCountdown
+ ? `Reset (${availableResetCredits}) · ${resetCountdown.label}`
+ : `Reset (${availableResetCredits})`;
return (
@@ -201,7 +212,8 @@ export function AccountCard({ account, showAccountId = false, readOnly = false,
size="sm"
variant="ghost"
className="relative h-7 gap-1.5 rounded-lg pr-8 text-xs text-muted-foreground hover:text-foreground"
- disabled={readOnly}
+ title={resetButtonTitle}
+ disabled={resetCreditDisabled}
onClick={() => onAction?.(account, "reset-credit")}
>
diff --git a/frontend/src/features/dashboard/components/account-list.test.tsx b/frontend/src/features/dashboard/components/account-list.test.tsx
index f3fdc9f48..a30491261 100644
--- a/frontend/src/features/dashboard/components/account-list.test.tsx
+++ b/frontend/src/features/dashboard/components/account-list.test.tsx
@@ -61,15 +61,18 @@ describe("AccountList", () => {
render(
);
+ const resetButton = screen.getByRole("button", { name: "Redeem reset credit for Paused Account" });
+ expect(resetButton).toBeDisabled();
+
await user.click(screen.getByRole("button", { name: "View details for Paused Account" }));
- await user.click(screen.getByRole("button", { name: "Redeem reset credit for Paused Account" }));
+ await user.click(resetButton);
await user.click(screen.getByRole("button", { name: "Enable limit warm-up for Paused Account" }));
await user.click(screen.getByRole("button", { name: "Resume Paused Account" }));
expect(onAction).toHaveBeenNthCalledWith(1, account, "details");
- expect(onAction).toHaveBeenNthCalledWith(2, account, "reset-credit");
- expect(onAction).toHaveBeenNthCalledWith(3, account, "warmup-toggle");
- expect(onAction).toHaveBeenNthCalledWith(4, account, "resume");
+ expect(onAction).toHaveBeenNthCalledWith(2, account, "warmup-toggle");
+ expect(onAction).toHaveBeenNthCalledWith(3, account, "resume");
+ expect(onAction).not.toHaveBeenCalledWith(account, "reset-credit");
});
it("blurs list identity text when privacy mode is enabled", () => {
diff --git a/frontend/src/features/dashboard/components/account-list.tsx b/frontend/src/features/dashboard/components/account-list.tsx
index 7fd1f0f86..73223c094 100644
--- a/frontend/src/features/dashboard/components/account-list.tsx
+++ b/frontend/src/features/dashboard/components/account-list.tsx
@@ -314,9 +314,20 @@ export function AccountList({ accounts, readOnly = false, onAction }: AccountLis
: "No attempts";
const availableResetCredits = account.availableResetCredits ?? 0;
const hasResetCredits = availableResetCredits > 0;
+ const resetCreditDisabled =
+ readOnly || status === "paused" || status === "reauth" || status === "deactivated";
const resetCountdown = account.resetCreditNearestExpiresAt
? formatSingleUnitRemaining(account.resetCreditNearestExpiresAt)
: null;
+ const resetButtonTitle = resetCreditDisabled
+ ? status === "paused"
+ ? "Resume account to redeem reset credits"
+ : status === "reauth" || status === "deactivated"
+ ? "Re-authenticate account to redeem reset credits"
+ : "Reset credits unavailable"
+ : resetCountdown
+ ? `Reset (${availableResetCredits}) · ${resetCountdown.label}`
+ : `Reset (${availableResetCredits})`;
return (
onAction?.(account, "reset-credit")}
>
diff --git a/frontend/src/features/dashboard/components/dashboard-page.tsx b/frontend/src/features/dashboard/components/dashboard-page.tsx
index 5f6381522..c9973560b 100644
--- a/frontend/src/features/dashboard/components/dashboard-page.tsx
+++ b/frontend/src/features/dashboard/components/dashboard-page.tsx
@@ -52,7 +52,8 @@ export function DashboardPage() {
const projectionsQuery = useDashboardProjections(Boolean(dashboardQuery.data));
const { filters, logsQuery, optionsQuery, updateFilters } = useRequestLogs();
const { resumeMutation, limitWarmupMutation } = useAccountMutations();
- const resetCreditDialog = useDialogState();
+ type ResetCreditDialogTarget = { accountId: string; availableResetCredits: number };
+ const resetCreditDialog = useDialogState();
const isRefreshing = dashboardQuery.isFetching || projectionsQuery.isFetching || logsQuery.isFetching;
@@ -96,7 +97,10 @@ export function DashboardPage() {
}
break;
case "reset-credit":
- resetCreditDialog.show(account.accountId);
+ resetCreditDialog.show({
+ accountId: account.accountId,
+ availableResetCredits: account.availableResetCredits ?? 0,
+ });
break;
}
},
@@ -299,7 +303,8 @@ export function DashboardPage() {
{resetCreditDialog.data ? (
) : null}
diff --git a/openspec/changes/add-rate-limit-reset-credits/design.md b/openspec/changes/add-rate-limit-reset-credits/design.md
index fc8417025..697a06328 100644
--- a/openspec/changes/add-rate-limit-reset-credits/design.md
+++ b/openspec/changes/add-rate-limit-reset-credits/design.md
@@ -33,24 +33,16 @@ The reference is a single-account CLI; codex-lb needs a multi-account, dashboard
**Rationale:** Usage refresh owns account-status derivation and has a dense, scenario-heavy spec (`usage-refresh-policy`) tying it to quota reconciliation, cooldowns, and warm-up. Bolting credits onto that loop would couple two upstream calls and their failure modes, and muddy the usage-refresh contract. A dedicated `RateLimitResetCreditsRefreshScheduler` reuses the `UsageRefreshScheduler` loop shape (`asyncio.Lock`-guarded `_refresh_once`, interval-only configuration) and always starts with the application. Unlike usage refresh, it deliberately runs on every replica because reset-credit snapshots are process-local and dashboard reads must be consistent regardless of which replica handles the request.
**Alternatives considered:** (a) Fold into `UsageRefreshScheduler._refresh_once` — rejected for the coupling above. (b) Pure passthrough via the local `wham_router` proxy — rejected because the dashboard needs the in-memory store and per-account token decryption that the proxy router does not have, and the requirement is "refresh every 60s + store in-memory."
-### Decision: Dashboard consume stays "soonest", but `/v1/reset-credit` redeems an exact credit id
-**Rationale:** The dashboard flow is intentionally one-click and should keep server-side selection of the soonest available credit from the freshest snapshot. API-key automation has different needs: clients first call `GET /v1/reset-credit`, receive an explicit `redeem_id`, then submit `{account_id, redeem_id}` to `POST /v1/reset-credit`. The server still validates that the account belongs to the authenticated API key's pool and that the requested credit is currently available before forwarding the exact `credit_id` upstream.
-**Alternatives considered:** (a) make `/v1/reset-credit` also pick the soonest credit server-side — rejected because the caller explicitly needs a stable `redeem_id` contract. (b) make the dashboard send a specific `credit_id` — rejected because that would add stale-UI and clock-skew sensitivity to the one-click operator flow.
-
-### Decision: `/v1/reset-credit` lives beside `/v1/usage` and reuses API-key account-pool semantics
-**Rationale:** This is a self-service API-key feature, not a dashboard feature. The existing structural match is `GET /v1/usage`, which already requires a valid Bearer key even when global proxy auth is disabled. Reusing that surface keeps the route family coherent and reuses established `assigned_account_ids` scope semantics: scoped keys see only assigned accounts; unscoped keys see all selectable accounts.
-**Alternatives considered:** (a) add `/api/reset-credit` — rejected because `/api/*` in this repo is primarily dashboard/admin surface. (b) add `/api/codex/reset-credit` — rejected because the existing API-key self-service contract already lives under `/v1/*`.
-
-### Decision: `GET /v1/reset-credit` returns one array item per account
-**Rationale:** Email is useful data for callers, but using it as the response key collides with this repo's duplicate-email account rows. Returning one array item per account avoids key collisions while still exposing the real email inside each object. The response sorts eligible accounts by email ascending, then account id ascending, for deterministic output. Each item exposes `account_id`, `email`, `redeem_id`, and `expiredAt`, which is enough for clients to choose and redeem a credit without exposing the full upstream payload.
-**Alternatives considered:** (a) key by email — rejected because duplicate-email rows can overwrite each other. (b) key by account id — rejected because the caller asked for one big array.
+### Decision: Server picks the soonest-expiring credit at consume time
+**Rationale:** Single source of truth. The client passes only `{account_id}` to `POST /consume`; the server reads the cached snapshot, selects the available credit with the smallest `expires_at`, generates `redeem_request_id = uuid4()`, and calls upstream. This guarantees "nearest expiry_at is selected" even if the UI is stale, and avoids a client/server clock skew race.
+**Alternatives considered:** Client sends the specific `credit_id` — rejected because the cached snapshot may have changed between render and click (e.g. one expired or was redeemed elsewhere).
### Decision: Expose `available_reset_credits` + `reset_credit_nearest_expires_at` on `AccountSummary` (no DB column)
**Rationale:** The Accounts-page and Dashboard list both consume `AccountSummary`; joining the cached snapshot at mapper time gets the data to every UI surface with one change and zero migration. Account rows that have no cache yet return `0` / `null` and the UI hides its reset affordances for them.
**Alternatives considered:** Separate `/api/accounts/{id}/rate-limit-reset-credits` GET consumed per-card — rejected because it adds N round-trips and N re-renders; the count belongs on the summary the UI already fetches.
### Decision: Countdown is single-unit and goes red under 7 days
-**Rationale:** User requirement: one unit only ("6d" / "13h" / "45m" / "now"), red when `< 7d`. A new `formatSingleUnitRemaining(expiresAtIso)` helper sits next to existing `formatQuotaResetLabel` / `formatResetRelative` in `utils/formatters.ts`; the caller colors it via `ms < 7 * DAY_MS`. We do NOT add a ticking hook (matches the existing reset-label pattern). The confirmation dialog remains generic and does not need to render the upstream credit title, description, exact expiry timestamp, or upstream partial-reset consumption caveat.
+**Rationale:** User requirement: one unit only ("6d" / "13h" / "45m" / "now"), red when `< 7d`. A new `formatSingleUnitRemaining(expiresAtIso)` helper sits next to existing `formatQuotaResetLabel` / `formatResetRelative` in `utils/formatters.ts`; the caller colors it via `ms < 7 * DAY_MS`. We do NOT add a ticking hook (matches the existing reset-label pattern). The confirmation dialog uses a separate local-time formatter with exact `YYYY-MM-DD HH:MM:SS` output so operators can see the precise expiry instant in their own timezone.
**Alternatives considered:** Reuse `formatResetRelative` — rejected because it returns multi-unit ("6d 13h") output.
### Decision: Reset credit refresh never mutates account status
@@ -61,10 +53,8 @@ The reference is a single-account CLI; codex-lb needs a multi-account, dashboard
- **[Upstream endpoints are undocumented]** → Mitigation: client treats non-200 / non-JSON defensively, logs, keeps prior snapshot; consume-failure surfaces to UI as a toast without invalidating the cache. Document the upstream-dependence caveat in the capability `context.md`.
- **[In-memory cache lost on restart]** → Mitigation: acceptable per requirements; the next 60s tick repopulates. UI treats missing snapshot as `available_reset_credits: 0` (hidden affordances), not an error.
-- **[Credit consumed even on partial reset]** (upstream behavior: "if POST returns 200, the credit is gone") → Mitigation: on success we invalidate the cache and let the next tick reconcile. This caveat is documented in OpenSpec context but is not required in the dashboard confirmation dialog.
+- **[Credit consumed even on partial reset]** (upstream behavior: "if POST returns 200, the credit is gone") → Mitigation: require an explicit confirmation step before redeeming. On success we invalidate the cache and let the next tick reconcile.
- **[Race: credit expires between render and click]** → Mitigation: server re-selects from the freshest cached snapshot at consume time and surfaces upstream's error if the chosen credit is no longer redeemable.
-- **[Race: `/v1/reset-credit` client redeems a stale `redeem_id`]** → Mitigation: the POST handler re-reads the current cached snapshot, rejects credits that are no longer `available`, and only forwards the exact `redeem_id` when it still matches an available credit on an in-pool account.
-- **[Disabled or incomplete account keeps stale cached credits]** → Mitigation: paused, deactivated, and missing-`chatgpt-account-id` accounts invalidate any existing snapshot during scheduler refresh. Account summaries suppress such snapshots immediately, and dashboard consume refuses them before route resolution or upstream calls.
- **[Many accounts = many upstream calls per tick]** → Mitigation: reuse the same skip rules (paused/deactivated/missing chatgpt-account-id) and keep the interval configurable. Each replica polls so its process-local cache is useful for dashboard reads; moving snapshots to shared storage can later reduce duplicate polling if upstream load becomes a problem.
- **[Guest read-only dashboard users]** → Mitigation: `POST /consume` requires `require_dashboard_write_access`; guests can see the badge/button (count is read off `AccountSummary`) but cannot redeem.
diff --git a/openspec/changes/add-rate-limit-reset-credits/specs/frontend-architecture/spec.md b/openspec/changes/add-rate-limit-reset-credits/specs/frontend-architecture/spec.md
index 5e2721f41..1ae91f6dc 100644
--- a/openspec/changes/add-rate-limit-reset-credits/specs/frontend-architecture/spec.md
+++ b/openspec/changes/add-rate-limit-reset-credits/specs/frontend-architecture/spec.md
@@ -2,7 +2,7 @@
### Requirement: Accounts page exposes a reset-credits redeem action
-The Accounts page per-account action bar SHALL render a `Reset (N)` button next to the existing Export button with matching button styling whenever the account reports `available_reset_credits > 0`, where `N` is the available reset-credit count for that account. The button SHALL be hidden when `available_reset_credits` is `0`. Activating the button SHALL open a confirmation dialog that explains the dashboard will redeem the soonest-expiring banked reset credit for this account. Confirming SHALL submit a redeem request for that account and refresh account data on success.
+The Accounts page per-account action bar SHALL render a `Reset (N)` button next to the existing Export button with matching button styling whenever the account reports `available_reset_credits > 0`, where `N` is the available reset-credit count for that account. The button SHALL be hidden when `available_reset_credits` is `0`. Activating the button SHALL open a confirmation dialog that describes redeeming the soonest-expiring banked reset credit for that account and, when credit details are available, shows the soonest credit's expiry in local time using `YYYY-MM-DD HH:MM:SS`. Confirming SHALL submit a redeem request for that account and refresh account data on success.
#### Scenario: Reset button mirrors Export styling and placement
- **WHEN** the Accounts page renders the per-account action bar for an account with `available_reset_credits > 0`
@@ -15,12 +15,12 @@ The Accounts page per-account action bar SHALL render a `Reset (N)` button next
#### Scenario: Confirmation required before redeem
- **WHEN** the operator clicks the "Reset" button
-- **THEN** a confirmation dialog opens describing the soonest-expiring reset-credit redemption
+- **THEN** a confirmation dialog opens describing the soonest-expiring banked reset-credit redeem action
- **AND** no redeem request is sent until the operator confirms
-#### Scenario: Confirmation dialog can remain generic
-- **WHEN** the operator opens the reset-credit confirmation dialog
-- **THEN** the dialog is not required to render the upstream credit title, description, expiry timestamp, or upstream partial-reset consumption warning
+#### Scenario: Confirmation dialog shows local expiry timestamp
+- **WHEN** the operator opens the reset-credit confirmation dialog and credit details include an expiry timestamp
+- **THEN** the dialog renders the credit expiry in local time using `YYYY-MM-DD HH:MM:SS`
### Requirement: AccountListItem displays a reset-credits count badge
diff --git a/openspec/changes/add-rate-limit-reset-credits/specs/rate-limit-reset-credits/context.md b/openspec/changes/add-rate-limit-reset-credits/specs/rate-limit-reset-credits/context.md
index da2b27e63..c46370098 100644
--- a/openspec/changes/add-rate-limit-reset-credits/specs/rate-limit-reset-credits/context.md
+++ b/openspec/changes/add-rate-limit-reset-credits/specs/rate-limit-reset-credits/context.md
@@ -32,18 +32,9 @@ client treats non-200, non-JSON, and schema-drifted 200 responses defensively.
- **In-memory only.** No DB column, no migration. Each replica refreshes its own process-local
snapshots, which repopulate within one tick of startup. Restart cost: up to 60s of
`available_reset_credits: 0` on that replica.
-- **Eligibility clears stale snapshots.** Paused accounts, deactivated accounts, and accounts
- without a usable `chatgpt-account-id` do not fetch reset credits. If they already have a
- cached snapshot, the scheduler invalidates it; account summaries suppress it immediately;
- and dashboard consume rejects the account before route resolution or upstream calls.
- **Server picks the credit, not the client.** `POST /consume` takes only the account id;
the server selects the soonest-expiring available credit from the freshest snapshot and
generates the `redeem_request_id`. Avoids stale-UI and clock-skew races.
-- **Dashboard and self-service consume differ intentionally.** The dashboard `POST /api/accounts/{id}/rate-limit-reset-credits/consume`
- takes only the account id and the server selects the soonest-expiring available credit.
- The API-key self-service `POST /v1/reset-credit` instead accepts `{account_id, redeem_id}`
- and forwards that exact credit only after validating account-pool membership and current
- credit availability.
- **Never mutates account status.** Account status is owned by usage refresh
(see `usage-refresh-policy`). Reset-credit polling failure logs and retains the prior
snapshot; it does not deactivate, rate-limit, or quota-block any account.
@@ -51,19 +42,23 @@ client treats non-200, non-JSON, and schema-drifted 200 responses defensively.
loop shape (`asyncio.Lock`-guarded, configurable cadence) but intentionally does not use
leader election because the cache is process-local. The scheduler always starts with the
app; only the interval is configurable. See `design.md` for the rationale.
-- **`/v1/reset-credit` follows the `/v1/usage` route family.** It is an API-key self-service
- contract, not a dashboard/admin route. The authenticated key's account pool comes from
- existing assignment-scope behavior: scoped keys are limited to `assigned_account_ids`,
- while unscoped keys can see all selectable accounts.
## Failure Modes
- **Upstream returns 200 but the rate-limit window doesn't move.** Per upstream behavior
- the credit is still consumed. This is an upstream caveat rather than a dashboard dialog
- requirement; on success we invalidate the cache and let the next tick reconcile
+ the credit is still consumed. The dashboard requires explicit confirmation before
+ redeeming; on success we invalidate the cache and let the next tick reconcile
`available_count`.
- **Snapshot is empty/stale.** UI hides all reset affordances for that account
(`available_reset_credits: 0`). Not an error — wait one tick.
+- **Fresh consume preflight disproves a cached credit.** If the live pre-consume fetch says
+ `available_count: 0` or returns no available items, codex-lb overwrites the cached snapshot
+ with that fresh upstream state before returning `409`, so the dashboard does not keep
+ advertising a stale `Reset (N)` action until the next scheduler tick.
+- **Account becomes ineligible after a successful snapshot.** Scheduler skips paused,
+ reauth-required, deactivated, or account-id-less accounts, so dashboard reads also check
+ current account eligibility before serving cached reset credits. If the account is
+ ineligible, the read returns no snapshot and invalidates the stale cache entry.
- **Upstream 401/403/auth-expired.** Logged; prior snapshot retained. Does NOT deactivate
the account. If the token is genuinely expired, usage refresh / OAuth refresh owns the
deactivation path.
@@ -71,10 +66,6 @@ client treats non-200, non-JSON, and schema-drifted 200 responses defensively.
consume requests cannot forward the same cached `credit_id` upstream. After the first
request finishes, the second request re-reads the account snapshot and either sees a
refreshed state or fails with a dashboard conflict when no credit is still available.
-- **Stale `/v1/reset-credit` `redeem_id`.** A client may read a credit from `GET /v1/reset-credit`
- and redeem it later after the snapshot changed. `POST /v1/reset-credit` re-reads the
- current cached snapshot and rejects unavailable or mismatched `redeem_id` values with a
- client error instead of redeeming a different credit.
- **Upstream consume failures.** Client-facing upstream failures are preserved as dashboard
errors (`401`, `403`, `409`), while other consume failures surface as dashboard `503`
responses instead of falling into the generic internal-error handler.
@@ -117,52 +108,12 @@ client treats non-200, non-JSON, and schema-drifted 200 responses defensively.
}
```
-## Example: `/v1/reset-credit` GET response
-
-```json
-[
- {
- "account_id": "acc_alpha",
- "email": "alpha@example.com",
- "redeem_id": "RateLimitResetCredit_alpha",
- "expiredAt": "2026-07-12T01:29:41.346025Z"
- },
- {
- "account_id": "acc_alpha",
- "email": "alpha@example.com",
- "redeem_id": "RateLimitResetCredit_alpha_2",
- "expiredAt": null
- },
- {
- "account_id": "acc_beta",
- "email": "beta@example.com",
- "redeem_id": "RateLimitResetCredit_beta",
- "expiredAt": null
- }
-]
-```
-
-Entries are ordered by email ascending, then account id ascending, then credit expiry ascending
-with `null` expiries last, so the response remains deterministic for the same eligible pool.
-
-## Example: `/v1/reset-credit` POST body
-
-```json
-{
- "account_id": "acc_alpha",
- "redeem_id": "RateLimitResetCredit_alpha"
-}
-```
-
## Operational Notes
- The 60s cadence matches usage refresh, but each replica polls because each replica serves
dashboard reads from its own process-local snapshot cache.
-- A credit is consumed as soon as upstream returns 200; operators should treat the confirm
- action as the point of no return.
-- `/v1/reset-credit` uses the same process-local snapshot cache as the dashboard flow, so a
- client may need to retry after the next refresh tick if an account has just restarted or
- recently redeemed a credit.
+- A credit is consumed as soon as upstream returns 200 — treat the confirmation dialog as
+ the point of no return.
## Related Work
diff --git a/openspec/changes/add-rate-limit-reset-credits/specs/rate-limit-reset-credits/spec.md b/openspec/changes/add-rate-limit-reset-credits/specs/rate-limit-reset-credits/spec.md
index e2b07e813..205128c80 100644
--- a/openspec/changes/add-rate-limit-reset-credits/specs/rate-limit-reset-credits/spec.md
+++ b/openspec/changes/add-rate-limit-reset-credits/specs/rate-limit-reset-credits/spec.md
@@ -2,7 +2,7 @@
### Requirement: Reset credits are polled per account on a fixed cadence
-The system SHALL poll upstream `GET /wham/rate-limit-reset-credits` for each eligible account on a configurable cadence that defaults to 60 seconds, using that account's stored OAuth bearer token and `chatgpt-account-id`. The scheduler SHALL always start with the application lifespan. Because snapshots are kept in process-local memory, every running replica SHALL refresh its own snapshot cache instead of relying on leader election. The poll SHALL skip any account that is paused, deactivated, or lacks a usable `chatgpt-account-id`, and SHALL invalidate any cached reset-credit snapshot for skipped accounts.
+The system SHALL poll upstream `GET /wham/rate-limit-reset-credits` for each eligible account on a configurable cadence that defaults to 60 seconds, using that account's stored OAuth bearer token and `chatgpt-account-id`. The scheduler SHALL always start with the application lifespan. Because snapshots are kept in process-local memory, every running replica SHALL refresh its own snapshot cache instead of relying on leader election. The poll SHALL skip any account that is paused, requires reauthentication, deactivated, or lacks a usable `chatgpt-account-id`.
#### Scenario: Default cadence polls every 60 seconds
- **WHEN** the application starts with default settings
@@ -13,19 +13,14 @@ The system SHALL poll upstream `GET /wham/rate-limit-reset-credits` for each eli
- **THEN** each replica refreshes its own in-memory reset-credit snapshots on the configured cadence
- **AND** dashboard reads served by any replica can observe populated reset-credit data after that replica's refresh tick
-#### Scenario: Paused and deactivated accounts are skipped
-- **WHEN** an account is persisted as `paused` or `deactivated`
+#### Scenario: Ineligible accounts are skipped
+- **WHEN** an account is persisted as `paused`, `reauth_required`, or `deactivated`
- **THEN** the scheduler performs no upstream reset-credits fetch for that account
-- **AND** the cached snapshot for that account (if any) is invalidated
-
-#### Scenario: Account without ChatGPT account id is skipped
-- **WHEN** an account lacks a usable `chatgpt-account-id`
-- **THEN** the scheduler performs no upstream reset-credits fetch for that account
-- **AND** the cached snapshot for that account (if any) is invalidated
+- **AND** the cached snapshot for that account (if any) is left untouched by the skip
### Requirement: Reset credit snapshots are cached in memory keyed by account
-The system SHALL store the most recent successful reset-credits response per account in an in-memory store keyed by account id. The store SHALL be concurrency-safe and SHALL provide an `invalidate(account_id)` operation. Account-summary mappers SHALL join the cached snapshot onto each eligible account summary, exposing `available_reset_credits` (integer) and `reset_credit_nearest_expires_at` (ISO timestamp or null). Accounts with no cached snapshot, accounts that are paused or deactivated, and accounts without a usable `chatgpt-account-id` SHALL expose `available_reset_credits: 0` and `reset_credit_nearest_expires_at: null`.
+The system SHALL store the most recent successful reset-credits response per account in an in-memory store keyed by account id. The store SHALL be concurrency-safe and SHALL provide an `invalidate(account_id)` operation. Account-summary mappers SHALL join the cached snapshot onto each account summary, exposing `available_reset_credits` (integer) and `reset_credit_nearest_expires_at` (ISO timestamp or null). Accounts with no cached snapshot SHALL expose `available_reset_credits: 0` and `reset_credit_nearest_expires_at: null`.
#### Scenario: Account summary reflects cached credits
- **GIVEN** an account has a cached reset-credits snapshot with `available_count: 2` and a soonest expiry of `2026-07-10T00:00:00Z`
@@ -37,12 +32,6 @@ The system SHALL store the most recent successful reset-credits response per acc
- **WHEN** the account-summary mapper builds the summary for that account
- **THEN** the summary exposes `available_reset_credits: 0` and `reset_credit_nearest_expires_at: null`
-#### Scenario: Ineligible account summary suppresses stale credits
-- **GIVEN** an account is paused, deactivated, or lacks a usable `chatgpt-account-id`
-- **AND** a cached reset-credits snapshot still exists for that account
-- **WHEN** the account-summary mapper builds the summary for that account
-- **THEN** the summary exposes `available_reset_credits: 0` and `reset_credit_nearest_expires_at: null`
-
#### Scenario: Invalidate forces re-fetch on next tick
- **WHEN** a caller invokes `invalidate(account_id)` for an account
- **THEN** subsequent reads for that account return no cached snapshot
@@ -54,9 +43,16 @@ The system SHALL store the most recent successful reset-credits response per acc
- **WHEN** the refresh completes
- **THEN** the stale fetched response MUST NOT be written back into the cache
+#### Scenario: Dashboard read invalidates stale snapshots for ineligible accounts
+- **GIVEN** an account has a cached reset-credits snapshot
+- **AND** the account is now persisted as `paused`, `reauth_required`, `deactivated`, or no longer has a usable `chatgpt-account-id`
+- **WHEN** the dashboard invokes `GET /api/accounts/{id}/rate-limit-reset-credits`
+- **THEN** the endpoint returns `null` without calling upstream
+- **AND** the cached snapshot for that account is invalidated
+
### Requirement: Operators can redeem the soonest-expiring available credit
-The system SHALL expose a dashboard endpoint `POST /api/accounts/{account_id}/rate-limit-reset-credits/consume` that redeems exactly one credit for the named account. The endpoint SHALL refuse paused accounts, deactivated accounts, and accounts without a usable `chatgpt-account-id`, invalidating any cached snapshot for the account before returning a client error. For eligible accounts, the endpoint SHALL select, from the freshest cached snapshot, the credit whose `status` is `available` with the smallest `expires_at`, generate a `redeem_request_id` (UUID v4), and forward `{credit_id, redeem_request_id}` to upstream `POST /wham/rate-limit-reset-credits/consume` using the account's bearer token and `chatgpt-account-id`. A cached snapshot with `available_count <= 0` MUST be treated as having no redeemable credits, even if the cached `credits` list contains an item marked `available`. On a 200 response the endpoint SHALL invalidate the cached snapshot for that account and return `{code, windows_reset, redeemed_at}`. The endpoint SHALL require dashboard write access; read-only guests MUST be refused.
+The system SHALL expose a dashboard endpoint `POST /api/accounts/{account_id}/rate-limit-reset-credits/consume` that redeems exactly one credit for the named account. The endpoint SHALL select, from the freshest cached snapshot, the credit whose `status` is `available` with the smallest `expires_at`, generate a `redeem_request_id` (UUID v4), and forward `{credit_id, redeem_request_id}` to upstream `POST /wham/rate-limit-reset-credits/consume` using the account's bearer token and `chatgpt-account-id`. A cached snapshot with `available_count <= 0` MUST be treated as having no redeemable credits, even if the cached `credits` list contains an item marked `available`. When the fresh pre-consume fetch reports `available_count <= 0` or no available credit items, the endpoint SHALL replace any prior cached snapshot for that account with the fresh upstream snapshot before returning a conflict. On a 200 response the endpoint SHALL invalidate the cached snapshot for that account and return `{code, windows_reset, redeemed_at}`. The endpoint SHALL require dashboard write access; read-only guests MUST be refused.
#### Scenario: Consume selects the soonest-expiring credit
- **GIVEN** an account has cached credits with expiries `2026-07-10Z` and `2026-06-20Z`, both `status: available`
@@ -91,40 +87,12 @@ The system SHALL expose a dashboard endpoint `POST /api/accounts/{account_id}/ra
- **WHEN** the operator invokes `POST /api/accounts/{id}/rate-limit-reset-credits/consume`
- **THEN** the endpoint returns a `409` (or equivalent client-error) without calling upstream
-#### Scenario: Consume refuses ineligible account before upstream call
-- **GIVEN** an account is paused, deactivated, or lacks a usable `chatgpt-account-id`
-- **AND** a cached reset-credits snapshot still exists for that account
+#### Scenario: Fresh empty consume fetch replaces a stale cached snapshot
+- **GIVEN** an account has a cached reset-credits snapshot showing at least one available credit
+- **AND** the fresh pre-consume upstream fetch returns `available_count: 0` or no `status: available` items
- **WHEN** the operator invokes `POST /api/accounts/{id}/rate-limit-reset-credits/consume`
-- **THEN** the endpoint invalidates the cached snapshot for that account
-- **AND** returns a client error without resolving a route or calling upstream
-
-### Requirement: API-key self-service reset-credit reads and exact redemption reuse the cached snapshots
-
-The system SHALL expose `GET /v1/reset-credit` and `POST /v1/reset-credit` as API-key-authenticated self-service routes backed by the same cached reset-credit snapshots used by the dashboard flow. `GET /v1/reset-credit` SHALL project the authenticated API key's eligible account pool into one array item per available credit, ordered by account email ascending, then account id ascending, then credit `expires_at` ascending with `null` expiries last, then credit id ascending. Each item SHALL include `account_id`, `email`, `redeem_id`, and `expiredAt`, where `redeem_id` and `expiredAt` come from that available credit. Accounts with no cached snapshot or no available credit SHALL be omitted from the `GET` response.
-
-`POST /v1/reset-credit` SHALL accept JSON body `{account_id, redeem_id}`. The endpoint SHALL reject requests whose `account_id` is outside the authenticated API key's account pool. For in-pool accounts, the endpoint SHALL re-read that account's freshest cached snapshot, verify that `redeem_id` still identifies an `available` credit on the account, forward that exact `credit_id` upstream with a generated `redeem_request_id`, and invalidate the cached snapshot for the account on a 200 response. A cached snapshot with `available_count <= 0` MUST be treated as having no redeemable credits even if the cached `credits` list contains an item marked `available`.
-
-#### Scenario: GET returns every available credit for eligible accounts
-- **GIVEN** two in-pool accounts each have multiple cached available credits
-- **WHEN** the client invokes `GET /v1/reset-credit`
-- **THEN** the response contains one array item per available credit
-- **AND** entries are grouped by account email and account id, with each account's credits ordered by soonest expiry first and `null` expiries last
-
-#### Scenario: GET omits accounts without available cached credits
-- **GIVEN** one in-pool account has `available_count: 0` and another has no cached snapshot
-- **WHEN** the client invokes `GET /v1/reset-credit`
-- **THEN** neither account appears in the response array
-
-#### Scenario: POST rejects a redeem id that is not currently available
-- **GIVEN** an in-pool account whose cached snapshot does not contain the supplied `redeem_id` as an `available` credit
-- **WHEN** the client invokes `POST /v1/reset-credit`
-- **THEN** the endpoint returns `409` without calling upstream
-
-#### Scenario: POST forwards the exact requested redeem id
-- **GIVEN** an in-pool account whose cached snapshot contains `redeem_id = "credit_exact"` as an available credit
-- **WHEN** the client invokes `POST /v1/reset-credit` with `{account_id, redeem_id: "credit_exact"}`
-- **THEN** the upstream consume request carries `credit_id = "credit_exact"`
-- **AND** a successful response invalidates the cached snapshot for that account
+- **THEN** the endpoint returns a `409` (or equivalent client-error)
+- **AND** the cached snapshot for that account is replaced with the fresh upstream snapshot before the response is returned
### Requirement: Reset credit polling failure does not mutate account status
diff --git a/openspec/changes/add-rate-limit-reset-credits/tasks.md b/openspec/changes/add-rate-limit-reset-credits/tasks.md
index bacf74315..2fd3b6cf8 100644
--- a/openspec/changes/add-rate-limit-reset-credits/tasks.md
+++ b/openspec/changes/add-rate-limit-reset-credits/tasks.md
@@ -12,10 +12,6 @@
- [x] 2.3 Create `app/modules/rate_limit_reset_credits/api.py` with `GET /api/accounts/{account_id}/rate-limit-reset-credits` (returns cached snapshot or `null`) and `POST /api/accounts/{account_id}/rate-limit-reset-credits/consume` (selects soonest-`expires_at` available credit from the freshest snapshot, generates `redeem_request_id`, calls upstream, invalidates the cached snapshot, returns `{code, windows_reset, redeemed_at}`). Use `validate_dashboard_session` for GET and `require_dashboard_write_access` for POST. Return `409` when no credit is available. Register the router in `app/main.py`
- [x] 2.4 Extend the AccountSummary mapper(s) in `app/modules/accounts/` and the dashboard mapper to join the cached snapshot onto each returned account: add `available_reset_credits: int` (0 when no snapshot) and `reset_credit_nearest_expires_at: datetime | None` (null when no snapshot)
- [x] 2.5 Update the backend pydantic response schemas (`AccountSummary` / equivalent) to declare the two new fields
-- [x] 2.6 Extend the authenticated `/v1/*` proxy surface with `GET /v1/reset-credit` and `POST /v1/reset-credit` in `app/modules/proxy/api.py`, using `validate_usage_api_key` and the existing OpenAI-style error envelope
-- [x] 2.7 Reuse API-key assignment-scope semantics to resolve the eligible reset-credit account pool: scoped keys may access only `assigned_account_ids`; unscoped keys may access all selectable accounts
-- [x] 2.8 Implement `GET /v1/reset-credit` projection from cached snapshots into a deterministic array ordered by account email ascending, account id ascending, and credit expiry ascending (`null` last), returning every available credit for each eligible account with `email` included in each object and omitting accounts with no available cached credits
-- [x] 2.9 Implement `POST /v1/reset-credit` exact-credit redemption: validate `{account_id, redeem_id}`, reject out-of-pool accounts, reject unavailable or mismatched redeem ids, forward the exact `credit_id` upstream, and invalidate the cached snapshot on success
## 3. Frontend schemas, API client, formatter
@@ -29,7 +25,7 @@
- [x] 4.2 Add the `Reset (N)` button to `frontend/src/features/accounts/components/account-actions.tsx` immediately after the Export button, matching its `size="sm" variant="outline" className="h-8 gap-1.5 text-xs"` style, with a `RotateCcw` icon, a single-unit countdown label (using 3.3) placed at the button's right-upper radius, and destructive/red label color when `expiringSoon`. Render only when `availableResetCredits > 0`. Wire `onClick` to open the confirmation dialog
- [x] 4.3 Add a reset action to `frontend/src/features/dashboard/components/account-list.tsx` (table view) inside the existing Details action cell, matching the `h-7 w-7` icon-button style with the countdown and count exposed in the `title` tooltip. Render only when `availableResetCredits > 0`
- [x] 4.4 Add a `Reset (N)` button to `frontend/src/features/dashboard/components/account-card.tsx` (grid view) next to the Details button, matching the `h-7 gap-1.5` text style with the single-unit countdown label. Render only when `availableResetCredits > 0`
-- [x] 4.5 Implement the confirmation dialog (reuse `frontend/src/components/confirm-dialog.tsx` + `frontend/src/hooks/use-dialog-state.ts`, same shape as the delete-account dialog): body explains that the dashboard redeems the soonest-expiring banked reset credit for the account. On confirm → call `consumeRateLimitResetCredit(accountId)` → success/failure toast → query invalidation
+- [x] 4.5 Implement the confirmation dialog (reuse `frontend/src/components/confirm-dialog.tsx` + `frontend/src/hooks/use-dialog-state.ts`, same shape as the delete-account dialog): body describes the soonest-expiring banked reset-credit redeem action and shows `expires_at` formatted as local `YYYY-MM-DD HH:MM:SS` when credit details are available. On confirm → call `consumeRateLimitResetCredit(accountId)` → success/failure toast → query invalidation
- [x] 4.6 Add a "Most reset credits" option to the Accounts page sort selector in `frontend/src/features/accounts/sorting.ts` and make it the default Accounts page sort mode: comparator orders by `availableResetCredits` desc, tiebreak by `resetCreditNearestExpiresAt` asc (soonest first), accounts with null expiry last. Add the localized dropdown label
- [x] 4.7 Add a summed reset-credit badge to `frontend/src/components/layout/app-header.tsx` for the Accounts nav tab, capped at `99+`
@@ -43,16 +39,14 @@
- [x] 5.6 Frontend — `formatSingleUnitRemaining`: boundaries at 7d (color flip), 1d, 1h, 1m, and `now`; sub-minute and past timestamps both yield `"now"`
- [x] 5.7 Frontend — `AccountListItem` badge: renders count, `"99+"` at 100+, absent at 0
- [x] 5.8 Frontend — Reset button visibility: rendered when `availableResetCredits > 0`, absent at 0, in all three surfaces (account-actions, dashboard table, dashboard grid)
-- [x] 5.9 Frontend — confirm dialog → consume: confirmation calls `consumeRateLimitResetCredit`, success path invalidates queries, failure path surfaces a toast and does not invalidate
+- [x] 5.9 Frontend — confirm dialog → consume: confirmation calls `consumeRateLimitResetCredit`, shows the expiry in local `YYYY-MM-DD HH:MM:SS`, success path invalidates queries, failure path surfaces a toast and does not invalidate
- [x] 5.10 Frontend — "Most reset credits" sort: comparator orders by count desc with soonest-expiry tiebreak, null-expiry accounts last
- [x] 5.11 Frontend — Accounts nav badge: shows the summed total, caps at `99+`, and hides at zero
-- [x] 5.12 Backend — `/v1/reset-credit` GET: requires Bearer API key, honors scoped vs unscoped account pools, emits a deterministic array with `email` in each object for every available credit, and omits accounts without available cached credits
-- [x] 5.13 Backend — `/v1/reset-credit` POST: rejects out-of-pool `account_id`, rejects unavailable or mismatched `redeem_id`, forwards the exact requested `credit_id`, invalidates the snapshot on success, and preserves `/v1/*` OpenAI-style error responses
## 6. Validation and OpenSpec hygiene
- [x] 6.1 Run `openspec validate add-rate-limit-reset-credits --strict` and resolve any findings
- [x] 6.2 Run `openspec validate --specs --strict` to confirm no main-spec drift
-- [x] 6.3 Run backend checks: `uv run ruff check && uv run ruff format --check && uv run pytest` (or the repo's documented equivalent)
+- [ ] 6.3 Run backend checks: `uv run ruff check && uv run ruff format --check && uv run pytest` (or the repo's documented equivalent)
- [x] 6.4 Run frontend checks: `pnpm -C frontend lint && pnpm -C frontend typecheck && pnpm -C frontend test` (or the repo's documented equivalent)
- [ ] 6.5 Manually verify the three Reset button placements, the per-button count labels, the Accounts-nav total badge cap behavior, the countdown color flip at 7d, the local expiry timestamp, the confirm flow, and the new sort option against the spec scenarios
diff --git a/tests/integration/test_rate_limit_reset_credits_api.py b/tests/integration/test_rate_limit_reset_credits_api.py
new file mode 100644
index 000000000..d8b3493c1
--- /dev/null
+++ b/tests/integration/test_rate_limit_reset_credits_api.py
@@ -0,0 +1,197 @@
+from __future__ import annotations
+
+import base64
+import json
+from datetime import datetime
+from typing import Any
+
+import pytest
+
+from app.core.auth import generate_unique_account_id
+from app.core.clients.rate_limit_reset_credits import (
+ ConsumeResetCreditResponse,
+ ResetCreditItem,
+ ResetCreditsResponse,
+)
+from app.db.session import SessionLocal
+from app.modules.rate_limit_reset_credits import api as reset_credits_api
+
+pytestmark = pytest.mark.integration
+
+
+def _encode_jwt(payload: dict) -> str:
+ raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
+ body = base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
+ return f"header.{body}.sig"
+
+
+async def _import_test_account(async_client, *, email: str, account_id: str) -> str:
+ payload = {
+ "email": email,
+ "chatgpt_account_id": account_id,
+ "https://api.openai.com/auth": {"chatgpt_plan_type": "plus"},
+ }
+ auth_json = {
+ "tokens": {
+ "idToken": _encode_jwt(payload),
+ "accessToken": "access-token-not-a-real-secret",
+ "refreshToken": "refresh",
+ "accountId": account_id,
+ },
+ }
+ files = {"auth_json": ("auth.json", json.dumps(auth_json), "application/json")}
+ response = await async_client.post("/api/accounts/import", files=files)
+ assert response.status_code == 200, response.text
+ return generate_unique_account_id(account_id, email)
+
+
+def _credit(credit_id: str, *, expires_at: str = "2026-07-12T00:00:00Z") -> ResetCreditItem:
+ return ResetCreditItem.model_validate({"id": credit_id, "status": "available", "expires_at": expires_at})
+
+
+def _upstream_response(credits: list[ResetCreditItem], available_count: int | None = None) -> ResetCreditsResponse:
+ count = available_count if available_count is not None else len(credits)
+ return ResetCreditsResponse(credits=credits, available_count=count)
+
+
+@pytest.mark.asyncio
+async def test_consume_paused_account_returns_409(async_client, monkeypatch) -> None:
+ async def _should_not_fetch(*args: Any, **kwargs: Any) -> ResetCreditsResponse:
+ raise AssertionError("paused account should not invoke upstream fetch")
+
+ monkeypatch.setattr(reset_credits_api, "fetch_reset_credits", _should_not_fetch)
+
+ account_id = await _import_test_account(
+ async_client,
+ email="reset-paused@example.com",
+ account_id="acc_reset_paused",
+ )
+ pause_resp = await async_client.post(f"/api/accounts/{account_id}/pause")
+ assert pause_resp.status_code == 200
+
+ response = await async_client.post(f"/api/accounts/{account_id}/rate-limit-reset-credits/consume")
+ assert response.status_code == 409
+ body = response.json()
+ assert body["error"]["code"] == "account_not_reset_credit_applicable"
+
+
+@pytest.mark.asyncio
+async def test_consume_active_account_returns_success_with_mocked_upstream(async_client, monkeypatch) -> None:
+ captured: dict[str, Any] = {}
+
+ async def _fake_fetch(access_token: str, account_id: str | None, **kwargs: Any) -> ResetCreditsResponse:
+ captured["fetch_account_id"] = account_id
+ captured["fetch_had_token"] = bool(access_token)
+ return _upstream_response([_credit("credit-1")])
+
+ async def _fake_consume(
+ access_token: str,
+ account_id: str | None,
+ credit_id: str,
+ **kwargs: Any,
+ ) -> ConsumeResetCreditResponse:
+ captured.update(
+ {
+ "consume_account_id": account_id,
+ "consume_credit_id": credit_id,
+ "consume_had_token": bool(access_token),
+ }
+ )
+ return ConsumeResetCreditResponse.model_validate(
+ {
+ "code": "reset",
+ "credit": {
+ "id": credit_id,
+ "status": "redeemed",
+ "redeemed_at": "2026-06-13T13:12:31Z",
+ },
+ "windows_reset": 2,
+ }
+ )
+
+ async def _noop_refresh(account) -> None: # noqa: ANN001
+ return None
+
+ monkeypatch.setattr(reset_credits_api, "fetch_reset_credits", _fake_fetch)
+ monkeypatch.setattr(reset_credits_api, "consume_reset_credit", _fake_consume)
+ monkeypatch.setattr(reset_credits_api, "_build_refresh_usage_callback", lambda _context: _noop_refresh)
+
+ account_id = await _import_test_account(
+ async_client,
+ email="reset-active@example.com",
+ account_id="acc_reset_active",
+ )
+
+ response = await async_client.post(f"/api/accounts/{account_id}/rate-limit-reset-credits/consume")
+ assert response.status_code == 200, response.text
+ body = response.json()
+ assert body["code"] == "reset"
+ assert body["windowsReset"] == 2
+ assert body["redeemedAt"] is not None
+ assert datetime.fromisoformat(body["redeemedAt"].replace("Z", "+00:00")).year == 2026
+
+ assert captured["fetch_account_id"] == "acc_reset_active"
+ assert captured["fetch_had_token"] is True
+ assert captured["consume_account_id"] == "acc_reset_active"
+ assert captured["consume_credit_id"] == "credit-1"
+ assert captured["consume_had_token"] is True
+
+
+@pytest.mark.asyncio
+async def test_consume_reauth_required_account_returns_409(async_client, monkeypatch) -> None:
+ async def _should_not_fetch(*args: Any, **kwargs: Any) -> ResetCreditsResponse:
+ raise AssertionError("reauth account should not invoke upstream fetch")
+
+ monkeypatch.setattr(reset_credits_api, "fetch_reset_credits", _should_not_fetch)
+
+ account_id = await _import_test_account(
+ async_client,
+ email="reset-reauth@example.com",
+ account_id="acc_reset_reauth",
+ )
+
+ async with SessionLocal() as session:
+ from sqlalchemy import update
+
+ from app.db.models import Account, AccountStatus
+
+ await session.execute(
+ update(Account).where(Account.id == account_id).values(status=AccountStatus.REAUTH_REQUIRED)
+ )
+ await session.commit()
+
+ response = await async_client.post(f"/api/accounts/{account_id}/rate-limit-reset-credits/consume")
+ assert response.status_code == 409
+ assert response.json()["error"]["code"] == "account_not_reset_credit_applicable"
+
+
+@pytest.mark.asyncio
+async def test_get_populates_reset_credits_on_cache_miss(async_client, monkeypatch) -> None:
+ account_id = await _import_test_account(
+ async_client,
+ email="reset-get@example.com",
+ account_id="acc_reset_get",
+ )
+
+ async def _fake_fetch(access_token: str, chatgpt_account_id: str | None, **kwargs: Any) -> ResetCreditsResponse:
+ return _upstream_response(
+ [
+ ResetCreditItem.model_validate(
+ {
+ "id": "credit-get",
+ "status": "available",
+ "expires_at": "2026-08-01T00:00:00Z",
+ "title": "Thanks for using Codex!",
+ }
+ )
+ ]
+ )
+
+ monkeypatch.setattr(reset_credits_api, "fetch_reset_credits", _fake_fetch)
+
+ response = await async_client.get(f"/api/accounts/{account_id}/rate-limit-reset-credits")
+ assert response.status_code == 200, response.text
+ body = response.json()
+ assert body["availableCount"] == 1
+ assert body["credits"][0]["id"] == "credit-get"
+
diff --git a/tests/unit/test_rate_limit_reset_credits_api.py b/tests/unit/test_rate_limit_reset_credits_api.py
index 27dcb53be..7d06211d3 100644
--- a/tests/unit/test_rate_limit_reset_credits_api.py
+++ b/tests/unit/test_rate_limit_reset_credits_api.py
@@ -8,11 +8,14 @@
import pytest
from app.core.auth.dependencies import require_dashboard_write_access
+from app.core.auth.refresh import RefreshError
from app.core.clients.rate_limit_reset_credits import (
ConsumeResetCreditError,
ConsumeResetCreditResponse,
RateLimitResetCreditsSnapshot,
+ ResetCreditFetchError,
ResetCreditItem,
+ ResetCreditsResponse,
)
from app.core.crypto import TokenEncryptor
from app.core.exceptions import (
@@ -26,8 +29,11 @@
from app.modules.rate_limit_reset_credits import api as reset_credits_api
from app.modules.rate_limit_reset_credits.api import (
ConsumeResetCreditResponseSchema,
+ _assert_account_can_redeem_reset_credit,
+ _build_refresh_usage_callback,
_redeem_soonest_reset_credit,
_select_soonest_available_credit,
+ _select_soonest_available_credit_from_response,
consume_rate_limit_reset_credit,
get_rate_limit_reset_credits,
)
@@ -59,18 +65,6 @@ def _account(account_id: str = "acc_1") -> Account:
)
-def _account_with_state(
- account_id: str,
- *,
- status: AccountStatus = AccountStatus.ACTIVE,
- chatgpt_account_id: str | None = "workspace-1",
-) -> Account:
- account = _account(account_id)
- account.status = status
- account.chatgpt_account_id = chatgpt_account_id
- return account
-
-
def _credit(
credit_id: str,
*,
@@ -80,6 +74,18 @@ def _credit(
return ResetCreditItem.model_validate({"id": credit_id, "status": status, "expires_at": expires_at})
+def _response(credits: list[ResetCreditItem], available_count: int | None = None) -> ResetCreditsResponse:
+ count = available_count if available_count is not None else len(credits)
+ return ResetCreditsResponse(credits=credits, available_count=count)
+
+
+def _static_fetch_fn(response: ResetCreditsResponse):
+ async def fetch_fn(*args: Any, **kwargs: Any) -> ResetCreditsResponse:
+ return response
+
+ return fetch_fn
+
+
def _snapshot(credits: list[ResetCreditItem], available_count: int | None = None) -> RateLimitResetCreditsSnapshot:
expiries = [
credit.expires_at for credit in credits if credit.status == "available" and credit.expires_at is not None
@@ -97,12 +103,42 @@ def _snapshot(credits: list[ResetCreditItem], available_count: int | None = None
@pytest.mark.asyncio
async def test_get_returns_null_when_no_snapshot_cached(monkeypatch: pytest.MonkeyPatch) -> None:
store = RateLimitResetCreditsStore()
- # Point the module-level singleton accessor at an empty store for isolation.
monkeypatch.setattr(reset_credits_api, "get_rate_limit_reset_credits_store", lambda: store)
- response = await get_rate_limit_reset_credits("acc_missing")
+
+ class _Repo:
+ async def get_by_id(self, account_id: str) -> Account | None:
+ return None
+
+ fake_context = SimpleNamespace(repository=_Repo())
+ response = await get_rate_limit_reset_credits("acc_missing", context=cast(Any, fake_context))
assert response is None
+@pytest.mark.asyncio
+async def test_get_populates_cache_on_miss_for_active_account(monkeypatch: pytest.MonkeyPatch) -> None:
+ store = RateLimitResetCreditsStore()
+ monkeypatch.setattr(reset_credits_api, "get_rate_limit_reset_credits_store", lambda: store)
+
+ async def _refresh(account, **kwargs: Any) -> None:
+ await store.set("acc_1", _snapshot([_credit("live")], available_count=1))
+
+ monkeypatch.setattr(reset_credits_api, "_refresh_account_reset_credits", _refresh)
+
+ class _Repo:
+ async def get_by_id(self, account_id: str) -> Account | None:
+ return _account(account_id)
+
+ fake_context = SimpleNamespace(
+ repository=_Repo(),
+ service=SimpleNamespace(_auth_manager=None),
+ )
+ response = await get_rate_limit_reset_credits("acc_1", context=cast(Any, fake_context))
+
+ assert response is not None
+ assert response.available_count == 1
+ assert response.credits[0].id == "live"
+
+
@pytest.mark.asyncio
async def test_get_returns_cached_snapshot_shape(monkeypatch: pytest.MonkeyPatch) -> None:
store = RateLimitResetCreditsStore()
@@ -111,7 +147,13 @@ async def test_get_returns_cached_snapshot_shape(monkeypatch: pytest.MonkeyPatch
_snapshot([_credit("c1"), _credit("c2", expires_at="2026-06-20T00:00:00Z")], available_count=2),
)
monkeypatch.setattr(reset_credits_api, "get_rate_limit_reset_credits_store", lambda: store)
- response = await get_rate_limit_reset_credits("acc_1")
+
+ class _Repo:
+ async def get_by_id(self, account_id: str) -> Account | None:
+ return _account(account_id)
+
+ fake_context = SimpleNamespace(repository=_Repo())
+ response = await get_rate_limit_reset_credits("acc_1", context=cast(Any, fake_context))
assert response is not None
assert response.available_count == 2
@@ -119,23 +161,80 @@ async def test_get_returns_cached_snapshot_shape(monkeypatch: pytest.MonkeyPatch
assert {credit.id for credit in response.credits} == {"c1", "c2"}
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "status",
+ [AccountStatus.PAUSED, AccountStatus.REAUTH_REQUIRED, AccountStatus.DEACTIVATED],
+)
+async def test_get_invalidates_cached_snapshot_for_ineligible_status(
+ status: AccountStatus,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ store = RateLimitResetCreditsStore()
+ await store.set("acc_1", _snapshot([_credit("stale")], available_count=1))
+ monkeypatch.setattr(reset_credits_api, "get_rate_limit_reset_credits_store", lambda: store)
+
+ async def _should_not_refresh(*args: Any, **kwargs: Any) -> None:
+ raise AssertionError("ineligible account should not refresh reset credits")
+
+ monkeypatch.setattr(reset_credits_api, "_refresh_account_reset_credits", _should_not_refresh)
+
+ class _Repo:
+ async def get_by_id(self, account_id: str) -> Account | None:
+ account = _account(account_id)
+ account.status = status
+ return account
+
+ fake_context = SimpleNamespace(repository=_Repo())
+
+ response = await get_rate_limit_reset_credits("acc_1", context=cast(Any, fake_context))
+
+ assert response is None
+ assert store.get("acc_1") is None
+
+
+@pytest.mark.asyncio
+async def test_get_invalidates_cached_snapshot_without_chatgpt_account_id(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ store = RateLimitResetCreditsStore()
+ await store.set("acc_1", _snapshot([_credit("stale")], available_count=1))
+ monkeypatch.setattr(reset_credits_api, "get_rate_limit_reset_credits_store", lambda: store)
+
+ class _Repo:
+ async def get_by_id(self, account_id: str) -> Account | None:
+ account = _account(account_id)
+ account.chatgpt_account_id = None
+ return account
+
+ fake_context = SimpleNamespace(repository=_Repo())
+
+ response = await get_rate_limit_reset_credits("acc_1", context=cast(Any, fake_context))
+
+ assert response is None
+ assert store.get("acc_1") is None
+
+
# --- soonest-available selection helper ---
def test_select_soonest_available_credit_picks_smallest_expires_at() -> None:
- snapshot = _snapshot(
- [
- _credit("late", expires_at="2026-07-10T00:00:00Z"),
- _credit("soon", expires_at="2026-06-20T00:00:00Z"),
- _credit("used", status="redeemed", expires_at="2026-06-01T00:00:00Z"),
- ]
- )
+ credits = [
+ _credit("late", expires_at="2026-07-10T00:00:00Z"),
+ _credit("soon", expires_at="2026-06-20T00:00:00Z"),
+ _credit("used", status="redeemed", expires_at="2026-06-01T00:00:00Z"),
+ ]
+ snapshot = _snapshot(credits)
selected = _select_soonest_available_credit(snapshot)
assert selected is not None
assert selected.id == "soon"
+ response_selected = _select_soonest_available_credit_from_response(_response(credits))
+ assert response_selected is not None
+ assert response_selected.id == "soon"
+
def test_select_soonest_available_credit_returns_none_when_no_snapshot() -> None:
assert _select_soonest_available_credit(None) is None
@@ -157,31 +256,39 @@ def test_select_soonest_available_credit_returns_none_when_none_available() -> N
@pytest.mark.asyncio
async def test_redeem_returns_409_when_no_available_credit() -> None:
store = RateLimitResetCreditsStore()
- await store.set("acc_1", _snapshot([_credit("c1", status="redeemed")]))
with pytest.raises(DashboardConflictError) as excinfo:
await _redeem_soonest_reset_credit(
account=_account(),
store=store,
encryptor=StubEncryptor(),
+ fetch_fn=_static_fetch_fn(_response([_credit("c1", status="redeemed")])),
consume_fn=_raise_not_called, # type: ignore[arg-type]
)
assert excinfo.value.code == "no_available_reset_credit"
+ cached = store.get("acc_1")
+ assert cached is not None
+ assert cached.available_count == 1
+ assert cached.credits[0].status == "redeemed"
@pytest.mark.asyncio
async def test_redeem_returns_409_when_cached_count_is_zero() -> None:
store = RateLimitResetCreditsStore()
- await store.set("acc_1", _snapshot([_credit("cached_available")], available_count=0))
with pytest.raises(DashboardConflictError) as excinfo:
await _redeem_soonest_reset_credit(
account=_account(),
store=store,
encryptor=StubEncryptor(),
+ fetch_fn=_static_fetch_fn(_response([_credit("cached_available")], available_count=0)),
consume_fn=_raise_not_called, # type: ignore[arg-type]
)
assert excinfo.value.code == "no_available_reset_credit"
+ cached = store.get("acc_1")
+ assert cached is not None
+ assert cached.available_count == 0
+ assert cached.credits[0].status == "available"
@pytest.mark.asyncio
@@ -192,8 +299,34 @@ async def test_redeem_returns_409_when_snapshot_missing() -> None:
account=_account(),
store=store,
encryptor=StubEncryptor(),
+ fetch_fn=_static_fetch_fn(_response([], available_count=0)),
consume_fn=_raise_not_called, # type: ignore[arg-type]
)
+ cached = store.get("acc_1")
+ assert cached is not None
+ assert cached.available_count == 0
+ assert cached.credits == []
+
+
+@pytest.mark.asyncio
+async def test_redeem_replaces_stale_cached_snapshot_when_fresh_fetch_has_no_available_credit() -> None:
+ store = RateLimitResetCreditsStore()
+ await store.set("acc_1", _snapshot([_credit("stale")], available_count=1))
+
+ with pytest.raises(DashboardConflictError) as excinfo:
+ await _redeem_soonest_reset_credit(
+ account=_account(),
+ store=store,
+ encryptor=StubEncryptor(),
+ fetch_fn=_static_fetch_fn(_response([], available_count=0)),
+ consume_fn=_raise_not_called, # type: ignore[arg-type]
+ )
+
+ assert excinfo.value.code == "no_available_reset_credit"
+ cached = store.get("acc_1")
+ assert cached is not None
+ assert cached.available_count == 0
+ assert cached.credits == []
@pytest.mark.asyncio
@@ -210,24 +343,15 @@ async def test_redeem_selects_soonest_calls_upstream_and_invalidates_cache() ->
)
captured: dict[str, Any] = {}
+ refreshed: list[str] = []
async def consume_fn(
access_token: str,
account_id: str | None,
credit_id: str,
- *,
- route: object | None = None,
- allow_direct_egress: bool = False,
+ **kwargs: Any,
) -> ConsumeResetCreditResponse:
- captured.update(
- {
- "access_token": access_token,
- "account_id": account_id,
- "credit_id": credit_id,
- "route": route,
- "allow_direct_egress": allow_direct_egress,
- }
- )
+ captured.update({"access_token": access_token, "account_id": account_id, "credit_id": credit_id})
return ConsumeResetCreditResponse.model_validate(
{
"code": "reset",
@@ -236,11 +360,23 @@ async def consume_fn(
}
)
+ async def refresh_usage(account: Account) -> None:
+ refreshed.append(account.id)
+
result = await _redeem_soonest_reset_credit(
account=_account(),
store=store,
encryptor=StubEncryptor(),
+ fetch_fn=_static_fetch_fn(
+ _response(
+ [
+ _credit("late", expires_at="2026-07-10T00:00:00Z"),
+ _credit("soon", expires_at="2026-06-20T00:00:00Z"),
+ ]
+ )
+ ),
consume_fn=consume_fn,
+ refresh_usage=refresh_usage,
)
# The soonest-expiring credit id was forwarded with the decrypted token + workspace id.
@@ -248,24 +384,137 @@ async def consume_fn(
"access_token": "decrypted-access-token",
"account_id": "workspace-1",
"credit_id": "soon",
- "route": None,
- "allow_direct_egress": True,
}
# Successful redemption invalidates the in-memory snapshot so the next
# dashboard refresh repulls upstream state instead of serving a local edit.
assert store.get("acc_1") is None
- # Response shape matches the documented {code, windows_reset, redeemed_at}.
- assert isinstance(result, ConsumeResetCreditResponseSchema)
- assert result.code == "reset"
- assert result.windows_reset == 1
- assert result.redeemed_at is not None
- assert result.redeemed_at.year == 2026
+ assert result.available_count_before == 2
+ assert result.available_count_after == 1
+ assert isinstance(result.response, ConsumeResetCreditResponseSchema)
+ assert result.response.code == "reset"
+ assert result.response.windows_reset == 1
+ assert result.response.redeemed_at is not None
+ assert result.response.redeemed_at.year == 2026
+ assert refreshed == ["acc_1"]
+
+
+@pytest.mark.asyncio
+async def test_redeem_restores_snapshot_when_usage_refresh_fails() -> None:
+ store = RateLimitResetCreditsStore()
+ fetch_calls = 0
+
+ async def fetch_fn(*args: Any, **kwargs: Any) -> ResetCreditsResponse:
+ nonlocal fetch_calls
+ fetch_calls += 1
+ if fetch_calls == 1:
+ return _response([_credit("only")], available_count=1)
+ return _response([], available_count=0)
+
+ async def consume_fn(
+ access_token: str,
+ account_id: str | None,
+ credit_id: str,
+ **kwargs: Any,
+ ) -> ConsumeResetCreditResponse:
+ return ConsumeResetCreditResponse.model_validate(
+ {
+ "code": "reset",
+ "credit": {"id": credit_id, "status": "redeemed", "redeemed_at": "2026-06-13T13:12:31Z"},
+ "windows_reset": 1,
+ }
+ )
+
+ async def refresh_usage(account: Account) -> None:
+ raise RuntimeError("usage refresh failed")
+
+ await _redeem_soonest_reset_credit(
+ account=_account(),
+ store=store,
+ encryptor=StubEncryptor(),
+ fetch_fn=fetch_fn,
+ consume_fn=consume_fn,
+ refresh_usage=refresh_usage,
+ )
+
+ restored = store.get("acc_1")
+ assert fetch_calls == 2
+ assert restored is not None
+ assert restored.available_count == 0
+
+
+@pytest.mark.asyncio
+async def test_redeem_restores_snapshot_when_force_refresh_returns_false(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ store = RateLimitResetCreditsStore()
+ fetch_calls = 0
+
+ async def fetch_fn(*args: Any, **kwargs: Any) -> ResetCreditsResponse:
+ nonlocal fetch_calls
+ fetch_calls += 1
+ if fetch_calls == 1:
+ return _response([_credit("only")], available_count=1)
+ return _response([], available_count=0)
+
+ async def consume_fn(
+ access_token: str,
+ account_id: str | None,
+ credit_id: str,
+ **kwargs: Any,
+ ) -> ConsumeResetCreditResponse:
+ return ConsumeResetCreditResponse.model_validate(
+ {
+ "code": "reset",
+ "credit": {"id": credit_id, "status": "redeemed", "redeemed_at": "2026-06-13T13:12:31Z"},
+ "windows_reset": 1,
+ }
+ )
+
+ class _UsageUpdater:
+ async def force_refresh(self, account: Account) -> bool:
+ assert account.id == "acc_1"
+ return False
+
+ class _SelectionCache:
+ def __init__(self) -> None:
+ self.invalidated = 0
+
+ def invalidate(self) -> None:
+ self.invalidated += 1
+
+ selection_cache = _SelectionCache()
+ monkeypatch.setattr(reset_credits_api, "get_account_selection_cache", lambda: selection_cache)
+ refresh_usage = _build_refresh_usage_callback(
+ cast(Any, SimpleNamespace(service=SimpleNamespace(_usage_updater=_UsageUpdater())))
+ )
+
+ await _redeem_soonest_reset_credit(
+ account=_account(),
+ store=store,
+ encryptor=StubEncryptor(),
+ fetch_fn=fetch_fn,
+ consume_fn=consume_fn,
+ refresh_usage=refresh_usage,
+ )
+
+ restored = store.get("acc_1")
+ assert fetch_calls == 2
+ assert restored is not None
+ assert restored.available_count == 0
+ assert selection_cache.invalidated == 0
@pytest.mark.asyncio
async def test_redeem_serializes_requests_per_account() -> None:
store = RateLimitResetCreditsStore()
- await store.set("acc_1", _snapshot([_credit("only")], available_count=1))
+ fetch_calls = 0
+
+ async def fetch_fn(*args: Any, **kwargs: Any) -> ResetCreditsResponse:
+ nonlocal fetch_calls
+ fetch_calls += 1
+ if fetch_calls == 1:
+ return _response([_credit("only")], available_count=1)
+ return _response([], available_count=0)
started = asyncio.Event()
release = asyncio.Event()
@@ -275,12 +524,8 @@ async def consume_fn(
access_token: str,
account_id: str | None,
credit_id: str,
- *,
- route: object | None = None,
- allow_direct_egress: bool = False,
+ **kwargs: Any,
) -> ConsumeResetCreditResponse:
- assert route is None
- assert allow_direct_egress is True
consume_calls.append(credit_id)
started.set()
await release.wait()
@@ -297,6 +542,7 @@ async def consume_fn(
account=_account(),
store=store,
encryptor=StubEncryptor(),
+ fetch_fn=fetch_fn,
consume_fn=consume_fn,
)
)
@@ -307,6 +553,7 @@ async def consume_fn(
account=_account(),
store=store,
encryptor=StubEncryptor(),
+ fetch_fn=fetch_fn,
consume_fn=consume_fn,
)
)
@@ -323,6 +570,40 @@ async def consume_fn(
assert consume_calls == ["only"]
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("status_code", "expected_exception"),
+ [
+ (401, DashboardAuthError),
+ (403, DashboardPermissionError),
+ (409, DashboardConflictError),
+ (503, DashboardServiceUnavailableError),
+ (0, DashboardServiceUnavailableError),
+ ],
+)
+async def test_redeem_translates_upstream_fetch_failures(
+ status_code: int,
+ expected_exception: type[Exception],
+) -> None:
+ store = RateLimitResetCreditsStore()
+
+ async def fetch_fn(*args: Any, **kwargs: Any) -> ResetCreditsResponse:
+ raise ResetCreditFetchError(status_code, f"upstream fetch failed {status_code}", code=f"fetch_{status_code}")
+
+ with pytest.raises(expected_exception) as excinfo:
+ await _redeem_soonest_reset_credit(
+ account=_account(),
+ store=store,
+ encryptor=StubEncryptor(),
+ fetch_fn=fetch_fn,
+ consume_fn=_raise_not_called, # type: ignore[arg-type]
+ )
+
+ assert str(excinfo.value) == f"upstream fetch failed {status_code}"
+ assert getattr(excinfo.value, "code", None) == f"fetch_{status_code}"
+ assert store.get("acc_1") is None
+
+
@pytest.mark.asyncio
@pytest.mark.parametrize(
("status_code", "expected_exception"),
@@ -339,18 +620,13 @@ async def test_redeem_translates_upstream_consume_failures(
expected_exception: type[Exception],
) -> None:
store = RateLimitResetCreditsStore()
- await store.set("acc_1", _snapshot([_credit("only")], available_count=1))
async def consume_fn(
access_token: str,
account_id: str | None,
credit_id: str,
- *,
- route: object | None = None,
- allow_direct_egress: bool = False,
+ **kwargs: Any,
) -> ConsumeResetCreditResponse:
- assert route is None
- assert allow_direct_egress is True
raise ConsumeResetCreditError(status_code, f"upstream failed {status_code}", code=f"upstream_{status_code}")
with pytest.raises(expected_exception) as excinfo:
@@ -358,12 +634,62 @@ async def consume_fn(
account=_account(),
store=store,
encryptor=StubEncryptor(),
+ fetch_fn=_static_fetch_fn(_response([_credit("only")], available_count=1)),
consume_fn=consume_fn,
)
assert str(excinfo.value) == f"upstream failed {status_code}"
assert getattr(excinfo.value, "code", None) == f"upstream_{status_code}"
- assert store.get("acc_1") is not None
+ assert store.get("acc_1") is None
+
+
+@pytest.mark.asyncio
+async def test_redeem_reports_zero_available_count_after_last_credit() -> None:
+ store = RateLimitResetCreditsStore()
+
+ async def consume_fn(
+ access_token: str,
+ account_id: str | None,
+ credit_id: str,
+ **kwargs: Any,
+ ) -> ConsumeResetCreditResponse:
+ return ConsumeResetCreditResponse.model_validate(
+ {
+ "code": "reset",
+ "credit": {"id": credit_id, "status": "redeemed", "redeemed_at": "2026-06-13T13:12:31Z"},
+ "windows_reset": 1,
+ }
+ )
+
+ result = await _redeem_soonest_reset_credit(
+ account=_account(),
+ store=store,
+ encryptor=StubEncryptor(),
+ fetch_fn=_static_fetch_fn(_response([_credit("only")], available_count=1)),
+ consume_fn=consume_fn,
+ )
+
+ assert result.available_count_after == 0
+
+
+@pytest.mark.parametrize(
+ "status",
+ [AccountStatus.PAUSED, AccountStatus.REAUTH_REQUIRED, AccountStatus.DEACTIVATED],
+)
+def test_assert_account_can_redeem_reset_credit_rejects_non_applicable_statuses(status: AccountStatus) -> None:
+ account = _account()
+ account.status = status
+ with pytest.raises(DashboardConflictError) as excinfo:
+ _assert_account_can_redeem_reset_credit(account)
+ assert excinfo.value.code == "account_not_reset_credit_applicable"
+
+
+def test_assert_account_can_redeem_reset_credit_rejects_missing_chatgpt_account_id() -> None:
+ account = _account()
+ account.chatgpt_account_id = None
+ with pytest.raises(DashboardConflictError) as excinfo:
+ _assert_account_can_redeem_reset_credit(account)
+ assert excinfo.value.code == "account_not_reset_credit_applicable"
# --- POST consume: handler-level 404 when account missing ---
@@ -377,8 +703,11 @@ async def get_by_id(self, account_id: str) -> Account | None:
fake_context = SimpleNamespace(repository=_Repo())
+ fake_request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
+
with pytest.raises(DashboardNotFoundError):
await consume_rate_limit_reset_credit(
+ fake_request,
account_id="missing",
_write_access=None,
context=cast(Any, fake_context),
@@ -386,59 +715,129 @@ async def get_by_id(self, account_id: str) -> Account | None:
@pytest.mark.asyncio
-@pytest.mark.parametrize("status", [AccountStatus.PAUSED, AccountStatus.DEACTIVATED])
-async def test_consume_handler_rejects_ineligible_account_status_and_invalidates_snapshot(
+async def test_consume_handler_audits_live_available_count_before_when_cache_missing(
monkeypatch: pytest.MonkeyPatch,
- status: AccountStatus,
) -> None:
store = RateLimitResetCreditsStore()
- await store.set("acc_disabled", _snapshot([_credit("stale")], available_count=1))
+ monkeypatch.setattr(reset_credits_api, "get_rate_limit_reset_credits_store", lambda: store)
class _Repo:
async def get_by_id(self, account_id: str) -> Account | None:
- return _account_with_state(account_id, status=status)
+ return _account(account_id)
- async def _route_not_called(*args: Any, **kwargs: Any) -> object:
- raise AssertionError("ineligible accounts must be rejected before route resolution")
+ logged: dict[str, Any] = {}
- monkeypatch.setattr(reset_credits_api, "get_rate_limit_reset_credits_store", lambda: store)
- monkeypatch.setattr(reset_credits_api, "resolve_upstream_route", _route_not_called)
- fake_context = SimpleNamespace(repository=_Repo())
+ def _log_async(event: str, **kwargs: Any) -> None:
+ logged["event"] = event
+ logged.update(kwargs)
+
+ async def _redeem(**kwargs: Any) -> Any:
+ return reset_credits_api._RedeemResetCreditOutcome(
+ response=ConsumeResetCreditResponseSchema(code="reset", windows_reset=1, redeemed_at=None),
+ available_count_before=3,
+ available_count_after=2,
+ )
+
+ monkeypatch.setattr(reset_credits_api, "_redeem_soonest_reset_credit", _redeem)
+ monkeypatch.setattr(reset_credits_api.AuditService, "log_async", _log_async)
+
+ fake_context = SimpleNamespace(
+ repository=_Repo(),
+ service=SimpleNamespace(_auth_manager=None, _usage_updater=None),
+ )
+ fake_request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
+
+ response = await consume_rate_limit_reset_credit(
+ fake_request,
+ account_id="acc_1",
+ _write_access=None,
+ context=cast(Any, fake_context),
+ )
+
+ assert response.code == "reset"
+ assert logged["event"] == "account_rate_limit_reset_credit_consumed"
+ assert logged["details"]["available_reset_credits_before"] == 3
+ assert logged["details"]["available_reset_credits_after"] == 2
+
+
+@pytest.mark.asyncio
+async def test_consume_handler_invalidates_selection_cache_on_permanent_refresh_error(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ class _Repo:
+ async def get_by_id(self, account_id: str) -> Account | None:
+ return _account(account_id)
+
+ class _SelectionCache:
+ def __init__(self) -> None:
+ self.invalidated = 0
+
+ def invalidate(self) -> None:
+ self.invalidated += 1
+
+ async def _redeem(**kwargs: Any) -> Any:
+ raise RefreshError("invalid_grant", "refresh token expired", True)
+
+ selection_cache = _SelectionCache()
+ monkeypatch.setattr(reset_credits_api, "_redeem_soonest_reset_credit", _redeem)
+ monkeypatch.setattr(reset_credits_api, "get_account_selection_cache", lambda: selection_cache)
+
+ fake_context = SimpleNamespace(
+ repository=_Repo(),
+ service=SimpleNamespace(_auth_manager=None, _usage_updater=None),
+ )
+ fake_request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
with pytest.raises(DashboardConflictError) as excinfo:
await consume_rate_limit_reset_credit(
- account_id="acc_disabled",
+ fake_request,
+ account_id="acc_1",
_write_access=None,
context=cast(Any, fake_context),
)
- assert excinfo.value.code == "reset_credit_account_ineligible"
- assert store.get("acc_disabled") is None
+ assert excinfo.value.code == "account_reset_credit_refresh_failed"
+ assert selection_cache.invalidated == 1
@pytest.mark.asyncio
-async def test_consume_handler_rejects_account_without_chatgpt_account_id_and_invalidates_snapshot(
+async def test_consume_handler_keeps_selection_cache_on_transient_refresh_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- store = RateLimitResetCreditsStore()
- await store.set("acc_no_workspace", _snapshot([_credit("stale")], available_count=1))
-
class _Repo:
async def get_by_id(self, account_id: str) -> Account | None:
- return _account_with_state(account_id, chatgpt_account_id=None)
+ return _account(account_id)
- monkeypatch.setattr(reset_credits_api, "get_rate_limit_reset_credits_store", lambda: store)
- fake_context = SimpleNamespace(repository=_Repo())
+ class _SelectionCache:
+ def __init__(self) -> None:
+ self.invalidated = 0
+
+ def invalidate(self) -> None:
+ self.invalidated += 1
+
+ async def _redeem(**kwargs: Any) -> Any:
+ raise RefreshError("transport_error", "timeout", False)
+
+ selection_cache = _SelectionCache()
+ monkeypatch.setattr(reset_credits_api, "_redeem_soonest_reset_credit", _redeem)
+ monkeypatch.setattr(reset_credits_api, "get_account_selection_cache", lambda: selection_cache)
+
+ fake_context = SimpleNamespace(
+ repository=_Repo(),
+ service=SimpleNamespace(_auth_manager=None, _usage_updater=None),
+ )
+ fake_request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
with pytest.raises(DashboardConflictError) as excinfo:
await consume_rate_limit_reset_credit(
- account_id="acc_no_workspace",
+ fake_request,
+ account_id="acc_1",
_write_access=None,
context=cast(Any, fake_context),
)
- assert excinfo.value.code == "reset_credit_account_ineligible"
- assert store.get("acc_no_workspace") is None
+ assert excinfo.value.code == "account_reset_credit_refresh_failed"
+ assert selection_cache.invalidated == 0
# --- POST consume: write-access gating refuses guests (full ASGI path) ---
diff --git a/tests/unit/test_rate_limit_reset_credits_client.py b/tests/unit/test_rate_limit_reset_credits_client.py
index 9a7342701..36a9078b0 100644
--- a/tests/unit/test_rate_limit_reset_credits_client.py
+++ b/tests/unit/test_rate_limit_reset_credits_client.py
@@ -6,6 +6,7 @@
import pytest
+from app.core.config.settings import get_settings
from app.core.clients.headers import build_chatgpt_auth_headers
from app.core.clients.rate_limit_reset_credits import (
ConsumeResetCreditError,
@@ -17,7 +18,6 @@
fetch_reset_credits,
)
from app.core.clients.usage import _usage_headers
-from app.core.upstream_proxy import ResolvedProxyEndpoint, ResolvedUpstreamRoute
pytestmark = pytest.mark.unit
@@ -113,24 +113,6 @@ def request(
)
-class StubCodexClient:
- def __init__(self, response: object) -> None:
- self.response = response
- self.calls: list[dict[str, Any]] = []
-
- async def request(self, method: str, url: str, **kwargs: Any) -> object:
- self.calls.append({"method": method, "url": url, **kwargs})
- return self.response
-
-
-def _route() -> ResolvedUpstreamRoute:
- return ResolvedUpstreamRoute(
- mode="account_bound",
- pool_id="pool_1",
- endpoint=ResolvedProxyEndpoint(id="endpoint_1", scheme="http", host="proxy.local", port=8080),
- )
-
-
def _list_payload() -> dict:
return {
"credits": [
@@ -180,29 +162,6 @@ async def test_fetch_reset_credits_sends_bearer_and_account_id_headers() -> None
assert state.headers["chatgpt-account-id"] == "acc_workspace"
-@pytest.mark.asyncio
-async def test_fetch_reset_credits_uses_resolved_route_when_provided() -> None:
- route = _route()
- codex_client = StubCodexClient(StubResponse(200, _list_payload(), ""))
-
- data = await fetch_reset_credits(
- "access-token",
- "acc_workspace",
- base_url="http://upstream.test/backend-api",
- timeout_seconds=2.0,
- max_retries=0,
- route=route,
- codex_client=cast(Any, codex_client),
- )
-
- assert data.available_count == 1
- assert len(codex_client.calls) == 1
- call = codex_client.calls[0]
- assert call["method"] == "GET"
- assert call["route"] is route
- assert call["url"] == "http://upstream.test/backend-api/wham/rate-limit-reset-credits"
-
-
@pytest.mark.asyncio
async def test_fetch_reset_credits_skips_account_id_header_for_email_and_local_prefixes() -> None:
for account_id in ("email_user@example.com", "local_abcd"):
@@ -372,39 +331,6 @@ async def test_consume_reset_credit_sends_credit_id_and_uuid_redeem_request_id()
assert str(parsed) == redeem_request_id
-@pytest.mark.asyncio
-async def test_consume_reset_credit_uses_resolved_route_when_provided() -> None:
- route = _route()
- codex_client = StubCodexClient(
- StubResponse(
- 200,
- {
- "code": "reset",
- "credit": {"id": "RateLimitResetCredit_test", "status": "redeemed"},
- "windows_reset": 1,
- },
- "",
- )
- )
-
- result = await consume_reset_credit(
- "access-token",
- "acc_workspace",
- "RateLimitResetCredit_test",
- base_url="http://upstream.test/backend-api",
- timeout_seconds=2.0,
- route=route,
- codex_client=cast(Any, codex_client),
- )
-
- assert result.windows_reset == 1
- assert len(codex_client.calls) == 1
- call = codex_client.calls[0]
- assert call["method"] == "POST"
- assert call["route"] is route
- assert call["json"]["credit_id"] == "RateLimitResetCredit_test"
-
-
@pytest.mark.asyncio
async def test_consume_reset_credit_generates_fresh_redeem_request_id_each_call() -> None:
ids: list[str] = []
@@ -429,6 +355,38 @@ async def test_consume_reset_credit_generates_fresh_redeem_request_id_each_call(
assert ids[0] != ids[1]
+@pytest.mark.asyncio
+async def test_consume_reset_credit_does_not_retry_when_max_retries_omitted(monkeypatch: pytest.MonkeyPatch) -> None:
+ settings = get_settings()
+ original_retries = settings.usage_fetch_max_retries
+ monkeypatch.setattr(settings, "usage_fetch_max_retries", 2)
+
+ state = ClientState()
+ client = StubRetryClient(
+ [
+ StubResponse(503, {"error": {"code": "temporarily_unavailable", "message": "retry later"}}, ""),
+ StubResponse(200, {"code": "reset", "credit": {"id": "x"}, "windows_reset": 1}, ""),
+ ],
+ state,
+ )
+
+ with pytest.raises(ConsumeResetCreditError) as excinfo:
+ await consume_reset_credit(
+ "access-token",
+ None,
+ "RateLimitResetCredit_test",
+ base_url="http://upstream.test/backend-api",
+ timeout_seconds=2.0,
+ client=cast(Any, client),
+ allow_direct_egress=True,
+ )
+
+ assert excinfo.value.status_code == 503
+ assert excinfo.value.code == "temporarily_unavailable"
+ assert state.calls == 1
+ monkeypatch.setattr(settings, "usage_fetch_max_retries", original_retries)
+
+
@pytest.mark.asyncio
async def test_consume_reset_credit_raises_on_non_200() -> None:
state = ClientState()
diff --git a/tests/unit/test_rate_limit_reset_credits_mapper.py b/tests/unit/test_rate_limit_reset_credits_mapper.py
index 15225d34e..8503ceafb 100644
--- a/tests/unit/test_rate_limit_reset_credits_mapper.py
+++ b/tests/unit/test_rate_limit_reset_credits_mapper.py
@@ -76,7 +76,10 @@ def test_account_summary_returns_zero_and_null_when_no_snapshot() -> None:
assert summary.reset_credit_nearest_expires_at is None
-@pytest.mark.parametrize("status", [AccountStatus.PAUSED, AccountStatus.DEACTIVATED])
+@pytest.mark.parametrize(
+ "status",
+ [AccountStatus.PAUSED, AccountStatus.REAUTH_REQUIRED, AccountStatus.DEACTIVATED],
+)
def test_account_summary_suppresses_cached_reset_credits_for_ineligible_status(status: AccountStatus) -> None:
store = RateLimitResetCreditsStore()
store._snapshots["acc_ineligible"] = RateLimitResetCreditsSnapshot( # type: ignore[attr-defined]
diff --git a/tests/unit/test_rate_limit_reset_credits_scheduler.py b/tests/unit/test_rate_limit_reset_credits_scheduler.py
index cb3867810..0da693a51 100644
--- a/tests/unit/test_rate_limit_reset_credits_scheduler.py
+++ b/tests/unit/test_rate_limit_reset_credits_scheduler.py
@@ -64,8 +64,12 @@ def _response(available_count: int = 1) -> ResetCreditsResponse:
@pytest.mark.asyncio
-async def test_refresh_skips_paused_and_deactivated_accounts() -> None:
+async def test_refresh_skips_paused_reauth_and_deactivated_accounts() -> None:
store = RateLimitResetCreditsStore()
+ stale = RateLimitResetCreditsSnapshot(available_count=5)
+ await store.set("acc_paused", stale)
+ await store.set("acc_reauth", stale)
+ await store.set("acc_deactivated", stale)
fetched: list[str] = []
async def fetch_fn(access_token: str, account_id: str | None, **kwargs: Any) -> ResetCreditsResponse:
@@ -74,6 +78,7 @@ async def fetch_fn(access_token: str, account_id: str | None, **kwargs: Any) ->
accounts = [
_make_account("acc_paused", status=AccountStatus.PAUSED),
+ _make_account("acc_reauth", status=AccountStatus.REAUTH_REQUIRED),
_make_account("acc_deactivated", status=AccountStatus.DEACTIVATED),
_make_account("acc_active"),
]
@@ -88,39 +93,15 @@ async def fetch_fn(access_token: str, account_id: str | None, **kwargs: Any) ->
# Only the active account was fetched and cached.
assert fetched == ["token-for-acc_active"]
assert store.get("acc_paused") is None
+ assert store.get("acc_reauth") is None
assert store.get("acc_deactivated") is None
assert store.get("acc_active") is not None
-@pytest.mark.asyncio
-async def test_refresh_invalidates_snapshots_for_paused_and_deactivated_accounts() -> None:
- store = RateLimitResetCreditsStore()
- await store.set("acc_paused", RateLimitResetCreditsSnapshot(available_count=1))
- await store.set("acc_deactivated", RateLimitResetCreditsSnapshot(available_count=1))
- fetched: list[str] = []
-
- async def fetch_fn(access_token: str, account_id: str | None, **kwargs: Any) -> ResetCreditsResponse:
- fetched.append(access_token)
- return _response()
-
- await refresh_reset_credits_for_accounts(
- accounts=[
- _make_account("acc_paused", status=AccountStatus.PAUSED),
- _make_account("acc_deactivated", status=AccountStatus.DEACTIVATED),
- ],
- encryptor=StubEncryptor(),
- store=store,
- fetch_fn=fetch_fn,
- )
-
- assert fetched == []
- assert store.get("acc_paused") is None
- assert store.get("acc_deactivated") is None
-
-
@pytest.mark.asyncio
async def test_refresh_skips_account_without_chatgpt_account_id() -> None:
store = RateLimitResetCreditsStore()
+ await store.set("acc_no_workspace", RateLimitResetCreditsSnapshot(available_count=4))
fetched: list[str] = []
async def fetch_fn(access_token: str, account_id: str | None, **kwargs: Any) -> ResetCreditsResponse:
@@ -139,21 +120,33 @@ async def fetch_fn(access_token: str, account_id: str | None, **kwargs: Any) ->
@pytest.mark.asyncio
-async def test_refresh_invalidates_snapshot_for_account_without_chatgpt_account_id() -> None:
+async def test_refresh_401_retains_prior_snapshot_without_status_mutation() -> None:
+ """A 401 on reset-credits must not trigger a token refresh or status write.
+
+ Reset-credits polling owns no account-status derivation; usage refresh owns
+ token refresh and deactivation. A 401 logs and retains the prior cached
+ snapshot with a single fetch attempt and no AuthManager involvement.
+ """
store = RateLimitResetCreditsStore()
- await store.set("acc_no_workspace", RateLimitResetCreditsSnapshot(available_count=1))
+ prior = RateLimitResetCreditsSnapshot(available_count=2)
+ await store.set("acc_401", prior)
+ account = _make_account("acc_401", status=AccountStatus.ACTIVE)
+ fetch_calls = {"count": 0}
async def fetch_fn(access_token: str, account_id: str | None, **kwargs: Any) -> ResetCreditsResponse:
- raise AssertionError("ineligible account must not fetch reset credits")
+ fetch_calls["count"] += 1
+ raise ResetCreditFetchError(401, "unauthorized")
await refresh_reset_credits_for_accounts(
- accounts=[_make_account("acc_no_workspace", chatgpt_account_id=None)],
+ accounts=[account],
encryptor=StubEncryptor(),
store=store,
fetch_fn=fetch_fn,
)
- assert store.get("acc_no_workspace") is None
+ assert fetch_calls["count"] == 1
+ assert store.get("acc_401") is prior
+ assert account.status == AccountStatus.ACTIVE
@pytest.mark.asyncio
@@ -331,7 +324,6 @@ async def _fake_background_session():
monkeypatch.setattr(scheduler_module, "AccountsRepository", lambda session: _FakeRepo())
monkeypatch.setattr(scheduler_module, "TokenEncryptor", lambda: StubEncryptor())
monkeypatch.setattr(scheduler_module, "get_rate_limit_reset_credits_store", lambda: store)
- monkeypatch.setattr(scheduler_module, "_resolve_upstream_route_for_account", lambda account: _async_none())
async def fetch_fn(access_token: str, account_id: str | None, **kwargs: Any) -> ResetCreditsResponse:
captured.append(("fetch", access_token, account_id))
@@ -347,7 +339,3 @@ async def fetch_fn(access_token: str, account_id: str | None, **kwargs: Any) ->
assert snapshot is not None
assert snapshot.available_count == 7
assert account.status == AccountStatus.ACTIVE
-
-
-async def _async_none() -> None:
- return None