feat(usage): show and redeem free limit resets for cc accounts

Adds the free usage-limit reset (the desktop app's "Reset for free")
to the Quota Tracker for cc OAuth accounts, mirroring the existing
Codex reset-credit button.

- Reset button with remaining count on the card; tooltip shows use-by
  date and which limits get refilled.
- Expiry modal (clock button) listing each grant: label, resets left,
  refills (session / weekly), use-by date, time remaining, status.
- Confirm dialog before redeeming, since a reset is irreversible.
- Usage and reset calls send the CLI User-Agent required for cedar_ember.
- Usage cache is dropped after a redeem so the card shows the refilled limits.
This commit is contained in:
Reid Nguyen
2026-09-23 12:16:10 +07:00
parent 8403576095
commit e571a8b6da
6 changed files with 236 additions and 23 deletions

View File

@@ -54,6 +54,8 @@ export default {
oauthUrl: "https://api.anthropic.com/api/oauth/usage",
orgUrl: "https://api.anthropic.com/v1/organizations/{org_id}/usage",
settingsUrl: "https://api.anthropic.com/v1/settings",
profileUrl: "https://api.anthropic.com/api/oauth/profile",
resetUrl: "https://api.anthropic.com/api/organizations/{org_id}/reset_rate_limits",
},
},
models: [

View File

@@ -4,10 +4,10 @@
import { getGitHubUsage } from "./usage/github.js";
import { getGeminiUsage, getAntigravityUsage } from "./usage/google.js";
import { getClaudeUsage } from "./usage/claude.js";
import { getClaudeUsage, consumeClaudeResetGrant } from "./usage/claude.js";
import { getCodexUsage, consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits } from "./usage/codex.js";
export { consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits };
export { consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits, consumeClaudeResetGrant };
import { getKiroUsage } from "./usage/kiro.js";
import { getMiniMaxUsage } from "./usage/minimax.js";
import { getCodeBuddyCnUsage, getCodeBuddyIntlUsage } from "./usage/codebuddy-cn.js";

View File

@@ -3,7 +3,7 @@
*/
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
import { ANTHROPIC_API_VERSION } from "../../providers/shared.js";
import { ANTHROPIC_API_VERSION, CLAUDE_CLI_VERSION } from "../../providers/shared.js";
import { U, parseResetTime } from "./shared.js";
// Claude API config (urls from registry, apiVersion is header logic kept here)
@@ -11,7 +11,11 @@ const CLAUDE_CONFIG = {
oauthUsageUrl: U("claude").oauthUrl,
usageUrl: U("claude").orgUrl,
settingsUrl: U("claude").settingsUrl,
profileUrl: U("claude").profileUrl,
resetUrl: U("claude").resetUrl,
apiVersion: ANTHROPIC_API_VERSION,
// Reset grants are gated by surface: only "(external, cli)" UA is eligible
userAgent: `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`,
};
// OAuth usage endpoint rate-limits (429); cool down per-token to stop hammering it.
@@ -64,12 +68,14 @@ async function fetchClaudeUsageRaw(accessToken, proxyOptions = null) {
}
// Primary: OAuth usage endpoint (Claude Code consumer OAuth tokens)
const oauthResponse = await proxyAwareFetch(CLAUDE_CONFIG.oauthUsageUrl, {
// cedar_ember=1 adds the "limit reset" grant block (same flag Claude Code sends)
const oauthResponse = await proxyAwareFetch(`${CLAUDE_CONFIG.oauthUsageUrl}?cedar_ember=1`, {
method: "GET",
headers: {
"Authorization": `Bearer ${accessToken}`,
"anthropic-beta": "oauth-2025-04-20",
"anthropic-version": CLAUDE_CONFIG.apiVersion,
"User-Agent": CLAUDE_CONFIG.userAgent,
},
}, proxyOptions);
@@ -129,6 +135,7 @@ async function fetchClaudeUsageRaw(accessToken, proxyOptions = null) {
return {
plan: "Claude Code",
extraUsage: data.extra_usage ?? null,
resetCredits: parseClaudeResetGrants(data.cedar_ember),
quotas,
};
}
@@ -146,6 +153,70 @@ async function fetchClaudeUsageRaw(accessToken, proxyOptions = null) {
}
}
// Free "limit reset" grants (Anthropic program id "cedar_ember").
// Shape: { eligible, next_grant_id, grants: [{ id, resets_left, ends_at, paused, clears }] }
export function parseClaudeResetGrants(block) {
if (!block?.eligible || !Array.isArray(block.grants)) return null;
const grants = block.grants.filter((g) => g?.id && !g.paused && Number(g.resets_left) > 0);
const next = grants.find((g) => g.id === block.next_grant_id) || grants[0] || null;
return {
availableCount: grants.reduce((sum, g) => sum + Number(g.resets_left), 0),
nextGrantId: next?.id || null,
expiresAt: next?.ends_at || null,
clears: next?.clears || [],
cooldownUntil: block.cooldown_until || null,
weeklyResetsAt: block.weekly_resets_at || null,
grants: block.grants.filter((g) => g?.id).map((g) => ({
id: g.id,
label: g.label || "",
resetsLeft: Number(g.resets_left) || 0,
resetsTotal: Number(g.resets_total) || 0,
startsAt: g.starts_at || null,
endsAt: g.ends_at || null,
clears: Array.isArray(g.clears) ? g.clears : [],
paused: g.paused === true,
usableNow: g.usable_now === true,
useRequiresLimit: g.use_requires_limit !== false,
})),
};
}
// Spend one reset grant: refills the limits listed in grant.clears. Irreversible.
export async function consumeClaudeResetGrant(accessToken, grantId, proxyOptions = null) {
if (!accessToken) throw new Error("No Claude access token available. Please re-authorize the connection.");
if (!/^[a-z0-9_-]{1,40}$/.test(grantId || "")) throw new Error("Invalid reset grant id.");
const headers = {
"Authorization": `Bearer ${accessToken}`,
"anthropic-beta": "oauth-2025-04-20",
"anthropic-version": CLAUDE_CONFIG.apiVersion,
"User-Agent": CLAUDE_CONFIG.userAgent,
"Content-Type": "application/json",
};
const profileRes = await proxyAwareFetch(CLAUDE_CONFIG.profileUrl, { method: "GET", headers }, proxyOptions);
const profile = await profileRes.json().catch(() => null);
const orgId = profile?.organization?.uuid;
if (!profileRes.ok || !orgId) throw new Error(`Cannot resolve Claude organization (${profileRes.status}).`);
const res = await proxyAwareFetch(CLAUDE_CONFIG.resetUrl.replace("{org_id}", orgId), {
method: "POST",
headers,
body: JSON.stringify({ program: "cedar_ember", grant_id: grantId, request_id: crypto.randomUUID() }),
}, proxyOptions);
const data = await res.json().catch(() => null);
usageCache.delete(accessToken); // next read must show refilled limits
return {
ok: res.ok && data?.result === "reset",
status: res.status,
result: data?.result || null,
reason: data?.reason || null,
resetsLeft: data?.resets_left ?? null,
message: data?.error?.message || null,
};
}
/**
* Legacy Claude usage for API key / org admin users
*/

View File

@@ -100,6 +100,28 @@ function getCodexResetCreditCount(quota) {
return Number.isFinite(count) ? Math.max(0, count) : 0;
}
const CLAUDE_RESET_LIMIT_NAMES = {
five_hour: "session",
seven_day: "weekly",
seven_day_overage_included: "weekly",
seven_day_opus: "Opus weekly",
seven_day_sonnet: "Sonnet weekly",
};
function formatClaudeResetClears(clears) {
const names = [...new Set((clears || []).map((c) => CLAUDE_RESET_LIMIT_NAMES[c]).filter(Boolean))];
return names.length ? `${names.join(" + ")} limits` : "limits";
}
function claudeGrantStatus(grant) {
if (grant.resetsLeft <= 0) return "used";
if (grant.paused) return "paused";
if (grant.endsAt && new Date(grant.endsAt).getTime() <= Date.now()) return "expired";
if (grant.usableNow) return "usable now";
if (grant.startsAt && new Date(grant.startsAt).getTime() > Date.now()) return "not started";
return grant.useRequiresLimit ? "at limit only" : "unavailable";
}
function providerLabel(providerId) {
return AI_PROVIDERS[providerId]?.name || providerId;
}
@@ -308,30 +330,41 @@ export default function ProviderLimits() {
const handleResetCodexLimit = useCallback(
async (connectionId, provider) => {
if (provider !== "codex" || resettingLimitId) return;
if ((provider !== "codex" && provider !== "claude") || resettingLimitId) return;
setResettingLimitId(connectionId);
setErrors((prev) => ({ ...prev, [connectionId]: null }));
try {
const response = await fetch(`/api/usage/${connectionId}/codex-reset-credits`, { method: "POST" });
const response = provider === "claude"
? await fetch(`/api/usage/${connectionId}/claude-reset`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ grantId: quotaData[connectionId]?.raw?.resetCredits?.nextGrantId }),
})
: await fetch(`/api/usage/${connectionId}/codex-reset-credits`, { method: "POST" });
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.message || result.error || result.code || "Failed to reset Codex limit");
throw new Error(result.message || result.error || result.code || "Failed to reset limit");
}
await fetchQuota(connectionId, provider);
await fetchQuota(connectionId, provider, { force: true });
setLastUpdated(new Date());
} catch (error) {
setErrors((prev) => ({ ...prev, [connectionId]: error.message || "Failed to reset Codex limit" }));
setErrors((prev) => ({ ...prev, [connectionId]: error.message || "Failed to reset limit" }));
} finally {
setResettingLimitId(null);
}
},
[fetchQuota, resettingLimitId],
[fetchQuota, resettingLimitId, quotaData],
);
// Claude grants already arrive with the usage read; no extra fetch
const handleViewClaudeResets = useCallback((connection, resetCredits) => {
setResetCreditsState({ connection, loading: false, error: null, data: { kind: "claude", ...resetCredits } });
}, []);
const handleViewCodexResetCredits = useCallback(async (connection) => {
setResetCreditsState({ connection, loading: true, error: null, data: null });
try {
@@ -1055,6 +1088,8 @@ export default function ProviderLimits() {
// Use table layout for all providers
const isInactive = conn.isActive === false;
const isCodex = conn.provider === "codex";
const claudeReset = conn.provider === "claude" ? quota?.raw?.resetCredits : null;
const resetLabel = isCodex ? "Codex reset credit" : "Claude limit reset";
const resetCreditCount = getCodexResetCreditCount(quota);
const isResettingLimit = resettingLimitId === conn.id;
const rowBusy = deletingId === conn.id || togglingId === conn.id || isResettingLimit;
@@ -1140,13 +1175,15 @@ export default function ProviderLimits() {
</div>
<div className="flex items-center gap-1 shrink-0">
{isCodex && (
{(isCodex || claudeReset) && (
<>
<Tooltip
text={
resetCreditCount > 0
? `Use one Codex reset credit. Available: ${resetCreditCount}`
: "No Codex reset credits available"
? claudeReset
? `Use your reset now (${resetCreditCount} left, use by ${formatCreditDate(claudeReset.expiresAt)}) · refills ${formatClaudeResetClears(claudeReset.clears)}`
: `Use one ${resetLabel}. Available: ${resetCreditCount}`
: `No ${resetLabel}s available`
}
>
<button
@@ -1155,8 +1192,8 @@ export default function ProviderLimits() {
disabled={resetCreditCount <= 0 || isLoading || rowBusy}
aria-label={
resetCreditCount > 0
? `Use one Codex reset credit. ${resetCreditCount} available.`
: "No Codex reset credits available"
? `Use one ${resetLabel}. ${resetCreditCount} available.`
: `No ${resetLabel}s available`
}
className={`flex h-8 min-w-10 items-center justify-center gap-1 rounded-lg border px-2 text-[11px] font-medium tabular-nums transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary/60 disabled:cursor-not-allowed disabled:opacity-60 ${
resetCreditCount > 0
@@ -1170,12 +1207,12 @@ export default function ProviderLimits() {
<span>{resetCreditCount}</span>
</button>
</Tooltip>
<Tooltip text="View Codex reset credit expiry">
<Tooltip text={isCodex ? "View Codex reset credit expiry" : "View Claude Code reset expiry"}>
<button
type="button"
onClick={() => handleViewCodexResetCredits(conn)}
onClick={() => (isCodex ? handleViewCodexResetCredits(conn) : handleViewClaudeResets(conn, claudeReset))}
disabled={isLoading || rowBusy}
aria-label="View Codex reset credit expiry"
aria-label={isCodex ? "View Codex reset credit expiry" : "View Claude Code reset expiry"}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-black/10 text-text-muted transition-colors hover:bg-black/5 hover:text-primary disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:hover:bg-white/5"
>
<span className="material-symbols-outlined text-[17px]">schedule</span>
@@ -1457,8 +1494,10 @@ export default function ProviderLimits() {
await handleResetCodexLimit(connection.id, connection.provider);
setResetConfirmState(null);
}}
title="Reset Codex limit?"
message={`Use 1 Codex reset credit for ${getConnectionLabel(resetConfirmState?.connection || {}) || "this account"}. This cannot be undone. Remaining credits: ${resetConfirmState?.resetCreditCount ?? 0}.`}
title={resetConfirmState?.connection?.provider === "claude" ? "Reset Claude limits?" : "Reset Codex limit?"}
message={resetConfirmState?.connection?.provider === "claude"
? `Refills your ${formatClaudeResetClears(quotaData[resetConfirmState.connection.id]?.raw?.resetCredits?.clears)} now for ${getConnectionLabel(resetConfirmState.connection) || "this account"} · your weekly reset day stays ${formatCreditDate(quotaData[resetConfirmState.connection.id]?.raw?.resetCredits?.weeklyResetsAt)}. This cannot be undone. Resets left: ${resetConfirmState.resetCreditCount ?? 0}.`
: `Use 1 Codex reset credit for ${getConnectionLabel(resetConfirmState?.connection || {}) || "this account"}. This cannot be undone. Remaining credits: ${resetConfirmState?.resetCreditCount ?? 0}.`}
confirmText="Reset limit"
cancelText="Cancel"
variant="danger"
@@ -1470,9 +1509,11 @@ export default function ProviderLimits() {
<div className="w-full max-w-2xl overflow-hidden rounded-2xl border border-black/15 bg-white shadow-2xl ring-1 ring-black/10 dark:border-white/15 dark:bg-neutral-950 dark:ring-white/10">
<div className="flex items-start justify-between gap-3 border-b border-black/10 bg-black/[0.03] px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]">
<div className="min-w-0">
<h3 className="text-base font-semibold text-text-primary">Codex Reset Credit Expiry</h3>
<h3 className="text-base font-semibold text-text-primary">
{resetCreditsState.data?.kind === "claude" ? "Claude Code Limit Resets" : "Codex Reset Credit Expiry"}
</h3>
<p className="mt-0.5 truncate text-xs text-text-muted">
{getConnectionLabel(resetCreditsState.connection) || "Codex account"}
{getConnectionLabel(resetCreditsState.connection) || (resetCreditsState.data?.kind === "claude" ? "Claude account" : "Codex account")}
</p>
</div>
<button
@@ -1495,6 +1536,42 @@ export default function ProviderLimits() {
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-300">
{resetCreditsState.error}
</div>
) : resetCreditsState.data?.kind === "claude" && resetCreditsState.data.grants?.length ? (
<div className="space-y-3">
<div className="flex items-center justify-between rounded-xl border border-black/10 bg-black/[0.02] px-3 py-2 text-xs text-text-muted dark:border-white/10 dark:bg-white/[0.03]">
<span>{resetCreditsState.data.availableCount ?? 0} reset{resetCreditsState.data.availableCount === 1 ? "" : "s"} left</span>
<span>Weekly reset day: {formatCreditDate(resetCreditsState.data.weeklyResetsAt)}</span>
</div>
<div className="overflow-x-auto rounded-xl border border-black/10 dark:border-white/10">
<table className="w-full min-w-[560px] text-left text-sm">
<thead className="bg-black/[0.03] text-xs uppercase tracking-wide text-text-muted dark:bg-white/[0.04]">
<tr>
<th className="px-3 py-2 font-medium">Reset</th>
<th className="px-3 py-2 font-medium">Left</th>
<th className="px-3 py-2 font-medium">Refills</th>
<th className="px-3 py-2 font-medium">Use By</th>
<th className="px-3 py-2 font-medium">Remaining</th>
</tr>
</thead>
<tbody>
{(resetCreditsState.data.grants || []).map((grant) => (
<tr key={grant.id} className="border-t border-black/5 dark:border-white/5">
<td className="px-3 py-2">
<div className="text-text-primary">{grant.label || grant.id}</div>
<span className="mt-1 inline-block rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{claudeGrantStatus(grant)}
</span>
</td>
<td className="whitespace-nowrap px-3 py-2 font-medium tabular-nums text-text-primary">{grant.resetsLeft} / {grant.resetsTotal}</td>
<td className="px-3 py-2 text-text-muted">{formatClaudeResetClears(grant.clears)}</td>
<td className="px-3 py-2 text-text-primary">{formatCreditDate(grant.endsAt)}</td>
<td className="whitespace-nowrap px-3 py-2 font-medium text-text-primary">{formatTimeRemaining(grant.endsAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : resetCreditsState.data?.credits?.length ? (
<div className="space-y-3">
<div className="flex items-center justify-between rounded-xl border border-black/10 bg-black/[0.02] px-3 py-2 text-xs text-text-muted dark:border-white/10 dark:bg-white/[0.03]">
@@ -1530,7 +1607,7 @@ export default function ProviderLimits() {
</div>
) : (
<div className="rounded-xl border border-black/10 bg-black/[0.02] px-3 py-8 text-center text-sm text-text-muted dark:border-white/10 dark:bg-white/[0.03]">
No reset credit details returned for this account.
{resetCreditsState.data?.kind === "claude" ? "No limit resets available for this account." : "No reset credit details returned for this account."}
</div>
)}
</div>

View File

@@ -0,0 +1,40 @@
// Ensure proxyFetch is loaded to patch globalThis.fetch
import "open-sse/index.js";
import { getProviderConnectionById } from "@/lib/localDb";
import { consumeClaudeResetGrant } from "open-sse/services/usage.js";
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
import { refreshAndUpdateCredentials } from "../route.js";
// Spend one free Claude "limit reset" grant (irreversible)
export async function POST(request, { params }) {
try {
const { connectionId } = await params;
const { grantId } = await request.json().catch(() => ({}));
let connection = await getProviderConnectionById(connectionId);
if (!connection) return Response.json({ error: "Connection not found" }, { status: 404 });
if (connection.provider !== "claude" || connection.authType !== "oauth") {
return Response.json({ error: "Limit reset is only available for Claude OAuth connections." }, { status: 400 });
}
const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData);
const proxyOptions = {
connectionProxyEnabled: proxyConfig.connectionProxyEnabled === true,
connectionProxyUrl: proxyConfig.connectionProxyUrl || "",
connectionNoProxy: proxyConfig.connectionNoProxy || "",
vercelRelayUrl: proxyConfig.vercelRelayUrl || "",
strictProxy: false,
};
({ connection } = await refreshAndUpdateCredentials(connection, false, proxyOptions));
const result = await consumeClaudeResetGrant(connection.accessToken, grantId, proxyOptions);
if (result.ok) return Response.json(result);
const status = result.status >= 400 && result.status < 500 ? result.status : 409;
return Response.json({ ...result, message: result.message || `Reset not applied: ${result.reason || result.result || "unknown"}` }, { status });
} catch (error) {
console.warn(`[Claude Reset] ${error.message}`);
return Response.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import { parseClaudeResetGrants } from "../../open-sse/services/usage/claude.js";
describe("parseClaudeResetGrants", () => {
it("sums usable grants and picks next_grant_id", () => {
const r = parseClaudeResetGrants({
eligible: true,
next_grant_id: "g2",
grants: [
{ id: "g1", resets_left: 1, ends_at: "2026-10-01T00:00:00Z" },
{ id: "g2", resets_left: 2, ends_at: "2026-10-22T00:00:00Z", clears: ["five_hour", "seven_day"] },
{ id: "g3", resets_left: 5, paused: true },
],
});
expect(r).toMatchObject({ availableCount: 3, nextGrantId: "g2", expiresAt: "2026-10-22T00:00:00Z" });
expect(r.grants.map((g) => g.id)).toEqual(["g1", "g2", "g3"]); // modal lists paused too
expect(r.grants[1].clears).toEqual(["five_hour", "seven_day"]);
});
it("returns null when ineligible or missing", () => {
expect(parseClaudeResetGrants(undefined)).toBeNull();
expect(parseClaudeResetGrants({ eligible: false, grants: [] })).toBeNull();
});
});