@@ -189,21 +202,21 @@ function resetText(window) {
function limitFacts(account) {
const quota = account.quota;
const windows = accountWindows(account);
- const reset = resetCreditsDetail(account, windows);
+ const wait = state.limitsUpdating.get(account.id);
+ const stale = wait && !wait.timedOut ? " stale" : "";
if (!quota)
return [
fact("Rate limits", '
', [
"waiting for the first sync",
]),
- reset?.fallback,
];
const facts = windows.map((window) => {
const severity = limitSeverity(window.percent);
return fact(
escapeHtml(window.named ? window.label : `${window.label} window`),
- `
`,
- [escapeHtml(resetText(window)), reset?.window === window && reset.line],
- `
`
);
});
if (isRateLimitedWithData(quota))
@@ -224,7 +237,6 @@ function limitFacts(account) {
);
else if (!facts.length)
facts.push(fact("Rate limits", '
'));
- if (reset?.fallback) facts.push(reset.fallback);
return facts;
}
@@ -366,9 +378,11 @@ function accountDetailHtml(account) {
account.provider === "codex" || account.provider === "claude";
const button = (label, action, shortcut, cls = "btn btn-ghost btn-sm") =>
`
`
+ const wait = state.limitsUpdating.get(account.id);
+ let synced = account.quota?.fetchedAt
+ ? `
`
: "";
+ if (wait && !wait.timedOut) synced = UPDATING_HTML;
const entering =
state.enteringDetail === `account:${account.id}` ? " entering" : "";
return `
@@ -379,6 +393,7 @@ function accountDetailHtml(account) {
tokensFact(accountUsageForId(account.id)),
accountFact(account),
].join("")}
diff --git a/public/admin/app-accounts.js b/public/admin/app-accounts.js
index 0be9429..7e75e07 100644
--- a/public/admin/app-accounts.js
+++ b/public/admin/app-accounts.js
@@ -33,6 +33,38 @@ import {
switchToTab,
} from "./app-usage.js";
+const RESET_CONFIRM_RELOAD_MS = 6000;
+const RESET_CONFIRM_TIMEOUT_MS = 20_000;
+
+// Ends the wait once a limits read newer than the one shown at the reset
+// arrives.
+function settleLimitsUpdating() {
+ for (const [id, wait] of state.limitsUpdating) {
+ const fetchedAt = state.accountsById.get(id)?.quota?.fetchedAt;
+ if (!state.accountsById.has(id) || (fetchedAt ?? 0) > wait.after)
+ state.limitsUpdating.delete(id);
+ }
+}
+
+// Marks the account's limits as updating until a read taken after
+// `respondedAt` (server time) arrives, re-reading the accounts twice and
+// giving up quietly at the second read.
+function awaitLimitsAfterReset(accountId, respondedAt) {
+ const wait = { after: respondedAt, timedOut: false };
+ state.limitsUpdating.set(accountId, wait);
+ renderAccounts();
+ const reload = () => {
+ loadAccounts().catch(() => undefined);
+ };
+ setTimeout(reload, RESET_CONFIRM_RELOAD_MS);
+ setTimeout(() => {
+ if (state.limitsUpdating.get(accountId) !== wait) return;
+ wait.timedOut = true;
+ renderAccounts();
+ reload();
+ }, RESET_CONFIRM_TIMEOUT_MS);
+}
+
async function loadAccounts() {
const requestSeq = ++state.accountsRequestSeq;
invalidateUsageDetails("account");
@@ -61,8 +93,12 @@ async function loadAccounts() {
claudeResets: account.quota
? account.claudeResets
: state.accountsById.get(account.id)?.claudeResets,
+ codexResets: account.quota
+ ? account.codexResets
+ : state.accountsById.get(account.id)?.codexResets,
}));
state.accountsById = usageMapFromList(state.accounts, "id");
+ settleLimitsUpdating();
pruneUsageDetails("account", state.accountsById);
pruneUsageDetails("daily", state.accountsById);
state.providerStatuses = accountsResult.value.providers || [];
@@ -118,7 +154,7 @@ async function loadMissingQuotas(requestSeq, missingQuotas) {
);
if (!missing) return;
try {
- const { quotas, profiles, claudeResets } = await api(
+ const { quotas, profiles, claudeResets, codexResets } = await api(
"/admin/accounts/quotas"
);
if (requestSeq !== state.accountsRequestSeq) return;
@@ -138,7 +174,12 @@ async function loadMissingQuotas(requestSeq, missingQuotas) {
account.claudeResets = claudeResets[account.id];
changed = true;
}
+ if (codexResets && account.id in codexResets) {
+ account.codexResets = codexResets[account.id];
+ changed = true;
+ }
}
+ settleLimitsUpdating();
if (changed) {
renderAccounts();
if (state.keysLoaded) renderKeys();
@@ -289,16 +330,19 @@ async function redeemResetCreditAction(request, accountId) {
if (!confirmed) return;
let feedback;
+ let respondedAt = Date.now();
try {
- const { result } = await request(
+ const response = await request(
`/admin/accounts/${accountId}/tracking/${account.provider}/reset-credits/consume`,
{ method: "POST", body: JSON.stringify({ resetId: offer.resetId }) }
);
- feedback = resetProviders[account.provider].resultToast(result);
+ feedback = resetProviders[account.provider].resultToast(response.result);
+ respondedAt = response.respondedAt ?? respondedAt;
} catch (error) {
feedback = { message: error.message, type: "error" };
}
if (!request.isCurrent()) return;
+ if (feedback.changed) awaitLimitsAfterReset(accountId, respondedAt);
const refreshed = await loadAccounts();
if (!request.isCurrent()) return;
const { message, type } = resetFeedback(
diff --git a/public/admin/app-claude-resets.js b/public/admin/app-claude-resets.js
index aaeff7b..12c607f 100644
--- a/public/admin/app-claude-resets.js
+++ b/public/admin/app-claude-resets.js
@@ -31,12 +31,10 @@ function expiryText(endsAt) {
}
const UNAVAILABLE_TEXT = {
- requires_limit: "at limit only",
paused: "paused",
- cooldown: "try later",
- not_usable: "unavailable",
- checking: "checking",
- unsupported_limits: "scope not reported",
+ cooldown: "cooling down after a recent reset",
+ not_usable: "unavailable right now",
+ unsupported_limits: "can't confirm what it refills",
};
function claudeResetStatus(resets) {
@@ -63,10 +61,16 @@ function claudeResetStatus(resets) {
};
}
if (!resets.saved || resets.offer) return null;
- const label = UNAVAILABLE_TEXT[resets.unavailable] || "waiting";
+ if (resets.unavailable === "requires_limit") {
+ return {
+ label: "usable once a limit is hit",
+ description:
+ "Claude only uses this reset once you hit a limit it refills.",
+ };
+ }
return {
- label,
- link: label,
+ label: UNAVAILABLE_TEXT[resets.unavailable] || "unavailable right now",
+ link: "Settings",
description:
resets.unavailable === "cooldown"
? "Another reset was just used. Try later."
@@ -91,17 +95,22 @@ function claudeResetOffer(account) {
];
const endsAt = offer?.endsAt ?? resets.nextEndsAt;
const weekly = quotaTimestamp(offer?.weeklyResetsAt);
+ const state = offer
+ ? "now"
+ : resets.unavailable === "requires_limit"
+ ? "at_limit"
+ : "unavailable";
return {
count: resets.saved,
unit: "reset",
noun: "saved reset",
heading: "Limit reset",
action: "Use reset",
- canUse: Boolean(offer),
- atLimitOnly: !offer && resets.unavailable === "requires_limit",
+ state,
+ canUse: state === "now",
+ atLimitOnly: state === "at_limit",
resetId: offer?.grantId,
scope: windowLabels.length ? limitsText(clears) : null,
- windowLabels,
expiresAt: quotaTimestamp(endsAt),
expiryText: expiryText(endsAt),
title: offer?.label,
@@ -128,39 +137,60 @@ function claudeResetOffer(account) {
};
}
+const NO_RESET_SPENT = "No reset was spent.";
+
const CLAIM_RESULT_TOASTS = {
- reset: ["Limits reset", "success"],
already_used: [
- "That reset was already used \u00b7 nothing changed now",
+ "That reset was already used \u00b7 nothing more was spent",
"info",
],
- not_limited: ["Nothing was used \u00b7 no limit needed a reset", "info"],
- cooldown: ["Another reset was just used \u00b7 nothing was used now", "info"],
- ineligible: [
- "This reset isn't available any more \u00b7 nothing was used",
+ not_limited: [
+ `Nothing to reset yet: no limit this reset refills is hit. ${NO_RESET_SPENT}`,
"info",
],
+ cooldown: [
+ `Another reset was used recently \u00b7 try again later. ${NO_RESET_SPENT}`,
+ "info",
+ ],
+ ineligible: [
+ `This reset can't be used on this account any more. ${NO_RESET_SPENT}`,
+ "error",
+ ],
};
+const UNCONFIRMED_REASONS = new Set([
+ "stamp_indeterminate",
+ "reset_unconfirmed",
+]);
+
function claimToast(claim) {
+ if (UNCONFIRMED_REASONS.has(claim.reason)) {
+ return {
+ message:
+ "Reset not confirmed yet \u00b7 the next limits check shows whether it was used",
+ type: "info",
+ };
+ }
if (claim.result === "unavailable") {
return {
message:
- "Couldn't confirm the reset \u00b7 the next limits check will show whether it was used",
+ "Couldn't confirm the reset \u00b7 the next limits check shows whether it was used",
type: "error",
};
}
+ if (claim.result === "reset") {
+ return {
+ message: `Refilled the ${limitsText(claim.cleared)} \u00b7 1 reset used`,
+ type: "success",
+ remaining: claim.resetsLeft,
+ changed: true,
+ };
+ }
const [message, type] = CLAIM_RESULT_TOASTS[claim.result] || [
- "Reset finished",
+ `Claude answered \u201c${claim.result}\u201d \u00b7 the next limits check shows whether a reset was used`,
"info",
];
- return claim.result === "reset"
- ? {
- message: `Refilled the ${limitsText(claim.cleared)}`,
- type,
- remaining: claim.resetsLeft,
- }
- : { message, type };
+ return { message, type };
}
const claudeResets = { offerFor: claudeResetOffer, resultToast: claimToast };
diff --git a/public/admin/app-codex-resets.js b/public/admin/app-codex-resets.js
index 540ab66..9e621ca 100644
--- a/public/admin/app-codex-resets.js
+++ b/public/admin/app-codex-resets.js
@@ -38,7 +38,11 @@ function codexResetOffer(account) {
const next = credits[0] || null;
const expiry = quotaTimestamp(next?.expiresAt);
const windows = accountWindows(account).filter((window) => !window.named);
- const tightest = [...windows].sort((a, b) => b.percent - a.percent)[0];
+ const state = !count
+ ? "unavailable"
+ : account.codexResets?.unavailable === "requires_limit"
+ ? "at_limit"
+ : "now";
return {
count,
next,
@@ -46,7 +50,9 @@ function codexResetOffer(account) {
noun: "reset credit",
heading: "Reset credit",
action: "Use credit",
- canUse: Boolean(count && next),
+ state,
+ canUse: state === "now" && Boolean(next),
+ atLimitOnly: state === "at_limit",
resetId: next?.id,
scope:
next?.resetType === "codex_rate_limits"
@@ -54,25 +60,22 @@ function codexResetOffer(account) {
? `${windows.map((window) => window.label).join(" + ")} windows`
: "Codex rate-limit windows"
: null,
- windowLabels:
- next?.resetType === "codex_rate_limits"
- ? windows.map((window) => window.label)
- : [],
expiresAt: expiry,
expiryText: expiry
? `${count > 1 ? "next expires" : "expires"} ${untilText(expiry)}`
: "expiry not reported",
title: next?.title,
- earlyUse:
- tightest && tightest.percent < 100
+ status:
+ state === "at_limit"
? {
- scope: `${tightest.label} window`,
- percentUsed: Math.round(tightest.percent),
+ label: "usable once a limit is hit",
+ description: "Codex only uses a credit once you hit a limit.",
}
: null,
+ earlyUse: null,
notes: [],
useful:
- count > 0 &&
+ state === "now" &&
!account.quota?.error &&
!needsReauthorization(account) &&
windows.some(
@@ -83,33 +86,38 @@ function codexResetOffer(account) {
};
}
+const NO_CREDIT_SPENT = "No credit was spent.";
+
function codexResultToast(result) {
- const windows =
- typeof result?.windowsReset === "number" && result.windowsReset > 0
- ? ` (${result.windowsReset} window${result.windowsReset === 1 ? "" : "s"} reset)`
- : "";
switch (result?.code) {
case "reset":
return {
- message: `Limits restored${windows}`,
+ message: "Limits reset \u00b7 1 credit used",
type: "success",
showBalance: true,
+ changed: true,
};
case "nothing_to_reset":
return {
- message: "Nothing to reset - limits are already clear",
+ message: `Nothing to reset yet: Codex only uses a credit once you hit a limit. ${NO_CREDIT_SPENT}`,
type: "info",
};
case "no_credit":
return {
- message: "No reset credit available on this account",
+ message: `That credit is no longer available. ${NO_CREDIT_SPENT}`,
type: "error",
};
case "already_redeemed":
- return { message: `Credit was already redeemed${windows}`, type: "info" };
+ return {
+ message:
+ "This credit already reset your limits \u00b7 nothing more was used",
+ type: "info",
+ showBalance: true,
+ changed: true,
+ };
default:
return {
- message: `Codex reset result: ${result?.code || "complete"}`,
+ message: `Codex answered \u201c${result?.code || "unknown"}\u201d \u00b7 the next limits check shows whether a credit was used`,
type: "info",
};
}
diff --git a/public/admin/app-core.js b/public/admin/app-core.js
index 87c485f..28eb7f6 100644
--- a/public/admin/app-core.js
+++ b/public/admin/app-core.js
@@ -34,6 +34,9 @@ const sessionState = () => ({
keyFilter: "",
accountFilter: "",
expandedAccountId: null,
+ // Accounts waiting for the limits read after a reset: the server time the
+ // reset was answered, and whether the wait timed out.
+ limitsUpdating: new Map(),
pendingExpandedAccountId: null,
showAllReauthorize: false,
expandedKeyId: null,
diff --git a/public/admin/app-reset-credits.js b/public/admin/app-reset-credits.js
index f26c8ee..c1a69b8 100644
--- a/public/admin/app-reset-credits.js
+++ b/public/admin/app-reset-credits.js
@@ -4,7 +4,6 @@ import { escapeHtml, mutationAttributes } from "./app-core.js";
import {
DAY_MS,
accountName,
- fact,
kbd,
needsReauthorization,
providerLabel,
@@ -39,62 +38,48 @@ function expiringResets(account) {
};
}
-function windowHours(window) {
- const span = window.label.match(/(\d+)([dh])$/);
- return span ? Number(span[1]) * (span[2] === "d" ? 24 : 1) : 0;
-}
-
-function resetCreditsDetail(account, windows) {
+// The account's resets on one line: the balance, what the owner can do with
+// it now, and what it refills.
+function resetCreditsDetail(account) {
const offer = resetOfferFor(account);
- if (!offer || (!offer.canUse && !offer.status)) return null;
- const covered = windows.filter(
- (window) =>
- (account.provider !== "codex" || !window.named) &&
- offer.windowLabels.includes(window.label)
- );
- const window = covered.sort(
- (a, b) => b.percent - a.percent || windowHours(b) - windowHours(a)
- )[0];
- const expiring = offer.expiresAt && offer.expiresAt - Date.now() < 3 * DAY_MS;
- const noun = account.provider === "codex" ? "credit" : offer.noun;
- const balance = `${offer.count ?? "\u2014"} ${noun}${offer.count === 1 ? "" : "s"}`;
- const text = offer.status
- ? offer.status.replaceCount
- ? offer.status.label
- : balance
- : !offer.scope
- ? `${balance} \u00b7 scope not reported`
- : `${balance}${account.provider === "codex" && !expiring ? " to refill" : ""}`;
+ if (!offer || (!offer.canUse && !offer.status)) return "";
+ const { status } = offer;
+ const balance = `${offer.count ?? "\u2014"} ${offer.unit}${offer.count === 1 ? "" : "s"}`;
+ const value = status?.replaceCount ? status.label : balance;
+ const left =
+ offer.expiresAt && offer.expiresAt - Date.now() < 3 * DAY_MS
+ ? `${untilText(offer.expiresAt).replace(/^in /, "")} left`
+ : null;
+ const scope = offer.scope ? `refills ${offer.scope}` : "scope not reported";
const title = [
- text,
- offer.status && !offer.status.replaceCount && offer.status.label,
- offer.scope ? `refills ${offer.scope}` : "reset scope not reported",
+ value,
+ status && !status.replaceCount && status.label,
+ scope,
offer.expiryText,
offer.title,
- offer.status?.description,
+ status?.description,
]
.filter(Boolean)
.join(" \u00b7 ");
const action = offer.canUse
? `
`
- : offer.status?.link
- ? `
${escapeHtml(offer.status.link)}`
+ : status?.link
+ ? `
${escapeHtml(status.link)}`
: "";
- const copy = expiring
- ? `${text} \u00b7 ${untilText(offer.expiresAt).replace(/^in /, "")} left`
- : text;
- const line = `
${escapeHtml(copy)}${action ? `\u00b7${action}` : ""}`;
- return {
- window,
- line,
- fallback: window
- ? ""
- : fact(
- `${offer.heading}${offer.count === 1 ? "" : "s"}`,
- `
${escapeHtml(copy)}`,
- [action && `
${action}`]
- ),
- };
+ const details = status?.replaceCount
+ ? [status.description]
+ : [
+ status?.label,
+ offer.canUse && offer.earlyUse && "usable early",
+ left,
+ scope,
+ ];
+ return `
${escapeHtml(`${offer.heading}s`)}${escapeHtml(value)}${details
+ .filter(Boolean)
+ .map((detail) => ` \u00b7 ${escapeHtml(detail)}`)
+ .join(
+ ""
+ )}${action ? `${action}` : ""}
`;
}
function earlyUseText(earlyUse) {
diff --git a/public/admin/styles.css b/public/admin/styles.css
index 25e5387..fc13d8b 100644
--- a/public/admin/styles.css
+++ b/public/admin/styles.css
@@ -188,6 +188,29 @@ code,
.tag-primary {
color: var(--accent);
}
+.stale {
+ opacity: 0.45;
+}
+.updating {
+ display: inline-flex;
+ gap: 6px;
+ align-items: center;
+ color: var(--muted);
+ white-space: nowrap;
+}
+.updating::before {
+ width: 6px;
+ height: 6px;
+ content: "";
+ background: currentColor;
+ border-radius: 50%;
+ animation: updating-pulse 1.2s ease-in-out infinite;
+}
+@keyframes updating-pulse {
+ 50% {
+ opacity: 0.25;
+ }
+}
/* Login */
@@ -1796,6 +1819,32 @@ input[type="checkbox"].toggle:checked::after {
.daily-tokens {
padding-bottom: 20px;
}
+.resets {
+ display: flex;
+ gap: 4px 16px;
+ align-items: baseline;
+ padding-bottom: 20px;
+}
+.resets .fact-label {
+ flex-shrink: 0;
+}
+.resets-copy {
+ flex: 1;
+ min-width: 0;
+ font-size: 12px;
+ line-height: 18px;
+ color: var(--muted);
+}
+.resets-value {
+ color: var(--text);
+}
+.resets-action .link {
+ display: inline-flex;
+ gap: 6px;
+ align-items: center;
+ color: var(--text);
+ white-space: nowrap;
+}
.daily-head,
.daily-axis {
display: flex;
diff --git a/src/http/routes/admin-accounts.ts b/src/http/routes/admin-accounts.ts
index 0617051..7205d31 100644
--- a/src/http/routes/admin-accounts.ts
+++ b/src/http/routes/admin-accounts.ts
@@ -351,8 +351,9 @@ export const adminAccountsRoutes = new Hono()
provider === "codex"
? await redeemCodexResetCredit(db, id, resetId)
: await claimClaudeReset(db, id, resetId);
+ // Limits read after this time show the spend's effect.
return result
- ? context.json({ result })
+ ? context.json({ result, respondedAt: Date.now() })
: context.json(accountNotFoundBody, 404);
} catch (error) {
if (error instanceof ResetSpendConflict) {