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() {