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:
@@ -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: [
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
40
src/app/api/usage/[connectionId]/claude-reset/route.js
Normal file
40
src/app/api/usage/[connectionId]/claude-reset/route.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
23
tests/unit/claude-reset-grants.test.js
Normal file
23
tests/unit/claude-reset-grants.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user