From d372a9af6915ff78ab5ae00174398c8e63809c3f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 1 Jul 2026 04:13:17 -0300 Subject: [PATCH] refactor(usage): extract 5 provider usage families into leaves (#5782) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split open-sse/services/usage.ts (1723 -> 901 LOC) by moving the Cursor, Kimi, Codex, Claude and Kiro usage-fetcher families into cohesive leaves under open-sse/services/usage/ (mirroring the existing glm/minimax/antigravity/quota/ scalars leaves): - usage/cursor.ts getCursorUsage (+ CURSOR_USAGE_CONFIG, decodeCursorJwtSub) - usage/kimi.ts getKimiUsage (+ KIMI_CONFIG, getKimiPlanName) - usage/codex.ts getCodexUsage (+ CODEX_CONFIG) - usage/claude.ts getClaudeUsage / getClaudePlanLabel (+ CLAUDE_CONFIG, legacy) - usage/kiro.ts getKiroUsage / buildKiroUsageResult / discoverKiroProfileArn (+ helpers) The host keeps the getUsageForProvider dispatcher and imports the fetchers back; the public export set is unchanged — buildKiroUsageResult + discoverKiroProfileArn are re-exported from the kiro leaf (the kiro-* tests import them from services/usage) and __testing stays wired to the moved claude/kiro internals. Bodies are verbatim: the code-line multiset of host + leaves equals the original. Adds tests/unit/usage-families-split.test.ts pinning the leaf surface, the kiro re-export identity, the __testing wiring, and getClaudePlanLabel's pure logic. --- open-sse/services/usage.ts | 836 +----------------------- open-sse/services/usage/claude.ts | 216 ++++++ open-sse/services/usage/codex.ts | 70 ++ open-sse/services/usage/cursor.ts | 161 +++++ open-sse/services/usage/kimi.ts | 209 ++++++ open-sse/services/usage/kiro.ts | 243 +++++++ tests/unit/usage-families-split.test.ts | 62 ++ 7 files changed, 968 insertions(+), 829 deletions(-) create mode 100644 open-sse/services/usage/claude.ts create mode 100644 open-sse/services/usage/codex.ts create mode 100644 open-sse/services/usage/cursor.ts create mode 100644 open-sse/services/usage/kimi.ts create mode 100644 open-sse/services/usage/kiro.ts create mode 100644 tests/unit/usage-families-split.test.ts diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 3aae5f02c6..a65d21daf8 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -2,18 +2,13 @@ * Usage Fetcher - Get usage data from provider APIs */ -import { buildCodexUsageQuotas } from "./codexUsageQuotas.ts"; -import { getGlmQuotaUrl } from "../config/glmProvider.ts"; import { getGitHubCopilotInternalUserHeaders } from "../config/providerHeaderProfiles.ts"; -import { safePercentage } from "@/shared/utils/formatting"; import { getDbInstance } from "@/lib/db/core"; import { fetchBailianQuota, type BailianTripleWindowQuota } from "./bailianQuotaFetcher.ts"; import { fetchDeepseekQuota, type DeepseekQuota } from "./deepseekQuotaFetcher.ts"; import { fetchOpencodeQuota, type OpencodeTripleWindowQuota } from "./opencodeQuotaFetcher.ts"; import { getOllamaCloudUsage, getOpenCodeGoUsage } from "./opencodeOllamaUsage.ts"; import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.ts"; -import { CLAUDE_CODE_VERSION, fetchClaudeBootstrap } from "../executors/claudeIdentity.ts"; -import { isClaudeOauthUsageCoolingDown, markClaudeOauthUsage429 } from "./claudeUsageCooldown.ts"; import { extractCodeAssistOnboardTierId, extractCodeAssistSubscriptionTier, @@ -55,48 +50,21 @@ import { mapCodeAssistTierIdToLabel, mapSubscriptionTierStringToPlanLabel, } from "./usage/antigravity.ts"; +import { getCursorUsage } from "./usage/cursor.ts"; +import { getKimiUsage } from "./usage/kimi.ts"; +import { getCodexUsage } from "./usage/codex.ts"; +import { getClaudeUsage, getClaudePlanLabel } from "./usage/claude.ts"; +import { getKiroUsage, buildKiroUsageResult, discoverKiroProfileArn } from "./usage/kiro.ts"; +// Re-exported para os testes kiro-* (importam de services/usage). +export { buildKiroUsageResult, discoverKiroProfileArn } from "./usage/kiro.ts"; // Quota / usage upstream URLs (overridable for testing or relays). const CROF_USAGE_URL = process.env.OMNIROUTE_CROF_USAGE_URL ?? "https://crof.ai/usage_api/"; -const CODEWHISPERER_BASE_URL = - process.env.OMNIROUTE_CODEWHISPERER_BASE_URL ?? "https://codewhisperer.us-east-1.amazonaws.com"; - -// Codex (OpenAI) API config -const CODEX_CONFIG = { - usageUrl: "https://chatgpt.com/backend-api/wham/usage", -}; - -// 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", -}; - -// Kimi Coding API config -const KIMI_CONFIG = { - baseUrl: "https://api.kimi.com/coding/v1", - usageUrl: "https://api.kimi.com/coding/v1/usages", - apiVersion: "2023-06-01", -}; const NANOGPT_CONFIG = { usageUrl: "https://nano-gpt.com/api/subscription/v1/usage", }; -// Cursor dashboard usage API config -// The endpoint that powers https://cursor.com/dashboard/spending. Validates the WorkOS -// session via the WorkosCursorSessionToken cookie (format: `${userId}::${jwt}`) and -// rejects requests without a matching Origin/Referer (Invalid origin for state-changing request). -const CURSOR_USAGE_CONFIG = { - usageUrl: "https://cursor.com/api/dashboard/get-current-period-usage", - origin: "https://cursor.com", - referer: "https://cursor.com/dashboard/spending", - userAgent: - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", -}; - type JsonRecord = Record; type UsageProviderConnection = JsonRecord & { id?: string; @@ -114,57 +82,6 @@ function shouldDisplayGitHubQuota(quota: UsageQuota | null): quota is UsageQuota return quota.total > 0 || quota.remainingPercentage !== undefined; } -function isKiroOverageEnabled(data: JsonRecord): boolean { - const overageConfiguration = toRecord(data.overageConfiguration); - const overageStatus = String(overageConfiguration.overageStatus || "") - .trim() - .toUpperCase(); - - return ( - overageStatus === "ENABLED" || - data.overageEnabled === true || - overageConfiguration.overageEnabled === true - ); -} - -function buildKiroQuota( - used: number, - total: number, - resetAt: string | null, - overageEnabled: boolean -): UsageQuota { - const remaining = total - used; - - if (!overageEnabled) { - return { used, total, remaining, resetAt, unlimited: false }; - } - - return { - used, - total, - remaining, - remainingPercentage: 100, - resetAt, - unlimited: true, - }; -} - -function getClaudePlanLabel(...candidates: Array): string | null { - for (const candidate of candidates) { - if (typeof candidate !== "string") continue; - const trimmed = candidate.trim(); - if ( - !trimmed || - trimmed.toLowerCase() === "claude code" || - trimmed.toLowerCase() === "unknown" - ) { - continue; - } - return trimmed; - } - return null; -} - // CrofAI surfaces a tiny endpoint with two signals: // GET https://crof.ai/usage_api/ → { usable_requests: number|null, credits: number } // `usable_requests` is the daily request bucket on a subscription plan; `null` @@ -545,143 +462,6 @@ async function getNanoGptUsage(apiKey: string) { } } -/** - * Decode the `sub` claim of a Cursor JWT (the WorkOS user id). - * Returns null if the token is not a parseable JWT. - */ -function decodeCursorJwtSub(token: string): string | null { - if (!token || typeof token !== "string") return null; - const parts = token.split("."); - if (parts.length !== 3) return null; - try { - let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/"); - while (payload.length % 4 !== 0) payload += "="; - const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf8")); - const sub = decoded?.sub; - return typeof sub === "string" && sub.length > 0 ? sub : null; - } catch { - return null; - } -} - -/** - * Cursor Pro Plan Usage - * Fetches current-billing-cycle spend from the cursor.com dashboard API and exposes three - * windows that mirror the cursor.com/dashboard/spending UI: Total / Auto + Composer / API. - */ -async function getCursorUsage(accessToken: string, providerSpecificData?: unknown) { - if (!accessToken) { - return { message: "Cursor access token missing. Re-import the connection from Cursor IDE." }; - } - - const storedUserId = (() => { - const raw = toRecord(providerSpecificData).userId; - return typeof raw === "string" && raw.length > 0 ? raw : null; - })(); - const userId = storedUserId || decodeCursorJwtSub(accessToken); - - if (!userId) { - return { - message: "Cursor token missing user id. Re-import the connection from Cursor IDE.", - }; - } - - try { - const response = await fetch(CURSOR_USAGE_CONFIG.usageUrl, { - method: "POST", - redirect: "manual", - headers: { - Cookie: `WorkosCursorSessionToken=${userId}::${accessToken}`, - Origin: CURSOR_USAGE_CONFIG.origin, - Referer: CURSOR_USAGE_CONFIG.referer, - "Content-Type": "application/json", - Accept: "application/json", - "User-Agent": CURSOR_USAGE_CONFIG.userAgent, - }, - body: "{}", - }); - - // 3xx redirect to WorkOS authkit means the session cookie was rejected. - if (response.status >= 300 && response.status < 400) { - return { - plan: "Cursor", - message: "Cursor session expired. Re-import the token from Cursor IDE.", - }; - } - - if (!response.ok) { - const errorText = (await response.text()).slice(0, 200); - if (response.status === 401 || response.status === 403) { - return { - plan: "Cursor", - message: "Cursor session unauthorized. Re-import the token from Cursor IDE.", - }; - } - return { - plan: "Cursor", - message: `Cursor usage endpoint error (${response.status}): ${errorText}`, - }; - } - - const data = toRecord(await response.json()); - const planUsage = toRecord(data.planUsage); - - if (Object.keys(planUsage).length === 0) { - return { - plan: "Cursor", - message: "Cursor connected. No active plan usage returned.", - }; - } - - const limitCents = Math.max(0, toNumber(planUsage.limit, 0)); - const totalSpendCents = Math.max(0, toNumber(planUsage.totalSpend, 0)); - const autoPercentUsed = clampPercentage(toNumber(planUsage.autoPercentUsed, 0)); - const apiPercentUsed = clampPercentage(toNumber(planUsage.apiPercentUsed, 0)); - const totalPercentUsed = clampPercentage(toNumber(planUsage.totalPercentUsed, 0)); - - // billingCycleEnd is a numeric-string in ms; coerce so parseResetTime sees a number. - const billingCycleEndMs = toNumber(data.billingCycleEnd, 0); - const resetAt = billingCycleEndMs > 0 ? parseResetTime(billingCycleEndMs) : null; - - // Convert cents → dollars rounded to 2 decimal places. - const toDollars = (cents: number) => Math.round(cents) / 100; - - const limitDollars = toDollars(limitCents); - const buildWindow = (percentUsed: number, usedCentsOverride?: number): UsageQuota => { - const usedCents = - typeof usedCentsOverride === "number" - ? usedCentsOverride - : Math.round((limitCents * percentUsed) / 100); - const used = toDollars(Math.min(usedCents, limitCents)); - const remaining = toDollars(Math.max(limitCents - Math.min(usedCents, limitCents), 0)); - return { - used, - total: limitDollars, - remaining, - remainingPercentage: clampPercentage(100 - percentUsed), - resetAt, - unlimited: false, - }; - }; - - const quotas: Record = { - Total: buildWindow(totalPercentUsed, totalSpendCents), - "Auto + Composer": buildWindow(autoPercentUsed), - API: buildWindow(apiPercentUsed), - }; - - return { - plan: "Cursor Pro", - quotas, - }; - } catch (error) { - return { - plan: "Cursor", - message: `Cursor connected. Unable to fetch usage: ${(error as Error).message}`, - }; - } -} - /** * Single source of truth for which providers have a `getUsageForProvider` * implementation. Consumers like `genericQuotaFetcher.ts` reference this so @@ -1011,421 +791,6 @@ function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null): return "GitHub Copilot"; } -/** - * Claude Usage - Try to fetch from Anthropic API - */ -async function getClaudeUsage(accessToken?: string) { - if (!accessToken) { - return { message: "Claude connected. Access token not available.", bootstrap: null }; - } - - // Refresh bootstrap in parallel; best-effort, failure non-fatal. - const bootstrapPromise = fetchClaudeBootstrap(accessToken).catch(() => null); - // Skip OAuth usage call while this token is cooling down from a recent 429 - // (chat with the same token still works — only the quota endpoint is throttled). - if (isClaudeOauthUsageCoolingDown(accessToken)) { - const legacy = await getClaudeUsageLegacy(accessToken); - return { ...legacy, bootstrap: await bootstrapPromise }; - } - try { - // Real CLI uses axios here, not Stainless — UA is `claude-code/` - // (not `claude-cli/...`) and the shape is simpler than /v1/messages. - const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), 10_000); - let oauthResponse; - try { - oauthResponse = await fetch(CLAUDE_CONFIG.oauthUsageUrl, { - method: "GET", - headers: { - Accept: "application/json, text/plain, */*", - "Accept-Encoding": "gzip, compress, deflate, br", - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": `claude-code/${CLAUDE_CODE_VERSION}`, - "anthropic-beta": "oauth-2025-04-20", - }, - signal: ctrl.signal, - }); - } finally { - clearTimeout(timer); - } - - if (oauthResponse.ok) { - const data = toRecord(await oauthResponse.json()); - const quotas: Record = {}; - - // utilization = percentage USED (e.g., 90 means 90% used, 10% remaining) - // Confirmed via user report #299: Claude.ai shows 87% used = OmniRoute must show 13% remaining. - const hasUtilization = (window: JsonRecord) => - window && typeof window === "object" && safePercentage(window.utilization) !== undefined; - - const createQuotaObject = (window: JsonRecord) => { - const used = safePercentage(window.utilization) as number; // utilization = % used - const remaining = Math.max(0, 100 - used); - return { - used, - total: 100, - remaining, - resetAt: parseResetTime(window.resets_at), - remainingPercentage: remaining, - unlimited: false, - }; - }; - - const fiveHour = toRecord(data.five_hour); - if (hasUtilization(fiveHour)) { - quotas["session (5h)"] = createQuotaObject(fiveHour); - } - - const sevenDay = toRecord(data.seven_day); - if (hasUtilization(sevenDay)) { - quotas["weekly (7d)"] = createQuotaObject(sevenDay); - } - - // Map Anthropic's internal codenames (e.g., omelette → Designer) for display. - const MODEL_DISPLAY_NAMES: Record = { - omelette: "designer", - }; - for (const [key, value] of Object.entries(data)) { - const valueRecord = toRecord(value); - if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(valueRecord)) { - const codename = key.replace("seven_day_", ""); - const modelName = MODEL_DISPLAY_NAMES[codename] || codename; - quotas[`weekly ${modelName} (7d)`] = createQuotaObject(valueRecord); - } - } - - const bootstrap = await bootstrapPromise; - const plan = - getClaudePlanLabel( - typeof data.tier === "string" ? data.tier : null, - typeof data.plan === "string" ? data.plan : null, - typeof data.subscription_type === "string" ? data.subscription_type : null, - bootstrap?.organization_rate_limit_tier - ) ?? undefined; - - return { - ...(plan ? { plan } : {}), - quotas, - extraUsage: data.extra_usage ?? null, - bootstrap, - }; - } - - // Cool down OAuth usage polling after a 429 (quota endpoint only — chat is unaffected). - if (oauthResponse.status === 429) { - markClaudeOauthUsage429(accessToken); - } - - // Fallback: OAuth endpoint returned non-OK, try legacy settings/org endpoint - console.warn( - `[Claude Usage] OAuth endpoint returned ${oauthResponse.status}, falling back to legacy` - ); - const legacy = await getClaudeUsageLegacy(accessToken); - return { ...legacy, bootstrap: await bootstrapPromise }; - } catch (error) { - return { - message: `Claude connected. Unable to fetch usage: ${(error as Error).message}`, - bootstrap: await bootstrapPromise, - }; - } -} - -/** - * Legacy Claude usage fetcher for API key / org admin users. - * Uses /v1/settings + /v1/organizations/{org_id}/usage endpoints. - */ -async function getClaudeUsageLegacy(accessToken?: string) { - try { - const settingsResponse = await fetch(CLAUDE_CONFIG.settingsUrl, { - method: "GET", - headers: { - Authorization: `Bearer ${accessToken}`, - "anthropic-version": CLAUDE_CONFIG.apiVersion, - }, - }); - - if (settingsResponse.ok) { - const settings = toRecord(await settingsResponse.json()); - - const organizationId = - typeof settings.organization_id === "string" ? settings.organization_id : ""; - if (organizationId) { - const usageResponse = await fetch( - CLAUDE_CONFIG.usageUrl.replace("{org_id}", organizationId), - { - method: "GET", - headers: { - Authorization: `Bearer ${accessToken}`, - "anthropic-version": CLAUDE_CONFIG.apiVersion, - }, - } - ); - - if (usageResponse.ok) { - const usage = await usageResponse.json(); - return { - plan: settings.plan || "Unknown", - organization: settings.organization_name, - quotas: usage, - }; - } - } - - return { - plan: settings.plan || "Unknown", - organization: settings.organization_name, - message: "Claude connected. Usage details require admin access.", - }; - } - - return { message: "Claude connected. Usage API requires admin permissions." }; - } catch (error) { - return { message: `Claude connected. Unable to fetch usage: ${(error as Error).message}` }; - } -} - -/** - * Codex (OpenAI) Usage - Fetch from ChatGPT backend API - * IMPORTANT: Uses persisted workspaceId from OAuth to ensure correct workspace binding. - * No fallback to other workspaces - strict binding to user's selected workspace. - */ -async function getCodexUsage( - accessToken?: string, - providerSpecificData: Record = {} -) { - try { - // Use persisted workspace ID from OAuth - NO FALLBACK - const accountId = - typeof providerSpecificData.workspaceId === "string" - ? providerSpecificData.workspaceId - : null; - - const headers: Record = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - Accept: "application/json", - }; - if (accountId) { - headers["chatgpt-account-id"] = accountId; - } - - const response = await fetch(CODEX_CONFIG.usageUrl, { - method: "GET", - headers, - }); - - if (!response.ok) { - if (response.status === 401 || response.status === 403) { - return { - message: `Codex token expired or access denied. Please re-authenticate the connection.`, - }; - } - throw new Error(`Codex API error: ${response.status}`); - } - - const data = await response.json(); - - const { rateLimit, quotas } = buildCodexUsageQuotas(data); - - return { - plan: String(getFieldValue(data, "plan_type", "planType") || "unknown"), - limitReached: Boolean(getFieldValue(rateLimit, "limit_reached", "limitReached")), - quotas, - }; - } catch (error) { - return { message: `Failed to fetch Codex usage: ${(error as Error).message}` }; - } -} - -/** - * Build the Kiro usage result from a GetUsageLimits response. When the account returns no - * usage breakdown (some AWS IAM / Builder ID accounts don't expose per-resource quota via - * GetUsageLimits), return an informative message instead of empty `quotas:{}` — otherwise the - * dashboard renders a blank quota card with no explanation (#3506). Exported for testing. - */ -export function buildKiroUsageResult( - data: JsonRecord -): { plan: string; quotas: Record } | { message: string } { - const usageList = Array.isArray(data.usageBreakdownList) ? data.usageBreakdownList : []; - const quotaInfo: Record = {}; - const resetAt = parseResetTime(data.nextDateReset || data.resetDate); - const overageEnabled = isKiroOverageEnabled(data); - - usageList.forEach((breakdownValue: unknown) => { - const breakdown = toRecord(breakdownValue); - const resourceType = - typeof breakdown.resourceType === "string" ? breakdown.resourceType.toLowerCase() : "unknown"; - const used = toNumber(breakdown.currentUsageWithPrecision, 0); - const total = toNumber(breakdown.usageLimitWithPrecision, 0); - - quotaInfo[resourceType] = buildKiroQuota(used, total, resetAt, overageEnabled); - - const freeTrialInfo = toRecord(breakdown.freeTrialInfo); - if (Object.keys(freeTrialInfo).length > 0) { - const freeUsed = toNumber(freeTrialInfo.currentUsageWithPrecision, 0); - const freeTotal = toNumber(freeTrialInfo.usageLimitWithPrecision, 0); - quotaInfo[`${resourceType}_freetrial`] = buildKiroQuota( - freeUsed, - freeTotal, - resetAt, - overageEnabled - ); - } - }); - - if (Object.keys(quotaInfo).length === 0) { - return { - message: - "Kiro connected, but the account returned no usage breakdown. Some AWS IAM / Builder ID accounts don't expose per-resource quota via GetUsageLimits.", - }; - } - - return { - plan: String(toRecord(data.subscriptionInfo).subscriptionTitle || "").trim() || "Kiro", - quotas: quotaInfo, - }; -} - -/** - * Discover a Kiro/CodeWhisperer profile ARN for an account that didn't persist one (common for - * AWS IAM Identity Center logins and kiro-cli imports). Calls ListAvailableProfiles on the - * region-matched endpoint and prefers a profile whose ARN is in the same region. Returns - * undefined when no profile is available (e.g. the org/token has no Kiro entitlement). - * Exported for testing. - */ -export async function discoverKiroProfileArn( - accessToken: string, - usageBaseUrl: string, - region: string -): Promise { - try { - const response = await fetch(usageBaseUrl, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/x-amz-json-1.0", - "x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles", - Accept: "application/json", - }, - body: JSON.stringify({ maxResults: 10 }), - // Don't let a hung profile lookup block the usage/quota refresh indefinitely. - signal: AbortSignal.timeout(10000), - }); - if (!response.ok) return undefined; - - const data = toRecord(await response.json()); - const profiles = Array.isArray(data.profiles) ? data.profiles : []; - const normalizedRegion = region.toLowerCase(); - const matched = - profiles.find((profile: unknown) => { - const arn = toRecord(profile).arn; - return typeof arn === "string" && arn.toLowerCase().includes(`:${normalizedRegion}:`); - }) || profiles[0]; - const arn = toRecord(matched).arn; - return typeof arn === "string" && arn.length > 0 ? arn : undefined; - } catch { - return undefined; - } -} - -/** - * Kiro (AWS CodeWhisperer) Usage - */ -async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRecord) { - try { - let profileArn = - typeof providerSpecificData?.profileArn === "string" - ? providerSpecificData.profileArn - : undefined; - - // Enterprise IAM Identity Center accounts are region-bound: the profileArn, token and - // endpoint must all match the region. Derive the region from the stored region (preferred) - // or the profileArn, then route to the regional Amazon Q endpoint (us-east-1 keeps the - // legacy codewhisperer host; codewhisperer.{region} does not resolve for other regions). - const regionFromArn = profileArn - ? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1] - : undefined; - const region = - (typeof providerSpecificData?.region === "string" && - providerSpecificData.region.trim().toLowerCase()) || - regionFromArn || - "us-east-1"; - const usageBaseUrl = - region === "us-east-1" ? CODEWHISPERER_BASE_URL : `https://q.${region}.amazonaws.com`; - - // IAM Identity Center logins and kiro-cli imports frequently don't persist a profileArn, which - // previously caused the quota card to show nothing ("0 used"). Discover it on demand from - // ListAvailableProfiles (region-matched) so usage still resolves for those accounts. - if (!profileArn && accessToken) { - profileArn = await discoverKiroProfileArn(accessToken, usageBaseUrl, region); - } - - if (!profileArn) { - return { message: "Kiro connected. Profile ARN not available for quota tracking." }; - } - - // Kiro uses AWS CodeWhisperer GetUsageLimits API - const payload = { - origin: "AI_EDITOR", - profileArn: profileArn, - resourceType: "AGENTIC_REQUEST", - }; - - const response = await fetch(usageBaseUrl, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/x-amz-json-1.0", - "x-amz-target": "AmazonCodeWhispererService.GetUsageLimits", - Accept: "application/json", - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - // Social-auth Kiro accounts (added via /api/oauth/kiro/social-exchange with provider - // Google or GitHub) use a different token format that AWS CodeWhisperer's GetUsageLimits - // routinely rejects with 401/403, even when /messages still works. Surface a clear - // "auth expired, chat may still work" message instead of a generic upstream-error blob - // so the quota card matches what users with legacy social-auth accounts already see. - // Inspired by https://github.com/decolua/9router/pull/620. - if ( - (response.status === 401 || response.status === 403) && - isSocialAuthKiroAccount(providerSpecificData) - ) { - return { - message: "Kiro quota API authentication expired. Chat may still work.", - quotas: {}, - }; - } - const errorText = await response.text(); - throw new Error(`Kiro API error (${response.status}): ${errorText}`); - } - - const data = toRecord(await response.json()); - return buildKiroUsageResult(data); - } catch (error) { - throw new Error(`Failed to fetch Kiro usage: ${error.message}`); - } -} - -/** - * Was this Kiro connection added via the Google/GitHub social-auth device flow - * (POST /api/oauth/kiro/social-exchange)? That route persists - * `{ authMethod: "imported", provider: "Google" | "Github" }` on the connection. - * Builder-ID / IDC / kiro-cli imports use different markers and should keep the - * existing throw-on-failure behavior. - */ -function isSocialAuthKiroAccount(providerSpecificData?: JsonRecord): boolean { - if (!providerSpecificData || providerSpecificData.authMethod !== "imported") return false; - const provider = - typeof providerSpecificData.provider === "string" - ? providerSpecificData.provider.toLowerCase() - : ""; - return provider === "google" || provider === "github"; -} - /** * Vertex AI — SELF-TRACKED spend. * @@ -1473,193 +838,6 @@ async function getVertexUsage(connectionId: string, provider: string) { } } -/** - * Map Kimi membership level to display name - * LEVEL_BASIC = Moderato, LEVEL_INTERMEDIATE = Allegretto, - * LEVEL_ADVANCED = Allegro, LEVEL_STANDARD = Vivace - */ -function getKimiPlanName(level: unknown): string { - if (!level) return ""; - const normalizedLevel = String(level); - - const levelMap = { - LEVEL_BASIC: "Moderato", - LEVEL_INTERMEDIATE: "Allegretto", - LEVEL_ADVANCED: "Allegro", - LEVEL_STANDARD: "Vivace", - }; - - return ( - levelMap[normalizedLevel as keyof typeof levelMap] || - normalizedLevel.replace("LEVEL_", "").toLowerCase() - ); -} - -/** - * Kimi Coding Usage - Fetch quota from Kimi API - * Uses the official /v1/usages endpoint with custom X-Msh-* headers - */ -async function getKimiUsage(accessToken?: string, apiKey?: string) { - // Generate device info for headers (same as OAuth flow) - const deviceId = "kimi-usage-" + Date.now(); - const platform = "omniroute"; - const version = "2.1.2"; - const deviceModel = - typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown"; - - // API key auth takes precedence — Kimi's /usages endpoint accepts the same - // API key used for /messages (verified live: responds with - // authentication.method = METHOD_API_KEY). OAuth flow falls through to the - // Bearer + device-headers shape used by Kimi Coding OAuth. - const useApiKey = typeof apiKey === "string" && apiKey.length > 0; - - const authHeaders: Record = useApiKey - ? { "x-api-key": apiKey as string } - : { - Authorization: `Bearer ${accessToken}`, - "X-Msh-Platform": platform, - "X-Msh-Version": version, - "X-Msh-Device-Model": deviceModel, - "X-Msh-Device-Id": deviceId, - }; - - try { - const response = await fetch(KIMI_CONFIG.usageUrl, { - method: "GET", - headers: { - ...authHeaders, - "Content-Type": "application/json", - }, - }); - - const responseText = await response.text(); - - if (!response.ok) { - return { - plan: "Kimi Coding", - message: `Kimi Coding connected. API Error ${response.status}: ${responseText.slice(0, 100)}`, - }; - } - - let data; - try { - data = JSON.parse(responseText); - } catch { - return { - plan: "Kimi Coding", - message: "Kimi Coding connected. Invalid JSON response from API.", - }; - } - - const quotas: Record = {}; - const dataObj = toRecord(data); - - // Parse Kimi usage response format - // Format: { user: {...}, usage: { limit: "100", used: "92", remaining: "8", resetTime: "..." }, limits: [...] } - const usageObj = toRecord(dataObj.usage); - - // Check for Kimi's actual usage fields (strings, not numbers) - const usageLimit = toNumber(usageObj.limit || usageObj.Limit, 0); - const usageUsed = toNumber(usageObj.used || usageObj.Used, 0); - const usageRemaining = toNumber(usageObj.remaining || usageObj.Remaining, 0); - const usageResetTime = - usageObj.resetTime || usageObj.ResetTime || usageObj.reset_at || usageObj.resetAt; - - if (usageLimit > 0) { - const percentRemaining = usageLimit > 0 ? (usageRemaining / usageLimit) * 100 : 0; - - quotas["Weekly"] = { - used: usageUsed, - total: usageLimit, - remaining: usageRemaining, - remainingPercentage: percentRemaining, - resetAt: parseResetTime(usageResetTime), - unlimited: false, - }; - } - - // Also parse limits array for rate limits - const limitsArray = Array.isArray(dataObj.limits) ? dataObj.limits : []; - for (let i = 0; i < limitsArray.length; i++) { - const limitItem = toRecord(limitsArray[i]); - const window = toRecord(limitItem.window); - const detail = toRecord(limitItem.detail); - - const limit = toNumber(detail.limit || detail.Limit, 0); - const remaining = toNumber(detail.remaining || detail.Remaining, 0); - const resetTime = detail.resetTime || detail.reset_at || detail.resetAt; - - if (limit > 0) { - quotas["Ratelimit"] = { - used: limit - remaining, - total: limit, - remaining, - remainingPercentage: limit > 0 ? (remaining / limit) * 100 : 0, - resetAt: parseResetTime(resetTime), - unlimited: false, - }; - } - } - - // Check for quota windows (Claude-like format with utilization) as fallback - const hasUtilization = (window: JsonRecord) => - window && typeof window === "object" && safePercentage(window.utilization) !== undefined; - - const createQuotaObject = (window: JsonRecord) => { - const remaining = safePercentage(window.utilization) as number; - const used = 100 - remaining; - return { - used, - total: 100, - remaining, - resetAt: parseResetTime(window.resets_at), - remainingPercentage: remaining, - unlimited: false, - }; - }; - - if (hasUtilization(toRecord(dataObj.five_hour))) { - quotas["session (5h)"] = createQuotaObject(toRecord(dataObj.five_hour)); - } - - if (hasUtilization(toRecord(dataObj.seven_day))) { - quotas["weekly (7d)"] = createQuotaObject(toRecord(dataObj.seven_day)); - } - - // Check for model-specific quotas - for (const [key, value] of Object.entries(dataObj)) { - const valueRecord = toRecord(value); - if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(valueRecord)) { - const modelName = key.replace("seven_day_", ""); - quotas[`weekly ${modelName} (7d)`] = createQuotaObject(valueRecord); - } - } - - if (Object.keys(quotas).length > 0) { - const userRecord = toRecord(dataObj.user); - const membershipLevel = toRecord(userRecord.membership).level; - const planName = getKimiPlanName(membershipLevel); - return { - plan: planName || "Kimi Coding", - quotas, - }; - } - - // No quota data in response - const userRecord = toRecord(dataObj.user); - const membershipLevel = toRecord(userRecord.membership).level; - const planName = getKimiPlanName(membershipLevel); - return { - plan: planName || "Kimi Coding", - message: "Kimi Coding connected. Usage tracked per request.", - }; - } catch (error) { - return { - message: `Kimi Coding connected. Unable to fetch usage: ${(error as Error).message}`, - }; - } -} - /** * Qwen Usage */ diff --git a/open-sse/services/usage/claude.ts b/open-sse/services/usage/claude.ts new file mode 100644 index 0000000000..e205b473ea --- /dev/null +++ b/open-sse/services/usage/claude.ts @@ -0,0 +1,216 @@ +/** + * usage/claude.ts — Claude (Anthropic OAuth + legacy org) usage fetcher + plan-label helper. + * + * Extracted from services/usage.ts (god-file decomposition): the Claude family — the API + * config, the plan-label picker, the OAuth usage fetcher (getClaudeUsage) with its legacy + * settings/org fallback (getClaudeUsageLegacy). Depends only on the sibling scalar/quota + * leaves + Claude identity/cooldown helpers + safePercentage — no host coupling — so it + * lives as a co-located provider leaf. usage.ts imports getClaudeUsage (dispatcher) + + * getClaudePlanLabel (__testing). Behavior-preserving move. + */ + +import { safePercentage } from "@/shared/utils/formatting"; +import { CLAUDE_CODE_VERSION, fetchClaudeBootstrap } from "../../executors/claudeIdentity.ts"; +import { isClaudeOauthUsageCoolingDown, markClaudeOauthUsage429 } from "../claudeUsageCooldown.ts"; +import { toRecord } from "./scalars.ts"; +import { type UsageQuota, parseResetTime } from "./quota.ts"; + +type JsonRecord = Record; + +// 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", +}; + +export function getClaudePlanLabel(...candidates: Array): string | null { + for (const candidate of candidates) { + if (typeof candidate !== "string") continue; + const trimmed = candidate.trim(); + if ( + !trimmed || + trimmed.toLowerCase() === "claude code" || + trimmed.toLowerCase() === "unknown" + ) { + continue; + } + return trimmed; + } + return null; +} + +/** + * Claude Usage - Try to fetch from Anthropic API + */ +export async function getClaudeUsage(accessToken?: string) { + if (!accessToken) { + return { message: "Claude connected. Access token not available.", bootstrap: null }; + } + + // Refresh bootstrap in parallel; best-effort, failure non-fatal. + const bootstrapPromise = fetchClaudeBootstrap(accessToken).catch(() => null); + // Skip OAuth usage call while this token is cooling down from a recent 429 + // (chat with the same token still works — only the quota endpoint is throttled). + if (isClaudeOauthUsageCoolingDown(accessToken)) { + const legacy = await getClaudeUsageLegacy(accessToken); + return { ...legacy, bootstrap: await bootstrapPromise }; + } + try { + // Real CLI uses axios here, not Stainless — UA is `claude-code/` + // (not `claude-cli/...`) and the shape is simpler than /v1/messages. + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 10_000); + let oauthResponse; + try { + oauthResponse = await fetch(CLAUDE_CONFIG.oauthUsageUrl, { + method: "GET", + headers: { + Accept: "application/json, text/plain, */*", + "Accept-Encoding": "gzip, compress, deflate, br", + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": `claude-code/${CLAUDE_CODE_VERSION}`, + "anthropic-beta": "oauth-2025-04-20", + }, + signal: ctrl.signal, + }); + } finally { + clearTimeout(timer); + } + + if (oauthResponse.ok) { + const data = toRecord(await oauthResponse.json()); + const quotas: Record = {}; + + // utilization = percentage USED (e.g., 90 means 90% used, 10% remaining) + // Confirmed via user report #299: Claude.ai shows 87% used = OmniRoute must show 13% remaining. + const hasUtilization = (window: JsonRecord) => + window && typeof window === "object" && safePercentage(window.utilization) !== undefined; + + const createQuotaObject = (window: JsonRecord) => { + const used = safePercentage(window.utilization) as number; // utilization = % used + const remaining = Math.max(0, 100 - used); + return { + used, + total: 100, + remaining, + resetAt: parseResetTime(window.resets_at), + remainingPercentage: remaining, + unlimited: false, + }; + }; + + const fiveHour = toRecord(data.five_hour); + if (hasUtilization(fiveHour)) { + quotas["session (5h)"] = createQuotaObject(fiveHour); + } + + const sevenDay = toRecord(data.seven_day); + if (hasUtilization(sevenDay)) { + quotas["weekly (7d)"] = createQuotaObject(sevenDay); + } + + // Map Anthropic's internal codenames (e.g., omelette → Designer) for display. + const MODEL_DISPLAY_NAMES: Record = { + omelette: "designer", + }; + for (const [key, value] of Object.entries(data)) { + const valueRecord = toRecord(value); + if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(valueRecord)) { + const codename = key.replace("seven_day_", ""); + const modelName = MODEL_DISPLAY_NAMES[codename] || codename; + quotas[`weekly ${modelName} (7d)`] = createQuotaObject(valueRecord); + } + } + + const bootstrap = await bootstrapPromise; + const plan = + getClaudePlanLabel( + typeof data.tier === "string" ? data.tier : null, + typeof data.plan === "string" ? data.plan : null, + typeof data.subscription_type === "string" ? data.subscription_type : null, + bootstrap?.organization_rate_limit_tier + ) ?? undefined; + + return { + ...(plan ? { plan } : {}), + quotas, + extraUsage: data.extra_usage ?? null, + bootstrap, + }; + } + + // Cool down OAuth usage polling after a 429 (quota endpoint only — chat is unaffected). + if (oauthResponse.status === 429) { + markClaudeOauthUsage429(accessToken); + } + + // Fallback: OAuth endpoint returned non-OK, try legacy settings/org endpoint + console.warn( + `[Claude Usage] OAuth endpoint returned ${oauthResponse.status}, falling back to legacy` + ); + const legacy = await getClaudeUsageLegacy(accessToken); + return { ...legacy, bootstrap: await bootstrapPromise }; + } catch (error) { + return { + message: `Claude connected. Unable to fetch usage: ${(error as Error).message}`, + bootstrap: await bootstrapPromise, + }; + } +} + +/** + * Legacy Claude usage fetcher for API key / org admin users. + * Uses /v1/settings + /v1/organizations/{org_id}/usage endpoints. + */ +async function getClaudeUsageLegacy(accessToken?: string) { + try { + const settingsResponse = await fetch(CLAUDE_CONFIG.settingsUrl, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "anthropic-version": CLAUDE_CONFIG.apiVersion, + }, + }); + + if (settingsResponse.ok) { + const settings = toRecord(await settingsResponse.json()); + + const organizationId = + typeof settings.organization_id === "string" ? settings.organization_id : ""; + if (organizationId) { + const usageResponse = await fetch( + CLAUDE_CONFIG.usageUrl.replace("{org_id}", organizationId), + { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "anthropic-version": CLAUDE_CONFIG.apiVersion, + }, + } + ); + + if (usageResponse.ok) { + const usage = await usageResponse.json(); + return { + plan: settings.plan || "Unknown", + organization: settings.organization_name, + quotas: usage, + }; + } + } + + return { + plan: settings.plan || "Unknown", + organization: settings.organization_name, + message: "Claude connected. Usage details require admin access.", + }; + } + + return { message: "Claude connected. Usage API requires admin permissions." }; + } catch (error) { + return { message: `Claude connected. Unable to fetch usage: ${(error as Error).message}` }; + } +} diff --git a/open-sse/services/usage/codex.ts b/open-sse/services/usage/codex.ts new file mode 100644 index 0000000000..b1d0261f03 --- /dev/null +++ b/open-sse/services/usage/codex.ts @@ -0,0 +1,70 @@ +/** + * usage/codex.ts — Codex (OpenAI / ChatGPT backend) usage fetcher. + * + * Extracted from services/usage.ts (god-file decomposition): the Codex family — the ChatGPT + * backend usage-API config and the getCodexUsage fetcher that reads the persisted workspace + * binding and shapes quotas via buildCodexUsageQuotas. Depends only on the scalar leaf + + * codexUsageQuotas — no host coupling — so it lives as a co-located provider leaf. usage.ts + * imports getCodexUsage (dispatcher). Behavior-preserving move. + */ + +import { buildCodexUsageQuotas } from "../codexUsageQuotas.ts"; +import { getFieldValue } from "./scalars.ts"; + +// Codex (OpenAI) API config +const CODEX_CONFIG = { + usageUrl: "https://chatgpt.com/backend-api/wham/usage", +}; + +/** + * Codex (OpenAI) Usage - Fetch from ChatGPT backend API + * IMPORTANT: Uses persisted workspaceId from OAuth to ensure correct workspace binding. + * No fallback to other workspaces - strict binding to user's selected workspace. + */ +export async function getCodexUsage( + accessToken?: string, + providerSpecificData: Record = {} +) { + try { + // Use persisted workspace ID from OAuth - NO FALLBACK + const accountId = + typeof providerSpecificData.workspaceId === "string" + ? providerSpecificData.workspaceId + : null; + + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + Accept: "application/json", + }; + if (accountId) { + headers["chatgpt-account-id"] = accountId; + } + + const response = await fetch(CODEX_CONFIG.usageUrl, { + method: "GET", + headers, + }); + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + return { + message: `Codex token expired or access denied. Please re-authenticate the connection.`, + }; + } + throw new Error(`Codex API error: ${response.status}`); + } + + const data = await response.json(); + + const { rateLimit, quotas } = buildCodexUsageQuotas(data); + + return { + plan: String(getFieldValue(data, "plan_type", "planType") || "unknown"), + limitReached: Boolean(getFieldValue(rateLimit, "limit_reached", "limitReached")), + quotas, + }; + } catch (error) { + return { message: `Failed to fetch Codex usage: ${(error as Error).message}` }; + } +} diff --git a/open-sse/services/usage/cursor.ts b/open-sse/services/usage/cursor.ts new file mode 100644 index 0000000000..67acc09a6a --- /dev/null +++ b/open-sse/services/usage/cursor.ts @@ -0,0 +1,161 @@ +/** + * usage/cursor.ts — Cursor (Pro) usage fetcher + JWT/config helpers. + * + * Extracted from services/usage.ts (god-file decomposition): the Cursor family — the + * dashboard usage-API config, the WorkOS JWT `sub` decoder, and the getCursorUsage fetcher + * that probes the cursor.com/dashboard/spending endpoint. Depends only on the sibling + * scalar/quota leaves — no host coupling — so it lives as a co-located provider leaf. + * usage.ts imports getCursorUsage (dispatcher). Behavior-preserving move. + */ + +import { toRecord, toNumber, clampPercentage } from "./scalars.ts"; +import { type UsageQuota, parseResetTime } from "./quota.ts"; + +// Cursor dashboard usage API config +// The endpoint that powers https://cursor.com/dashboard/spending. Validates the WorkOS +// session via the WorkosCursorSessionToken cookie (format: `${userId}::${jwt}`) and +// rejects requests without a matching Origin/Referer (Invalid origin for state-changing request). +const CURSOR_USAGE_CONFIG = { + usageUrl: "https://cursor.com/api/dashboard/get-current-period-usage", + origin: "https://cursor.com", + referer: "https://cursor.com/dashboard/spending", + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", +}; + +/** + * Decode the `sub` claim of a Cursor JWT (the WorkOS user id). + * Returns null if the token is not a parseable JWT. + */ +function decodeCursorJwtSub(token: string): string | null { + if (!token || typeof token !== "string") return null; + const parts = token.split("."); + if (parts.length !== 3) return null; + try { + let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + while (payload.length % 4 !== 0) payload += "="; + const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf8")); + const sub = decoded?.sub; + return typeof sub === "string" && sub.length > 0 ? sub : null; + } catch { + return null; + } +} + +/** + * Cursor Pro Plan Usage + * Fetches current-billing-cycle spend from the cursor.com dashboard API and exposes three + * windows that mirror the cursor.com/dashboard/spending UI: Total / Auto + Composer / API. + */ +export async function getCursorUsage(accessToken: string, providerSpecificData?: unknown) { + if (!accessToken) { + return { message: "Cursor access token missing. Re-import the connection from Cursor IDE." }; + } + + const storedUserId = (() => { + const raw = toRecord(providerSpecificData).userId; + return typeof raw === "string" && raw.length > 0 ? raw : null; + })(); + const userId = storedUserId || decodeCursorJwtSub(accessToken); + + if (!userId) { + return { + message: "Cursor token missing user id. Re-import the connection from Cursor IDE.", + }; + } + + try { + const response = await fetch(CURSOR_USAGE_CONFIG.usageUrl, { + method: "POST", + redirect: "manual", + headers: { + Cookie: `WorkosCursorSessionToken=${userId}::${accessToken}`, + Origin: CURSOR_USAGE_CONFIG.origin, + Referer: CURSOR_USAGE_CONFIG.referer, + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": CURSOR_USAGE_CONFIG.userAgent, + }, + body: "{}", + }); + + // 3xx redirect to WorkOS authkit means the session cookie was rejected. + if (response.status >= 300 && response.status < 400) { + return { + plan: "Cursor", + message: "Cursor session expired. Re-import the token from Cursor IDE.", + }; + } + + if (!response.ok) { + const errorText = (await response.text()).slice(0, 200); + if (response.status === 401 || response.status === 403) { + return { + plan: "Cursor", + message: "Cursor session unauthorized. Re-import the token from Cursor IDE.", + }; + } + return { + plan: "Cursor", + message: `Cursor usage endpoint error (${response.status}): ${errorText}`, + }; + } + + const data = toRecord(await response.json()); + const planUsage = toRecord(data.planUsage); + + if (Object.keys(planUsage).length === 0) { + return { + plan: "Cursor", + message: "Cursor connected. No active plan usage returned.", + }; + } + + const limitCents = Math.max(0, toNumber(planUsage.limit, 0)); + const totalSpendCents = Math.max(0, toNumber(planUsage.totalSpend, 0)); + const autoPercentUsed = clampPercentage(toNumber(planUsage.autoPercentUsed, 0)); + const apiPercentUsed = clampPercentage(toNumber(planUsage.apiPercentUsed, 0)); + const totalPercentUsed = clampPercentage(toNumber(planUsage.totalPercentUsed, 0)); + + // billingCycleEnd is a numeric-string in ms; coerce so parseResetTime sees a number. + const billingCycleEndMs = toNumber(data.billingCycleEnd, 0); + const resetAt = billingCycleEndMs > 0 ? parseResetTime(billingCycleEndMs) : null; + + // Convert cents → dollars rounded to 2 decimal places. + const toDollars = (cents: number) => Math.round(cents) / 100; + + const limitDollars = toDollars(limitCents); + const buildWindow = (percentUsed: number, usedCentsOverride?: number): UsageQuota => { + const usedCents = + typeof usedCentsOverride === "number" + ? usedCentsOverride + : Math.round((limitCents * percentUsed) / 100); + const used = toDollars(Math.min(usedCents, limitCents)); + const remaining = toDollars(Math.max(limitCents - Math.min(usedCents, limitCents), 0)); + return { + used, + total: limitDollars, + remaining, + remainingPercentage: clampPercentage(100 - percentUsed), + resetAt, + unlimited: false, + }; + }; + + const quotas: Record = { + Total: buildWindow(totalPercentUsed, totalSpendCents), + "Auto + Composer": buildWindow(autoPercentUsed), + API: buildWindow(apiPercentUsed), + }; + + return { + plan: "Cursor Pro", + quotas, + }; + } catch (error) { + return { + plan: "Cursor", + message: `Cursor connected. Unable to fetch usage: ${(error as Error).message}`, + }; + } +} diff --git a/open-sse/services/usage/kimi.ts b/open-sse/services/usage/kimi.ts new file mode 100644 index 0000000000..f5e0657475 --- /dev/null +++ b/open-sse/services/usage/kimi.ts @@ -0,0 +1,209 @@ +/** + * usage/kimi.ts — Kimi Coding (kimi-coding / kimi-coding-apikey) usage fetcher + helpers. + * + * Extracted from services/usage.ts (god-file decomposition): the Kimi family — the coding + * API config, membership-level → display-name mapping, and the getKimiUsage fetcher that + * probes the official /v1/usages endpoint. Depends only on the sibling scalar/quota leaves + * plus safePercentage — no host coupling — so it lives as a co-located provider leaf. + * usage.ts imports getKimiUsage (dispatcher). Behavior-preserving move. + */ + +import { safePercentage } from "@/shared/utils/formatting"; +import { toRecord, toNumber } from "./scalars.ts"; +import { type UsageQuota, parseResetTime } from "./quota.ts"; + +type JsonRecord = Record; + +// Kimi Coding API config +const KIMI_CONFIG = { + baseUrl: "https://api.kimi.com/coding/v1", + usageUrl: "https://api.kimi.com/coding/v1/usages", + apiVersion: "2023-06-01", +}; + +/** + * Map Kimi membership level to display name + * LEVEL_BASIC = Moderato, LEVEL_INTERMEDIATE = Allegretto, + * LEVEL_ADVANCED = Allegro, LEVEL_STANDARD = Vivace + */ +function getKimiPlanName(level: unknown): string { + if (!level) return ""; + const normalizedLevel = String(level); + + const levelMap = { + LEVEL_BASIC: "Moderato", + LEVEL_INTERMEDIATE: "Allegretto", + LEVEL_ADVANCED: "Allegro", + LEVEL_STANDARD: "Vivace", + }; + + return ( + levelMap[normalizedLevel as keyof typeof levelMap] || + normalizedLevel.replace("LEVEL_", "").toLowerCase() + ); +} + +/** + * Kimi Coding Usage - Fetch quota from Kimi API + * Uses the official /v1/usages endpoint with custom X-Msh-* headers + */ +export async function getKimiUsage(accessToken?: string, apiKey?: string) { + // Generate device info for headers (same as OAuth flow) + const deviceId = "kimi-usage-" + Date.now(); + const platform = "omniroute"; + const version = "2.1.2"; + const deviceModel = + typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown"; + + // API key auth takes precedence — Kimi's /usages endpoint accepts the same + // API key used for /messages (verified live: responds with + // authentication.method = METHOD_API_KEY). OAuth flow falls through to the + // Bearer + device-headers shape used by Kimi Coding OAuth. + const useApiKey = typeof apiKey === "string" && apiKey.length > 0; + + const authHeaders: Record = useApiKey + ? { "x-api-key": apiKey as string } + : { + Authorization: `Bearer ${accessToken}`, + "X-Msh-Platform": platform, + "X-Msh-Version": version, + "X-Msh-Device-Model": deviceModel, + "X-Msh-Device-Id": deviceId, + }; + + try { + const response = await fetch(KIMI_CONFIG.usageUrl, { + method: "GET", + headers: { + ...authHeaders, + "Content-Type": "application/json", + }, + }); + + const responseText = await response.text(); + + if (!response.ok) { + return { + plan: "Kimi Coding", + message: `Kimi Coding connected. API Error ${response.status}: ${responseText.slice(0, 100)}`, + }; + } + + let data; + try { + data = JSON.parse(responseText); + } catch { + return { + plan: "Kimi Coding", + message: "Kimi Coding connected. Invalid JSON response from API.", + }; + } + + const quotas: Record = {}; + const dataObj = toRecord(data); + + // Parse Kimi usage response format + // Format: { user: {...}, usage: { limit: "100", used: "92", remaining: "8", resetTime: "..." }, limits: [...] } + const usageObj = toRecord(dataObj.usage); + + // Check for Kimi's actual usage fields (strings, not numbers) + const usageLimit = toNumber(usageObj.limit || usageObj.Limit, 0); + const usageUsed = toNumber(usageObj.used || usageObj.Used, 0); + const usageRemaining = toNumber(usageObj.remaining || usageObj.Remaining, 0); + const usageResetTime = + usageObj.resetTime || usageObj.ResetTime || usageObj.reset_at || usageObj.resetAt; + + if (usageLimit > 0) { + const percentRemaining = usageLimit > 0 ? (usageRemaining / usageLimit) * 100 : 0; + + quotas["Weekly"] = { + used: usageUsed, + total: usageLimit, + remaining: usageRemaining, + remainingPercentage: percentRemaining, + resetAt: parseResetTime(usageResetTime), + unlimited: false, + }; + } + + // Also parse limits array for rate limits + const limitsArray = Array.isArray(dataObj.limits) ? dataObj.limits : []; + for (let i = 0; i < limitsArray.length; i++) { + const limitItem = toRecord(limitsArray[i]); + const window = toRecord(limitItem.window); + const detail = toRecord(limitItem.detail); + + const limit = toNumber(detail.limit || detail.Limit, 0); + const remaining = toNumber(detail.remaining || detail.Remaining, 0); + const resetTime = detail.resetTime || detail.reset_at || detail.resetAt; + + if (limit > 0) { + quotas["Ratelimit"] = { + used: limit - remaining, + total: limit, + remaining, + remainingPercentage: limit > 0 ? (remaining / limit) * 100 : 0, + resetAt: parseResetTime(resetTime), + unlimited: false, + }; + } + } + + // Check for quota windows (Claude-like format with utilization) as fallback + const hasUtilization = (window: JsonRecord) => + window && typeof window === "object" && safePercentage(window.utilization) !== undefined; + + const createQuotaObject = (window: JsonRecord) => { + const remaining = safePercentage(window.utilization) as number; + const used = 100 - remaining; + return { + used, + total: 100, + remaining, + resetAt: parseResetTime(window.resets_at), + remainingPercentage: remaining, + unlimited: false, + }; + }; + + if (hasUtilization(toRecord(dataObj.five_hour))) { + quotas["session (5h)"] = createQuotaObject(toRecord(dataObj.five_hour)); + } + + if (hasUtilization(toRecord(dataObj.seven_day))) { + quotas["weekly (7d)"] = createQuotaObject(toRecord(dataObj.seven_day)); + } + + // Check for model-specific quotas + for (const [key, value] of Object.entries(dataObj)) { + const valueRecord = toRecord(value); + if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(valueRecord)) { + const modelName = key.replace("seven_day_", ""); + quotas[`weekly ${modelName} (7d)`] = createQuotaObject(valueRecord); + } + } + + if (Object.keys(quotas).length > 0) { + const userRecord = toRecord(dataObj.user); + const membershipLevel = toRecord(userRecord.membership).level; + const planName = getKimiPlanName(membershipLevel); + return { + plan: planName || "Kimi Coding", + quotas, + }; + } + + // No quota data in response + const userRecord = toRecord(dataObj.user); + const membershipLevel = toRecord(userRecord.membership).level; + const planName = getKimiPlanName(membershipLevel); + return { + plan: planName || "Kimi Coding", + message: "Kimi Coding connected. Usage tracked per request.", + }; + } catch (error) { + return { + message: `Kimi Coding connected. Unable to fetch usage: ${(error as Error).message}`, + }; + } +} diff --git a/open-sse/services/usage/kiro.ts b/open-sse/services/usage/kiro.ts new file mode 100644 index 0000000000..9b85579103 --- /dev/null +++ b/open-sse/services/usage/kiro.ts @@ -0,0 +1,243 @@ +/** + * usage/kiro.ts — Kiro / Amazon Q (AWS CodeWhisperer) usage fetcher + quota helpers. + * + * Extracted from services/usage.ts (god-file decomposition): the Kiro family — overage + * detection, per-resource quota assembly (buildKiroQuota / buildKiroUsageResult), region-aware + * profile-ARN discovery, the social-auth account marker, and the getKiroUsage fetcher that + * calls GetUsageLimits on the region-matched CodeWhisperer endpoint. Depends only on the + * sibling scalar/quota leaves — no host coupling — so it lives as a co-located provider leaf. + * usage.ts imports getKiroUsage (dispatcher) + re-exports buildKiroUsageResult / + * discoverKiroProfileArn (external kiro tests import them from services/usage) and pulls + * getKiroUsage into __testing. Behavior-preserving move. + */ + +import { toRecord, toNumber } from "./scalars.ts"; +import { type UsageQuota, parseResetTime } from "./quota.ts"; + +type JsonRecord = Record; + +const CODEWHISPERER_BASE_URL = + process.env.OMNIROUTE_CODEWHISPERER_BASE_URL ?? "https://codewhisperer.us-east-1.amazonaws.com"; + +function isKiroOverageEnabled(data: JsonRecord): boolean { + const overageConfiguration = toRecord(data.overageConfiguration); + const overageStatus = String(overageConfiguration.overageStatus || "") + .trim() + .toUpperCase(); + + return ( + overageStatus === "ENABLED" || + data.overageEnabled === true || + overageConfiguration.overageEnabled === true + ); +} + +function buildKiroQuota( + used: number, + total: number, + resetAt: string | null, + overageEnabled: boolean +): UsageQuota { + const remaining = total - used; + + if (!overageEnabled) { + return { used, total, remaining, resetAt, unlimited: false }; + } + + return { + used, + total, + remaining, + remainingPercentage: 100, + resetAt, + unlimited: true, + }; +} + +/** + * Build the Kiro usage result from a GetUsageLimits response. When the account returns no + * usage breakdown (some AWS IAM / Builder ID accounts don't expose per-resource quota via + * GetUsageLimits), return an informative message instead of empty `quotas:{}` — otherwise the + * dashboard renders a blank quota card with no explanation (#3506). Exported for testing. + */ +export function buildKiroUsageResult( + data: JsonRecord +): { plan: string; quotas: Record } | { message: string } { + const usageList = Array.isArray(data.usageBreakdownList) ? data.usageBreakdownList : []; + const quotaInfo: Record = {}; + const resetAt = parseResetTime(data.nextDateReset || data.resetDate); + const overageEnabled = isKiroOverageEnabled(data); + + usageList.forEach((breakdownValue: unknown) => { + const breakdown = toRecord(breakdownValue); + const resourceType = + typeof breakdown.resourceType === "string" ? breakdown.resourceType.toLowerCase() : "unknown"; + const used = toNumber(breakdown.currentUsageWithPrecision, 0); + const total = toNumber(breakdown.usageLimitWithPrecision, 0); + + quotaInfo[resourceType] = buildKiroQuota(used, total, resetAt, overageEnabled); + + const freeTrialInfo = toRecord(breakdown.freeTrialInfo); + if (Object.keys(freeTrialInfo).length > 0) { + const freeUsed = toNumber(freeTrialInfo.currentUsageWithPrecision, 0); + const freeTotal = toNumber(freeTrialInfo.usageLimitWithPrecision, 0); + quotaInfo[`${resourceType}_freetrial`] = buildKiroQuota( + freeUsed, + freeTotal, + resetAt, + overageEnabled + ); + } + }); + + if (Object.keys(quotaInfo).length === 0) { + return { + message: + "Kiro connected, but the account returned no usage breakdown. Some AWS IAM / Builder ID accounts don't expose per-resource quota via GetUsageLimits.", + }; + } + + return { + plan: String(toRecord(data.subscriptionInfo).subscriptionTitle || "").trim() || "Kiro", + quotas: quotaInfo, + }; +} + +/** + * Discover a Kiro/CodeWhisperer profile ARN for an account that didn't persist one (common for + * AWS IAM Identity Center logins and kiro-cli imports). Calls ListAvailableProfiles on the + * region-matched endpoint and prefers a profile whose ARN is in the same region. Returns + * undefined when no profile is available (e.g. the org/token has no Kiro entitlement). + * Exported for testing. + */ +export async function discoverKiroProfileArn( + accessToken: string, + usageBaseUrl: string, + region: string +): Promise { + try { + const response = await fetch(usageBaseUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/x-amz-json-1.0", + "x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles", + Accept: "application/json", + }, + body: JSON.stringify({ maxResults: 10 }), + // Don't let a hung profile lookup block the usage/quota refresh indefinitely. + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) return undefined; + + const data = toRecord(await response.json()); + const profiles = Array.isArray(data.profiles) ? data.profiles : []; + const normalizedRegion = region.toLowerCase(); + const matched = + profiles.find((profile: unknown) => { + const arn = toRecord(profile).arn; + return typeof arn === "string" && arn.toLowerCase().includes(`:${normalizedRegion}:`); + }) || profiles[0]; + const arn = toRecord(matched).arn; + return typeof arn === "string" && arn.length > 0 ? arn : undefined; + } catch { + return undefined; + } +} + +/** + * Kiro (AWS CodeWhisperer) Usage + */ +export async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRecord) { + try { + let profileArn = + typeof providerSpecificData?.profileArn === "string" + ? providerSpecificData.profileArn + : undefined; + + // Enterprise IAM Identity Center accounts are region-bound: the profileArn, token and + // endpoint must all match the region. Derive the region from the stored region (preferred) + // or the profileArn, then route to the regional Amazon Q endpoint (us-east-1 keeps the + // legacy codewhisperer host; codewhisperer.{region} does not resolve for other regions). + const regionFromArn = profileArn + ? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1] + : undefined; + const region = + (typeof providerSpecificData?.region === "string" && + providerSpecificData.region.trim().toLowerCase()) || + regionFromArn || + "us-east-1"; + const usageBaseUrl = + region === "us-east-1" ? CODEWHISPERER_BASE_URL : `https://q.${region}.amazonaws.com`; + + // IAM Identity Center logins and kiro-cli imports frequently don't persist a profileArn, which + // previously caused the quota card to show nothing ("0 used"). Discover it on demand from + // ListAvailableProfiles (region-matched) so usage still resolves for those accounts. + if (!profileArn && accessToken) { + profileArn = await discoverKiroProfileArn(accessToken, usageBaseUrl, region); + } + + if (!profileArn) { + return { message: "Kiro connected. Profile ARN not available for quota tracking." }; + } + + // Kiro uses AWS CodeWhisperer GetUsageLimits API + const payload = { + origin: "AI_EDITOR", + profileArn: profileArn, + resourceType: "AGENTIC_REQUEST", + }; + + const response = await fetch(usageBaseUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/x-amz-json-1.0", + "x-amz-target": "AmazonCodeWhispererService.GetUsageLimits", + Accept: "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + // Social-auth Kiro accounts (added via /api/oauth/kiro/social-exchange with provider + // Google or GitHub) use a different token format that AWS CodeWhisperer's GetUsageLimits + // routinely rejects with 401/403, even when /messages still works. Surface a clear + // "auth expired, chat may still work" message instead of a generic upstream-error blob + // so the quota card matches what users with legacy social-auth accounts already see. + // Inspired by https://github.com/decolua/9router/pull/620. + if ( + (response.status === 401 || response.status === 403) && + isSocialAuthKiroAccount(providerSpecificData) + ) { + return { + message: "Kiro quota API authentication expired. Chat may still work.", + quotas: {}, + }; + } + const errorText = await response.text(); + throw new Error(`Kiro API error (${response.status}): ${errorText}`); + } + + const data = toRecord(await response.json()); + return buildKiroUsageResult(data); + } catch (error) { + throw new Error(`Failed to fetch Kiro usage: ${error.message}`); + } +} + +/** + * Was this Kiro connection added via the Google/GitHub social-auth device flow + * (POST /api/oauth/kiro/social-exchange)? That route persists + * `{ authMethod: "imported", provider: "Google" | "Github" }` on the connection. + * Builder-ID / IDC / kiro-cli imports use different markers and should keep the + * existing throw-on-failure behavior. + */ +function isSocialAuthKiroAccount(providerSpecificData?: JsonRecord): boolean { + if (!providerSpecificData || providerSpecificData.authMethod !== "imported") return false; + const provider = + typeof providerSpecificData.provider === "string" + ? providerSpecificData.provider.toLowerCase() + : ""; + return provider === "google" || provider === "github"; +} diff --git a/tests/unit/usage-families-split.test.ts b/tests/unit/usage-families-split.test.ts new file mode 100644 index 0000000000..9369ce42e5 --- /dev/null +++ b/tests/unit/usage-families-split.test.ts @@ -0,0 +1,62 @@ +// Characterization of the services/usage.ts family split (god-file decomposition): the Cursor, +// Kimi, Codex, Claude, and Kiro usage families moved out of services/usage.ts into +// services/usage/.ts leaves (mirroring the earlier glm/minimax/antigravity leaves). +// Behavior-preserving move; the locks pin each leaf's exported surface, the Claude plan-label +// helper's pure logic, that usage.ts re-exports buildKiroUsageResult + discoverKiroProfileArn +// (the kiro-* tests import them from services/usage), and that __testing stays wired to the leaves. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const CURSOR = await import("../../open-sse/services/usage/cursor.ts"); +const KIMI = await import("../../open-sse/services/usage/kimi.ts"); +const CODEX = await import("../../open-sse/services/usage/codex.ts"); +const CLAUDE = await import("../../open-sse/services/usage/claude.ts"); +const KIRO = await import("../../open-sse/services/usage/kiro.ts"); +const HOST = await import("../../open-sse/services/usage.ts"); + +const kind = (m: unknown, k: string) => typeof (m as Record)[k]; + +test("each family leaf exposes its usage fetcher(s)", () => { + assert.equal(kind(CURSOR, "getCursorUsage"), "function"); + assert.equal(kind(KIMI, "getKimiUsage"), "function"); + assert.equal(kind(CODEX, "getCodexUsage"), "function"); + assert.equal(kind(CLAUDE, "getClaudeUsage"), "function"); + assert.equal(kind(CLAUDE, "getClaudePlanLabel"), "function"); + assert.equal(kind(KIRO, "getKiroUsage"), "function"); + assert.equal(kind(KIRO, "buildKiroUsageResult"), "function"); + assert.equal(kind(KIRO, "discoverKiroProfileArn"), "function"); +}); + +test("host re-exports the kiro symbols the kiro-* tests import, with the same identity", () => { + assert.equal( + (HOST as Record).buildKiroUsageResult, + (KIRO as Record).buildKiroUsageResult + ); + assert.equal( + (HOST as Record).discoverKiroProfileArn, + (KIRO as Record).discoverKiroProfileArn + ); +}); + +test("host __testing stays wired to the moved claude/kiro internals", () => { + const testing = (HOST as Record>).__testing; + assert.equal(testing.getClaudePlanLabel, (CLAUDE as Record).getClaudePlanLabel); + assert.equal(testing.getKiroUsage, (KIRO as Record).getKiroUsage); +}); + +test("claude getClaudePlanLabel picks the first meaningful candidate, skipping placeholders", () => { + assert.equal(CLAUDE.getClaudePlanLabel("Pro"), "Pro"); + assert.equal(CLAUDE.getClaudePlanLabel(" Max "), "Max"); + assert.equal(CLAUDE.getClaudePlanLabel("claude code", "Team"), "Team"); + assert.equal(CLAUDE.getClaudePlanLabel("unknown", null, "Enterprise"), "Enterprise"); + assert.equal(CLAUDE.getClaudePlanLabel(null, undefined, ""), null); + assert.equal(CLAUDE.getClaudePlanLabel(), null); +}); + +test("host dispatcher + USAGE_FETCHER_PROVIDERS still cover the moved families", () => { + assert.equal(kind(HOST, "getUsageForProvider"), "function"); + const providers = (HOST as Record).USAGE_FETCHER_PROVIDERS as readonly string[]; + for (const p of ["cursor", "codex", "claude", "kiro", "kimi-coding"]) { + assert.ok(providers.includes(p), `${p} must remain a usage-fetcher provider`); + } +});