diff --git a/open-sse/providers/registry/claude.js b/open-sse/providers/registry/claude.js index d0bc9302..3677ffb5 100644 --- a/open-sse/providers/registry/claude.js +++ b/open-sse/providers/registry/claude.js @@ -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: [ diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index b431e21a..3ce46cfa 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -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"; diff --git a/open-sse/services/usage/claude.js b/open-sse/services/usage/claude.js index d93a0682..13665201 100644 --- a/open-sse/services/usage/claude.js +++ b/open-sse/services/usage/claude.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 */ diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index 0a508265..0090b7c6 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -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() {
- {isCodex && ( + {(isCodex || claudeReset) && ( <> 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` } > - +
+ ) : resetCreditsState.data?.kind === "claude" && resetCreditsState.data.grants?.length ? ( +
+
+ {resetCreditsState.data.availableCount ?? 0} reset{resetCreditsState.data.availableCount === 1 ? "" : "s"} left + Weekly reset day: {formatCreditDate(resetCreditsState.data.weeklyResetsAt)} +
+
+ + + + + + + + + + + + {(resetCreditsState.data.grants || []).map((grant) => ( + + + + + + + + ))} + +
ResetLeftRefillsUse ByRemaining
+
{grant.label || grant.id}
+ + {claudeGrantStatus(grant)} + +
{grant.resetsLeft} / {grant.resetsTotal}{formatClaudeResetClears(grant.clears)}{formatCreditDate(grant.endsAt)}{formatTimeRemaining(grant.endsAt)}
+
+
) : resetCreditsState.data?.credits?.length ? (
@@ -1530,7 +1607,7 @@ export default function ProviderLimits() {
) : (
- 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."}
)}
diff --git a/src/app/api/usage/[connectionId]/claude-reset/route.js b/src/app/api/usage/[connectionId]/claude-reset/route.js new file mode 100644 index 00000000..05aa9b18 --- /dev/null +++ b/src/app/api/usage/[connectionId]/claude-reset/route.js @@ -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 }); + } +} diff --git a/tests/unit/claude-reset-grants.test.js b/tests/unit/claude-reset-grants.test.js new file mode 100644 index 00000000..ec7cb6ad --- /dev/null +++ b/tests/unit/claude-reset-grants.test.js @@ -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(); + }); +});