From f1bf027c68ca9bbc24c5fcdebf9b2ffc7698489c Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 12 Mar 2026 09:42:17 +0700 Subject: [PATCH] feat(usage): claude quota tracker --- open-sse/services/usage.js | 84 ++++++++++++++++--- .../usage/components/ProviderLimits/index.js | 19 +---- src/app/api/usage/[connectionId]/route.js | 3 +- src/shared/constants/providers.js | 2 +- 4 files changed, 80 insertions(+), 28 deletions(-) diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 733768a..d6b7cf0 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -27,8 +27,10 @@ const CODEX_CONFIG = { // Claude API config const CLAUDE_CONFIG = { + oauthUsageUrl: "https://api.anthropic.com/api/oauth/usage", usageUrl: "https://api.anthropic.com/v1/organizations/{org_id}/usage", settingsUrl: "https://api.anthropic.com/v1/settings", + apiVersion: "2023-06-01", }; /** @@ -358,33 +360,96 @@ async function getAntigravitySubscriptionInfo(accessToken) { } /** - * Claude Usage - Try to fetch from Anthropic API + * Claude Usage - Primary: OAuth endpoint, Fallback: legacy settings/org endpoint */ async function getClaudeUsage(accessToken) { try { - // Try to get organization/account settings first - const settingsResponse = await fetch("https://api.anthropic.com/v1/settings", { + // Primary: OAuth usage endpoint (Claude Code consumer OAuth tokens) + const oauthResponse = await fetch(CLAUDE_CONFIG.oauthUsageUrl, { method: "GET", headers: { "Authorization": `Bearer ${accessToken}`, - "Content-Type": "application/json", - "anthropic-version": "2023-06-01", + "anthropic-beta": "oauth-2025-04-20", + "anthropic-version": CLAUDE_CONFIG.apiVersion, + }, + }); + + if (oauthResponse.ok) { + const data = await oauthResponse.json(); + const quotas = {}; + + // utilization = % USED (e.g. 87 means 87% used, 13% remaining) + const hasUtilization = (window) => + window && typeof window === "object" && typeof window.utilization === "number"; + + const createQuotaObject = (window) => { + const used = window.utilization; + const remaining = Math.max(0, 100 - used); + return { + used, + total: 100, + remaining, + remainingPercentage: remaining, + resetAt: parseResetTime(window.resets_at), + unlimited: false, + }; + }; + + if (hasUtilization(data.five_hour)) { + quotas["session (5h)"] = createQuotaObject(data.five_hour); + } + + if (hasUtilization(data.seven_day)) { + quotas["weekly (7d)"] = createQuotaObject(data.seven_day); + } + + // Parse model-specific weekly windows (e.g. seven_day_sonnet, seven_day_opus) + for (const [key, value] of Object.entries(data)) { + if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(value)) { + const modelName = key.replace("seven_day_", ""); + quotas[`weekly ${modelName} (7d)`] = createQuotaObject(value); + } + } + + return { + plan: "Claude Code", + extraUsage: data.extra_usage ?? null, + quotas, + }; + } + + // Fallback: legacy settings + org usage endpoint + console.warn(`[Claude Usage] OAuth endpoint returned ${oauthResponse.status}, falling back to legacy`); + return await getClaudeUsageLegacy(accessToken); + } catch (error) { + return { message: `Claude connected. Unable to fetch usage: ${error.message}` }; + } +} + +/** + * Legacy Claude usage for API key / org admin users + */ +async function getClaudeUsageLegacy(accessToken) { + try { + const settingsResponse = await fetch(CLAUDE_CONFIG.settingsUrl, { + method: "GET", + headers: { + "Authorization": `Bearer ${accessToken}`, + "anthropic-version": CLAUDE_CONFIG.apiVersion, }, }); if (settingsResponse.ok) { const settings = await settingsResponse.json(); - // Try usage endpoint if we have org info if (settings.organization_id) { const usageResponse = await fetch( - `https://api.anthropic.com/v1/organizations/${settings.organization_id}/usage`, + CLAUDE_CONFIG.usageUrl.replace("{org_id}", settings.organization_id), { method: "GET", headers: { "Authorization": `Bearer ${accessToken}`, - "Content-Type": "application/json", - "anthropic-version": "2023-06-01", + "anthropic-version": CLAUDE_CONFIG.apiVersion, }, } ); @@ -406,7 +471,6 @@ async function getClaudeUsage(accessToken) { }; } - // If settings API fails, OAuth token may not have required scope return { message: "Claude connected. Usage API requires admin permissions." }; } catch (error) { return { message: `Claude connected. Unable to fetch usage: ${error.message}` }; diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index c9b5d12..5dfe61e 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -247,22 +247,11 @@ export default function ProviderLimits() { USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth" ); - // Sort providers: antigravity first, then kiro, then others alphabetically + // Sort providers by USAGE_SUPPORTED_PROVIDERS order, then alphabetically const sortedConnections = [...filteredConnections].sort((a, b) => { - const getProviderPriority = (provider) => { - if (provider === "antigravity") return 1; - if (provider === "kiro") return 2; - return 3; - }; - - const priorityA = getProviderPriority(a.provider); - const priorityB = getProviderPriority(b.provider); - - if (priorityA !== priorityB) { - return priorityA - priorityB; - } - - // Same priority: sort alphabetically + const orderA = USAGE_SUPPORTED_PROVIDERS.indexOf(a.provider); + const orderB = USAGE_SUPPORTED_PROVIDERS.indexOf(b.provider); + if (orderA !== orderB) return orderA - orderB; return a.provider.localeCompare(b.provider); }); diff --git a/src/app/api/usage/[connectionId]/route.js b/src/app/api/usage/[connectionId]/route.js index 00bb661..29cf245 100644 --- a/src/app/api/usage/[connectionId]/route.js +++ b/src/app/api/usage/[connectionId]/route.js @@ -120,8 +120,7 @@ export async function GET(request, { params }) { const usage = await getUsageForProvider(connection); return Response.json(usage); } catch (error) { - console.error("[Usage API] Error fetching usage:", error); - console.error("[Usage API] Error stack:", error.stack); + console.warn(`[Usage] ${connection?.provider}: ${error.message}`); return Response.json({ error: error.message }, { status: 500 }); } } diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index b5ba86d..6024fc6 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -105,4 +105,4 @@ export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce((acc, p) => { }, {}); // Providers that support usage/quota API -export const USAGE_SUPPORTED_PROVIDERS = ["antigravity", "kiro", "github", "codex"]; +export const USAGE_SUPPORTED_PROVIDERS = [ "claude", "antigravity", "kiro", "github", "codex"];