From b46153801b55d37e5a7544d15e5b30e6db35a8da Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 11 Jun 2026 00:16:48 +0300 Subject: [PATCH] feat: add Cursor usage quota tracking Adds Cursor as a supported quota provider Reads Cursor auth from env, token files, or local app data Improves Cursor usage labels in the UI --- packages/ui/src/lib/i18n/messages/en.ts | 5 + packages/ui/src/lib/i18n/messages/es.ts | 5 + packages/ui/src/lib/i18n/messages/fr.ts | 5 + packages/ui/src/lib/i18n/messages/ko.ts | 5 + packages/ui/src/lib/i18n/messages/pl.ts | 5 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 5 + packages/ui/src/lib/i18n/messages/uk.ts | 5 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 5 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 5 + packages/ui/src/lib/quota/providers/index.ts | 1 + packages/ui/src/lib/quota/utils.ts | 5 + packages/ui/src/types/quota.ts | 1 + .../web/server/lib/quota/DOCUMENTATION.md | 1 + packages/web/server/lib/quota/index.js | 1 + .../web/server/lib/quota/providers/cursor.js | 319 ++++++++++++++++++ .../web/server/lib/quota/providers/index.js | 8 + 16 files changed, 381 insertions(+) create mode 100644 packages/web/server/lib/quota/providers/cursor.js diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index e204bf40..f7cef4c9 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -2571,6 +2571,11 @@ export const dict = { 'quota.window.monthly': 'Monthly Limit', 'quota.window.credits': 'Credits', 'quota.window.creditsBalance': 'Credits Balance', + 'quota.window.billingCycle': 'Billing Cycle', + 'quota.window.auto': 'Auto', + 'quota.window.api': 'API', + 'quota.window.planLimit': 'Plan Limit', + 'quota.window.onDemand': 'On-demand', 'quota.window.session': 'Session', 'quota.window.premium': 'Premium Interactions', 'quota.window.chat': 'Chat Requests', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 16e77bfc..f8e50da8 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2572,6 +2572,11 @@ export const dict: Record = { "quota.window.monthly": "Monthly Limit", "quota.window.credits": "Credits", "quota.window.creditsBalance": "Credits Balance", + "quota.window.billingCycle": "Billing Cycle", + "quota.window.auto": "Auto", + "quota.window.api": "API", + "quota.window.planLimit": "Plan Limit", + "quota.window.onDemand": "On-demand", "quota.window.session": "Session", "quota.window.premium": "Premium Interactions", "quota.window.chat": "Chat Requests", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 08a2b88a..4f78dd22 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -2381,6 +2381,11 @@ export const dict = { 'quota.window.monthly': 'Limite mensuelle', 'quota.window.credits': 'Crédits', 'quota.window.creditsBalance': 'Solde de crédits', + 'quota.window.billingCycle': 'Cycle de facturation', + 'quota.window.auto': 'Auto', + 'quota.window.api': 'API', + 'quota.window.planLimit': 'Limite du forfait', + 'quota.window.onDemand': 'À la demande', 'quota.window.session': 'Session', 'quota.window.premium': 'Interactions premium', 'quota.window.chat': 'Requêtes de chat', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 88a42fc1..4d12b9e9 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2571,6 +2571,11 @@ export const dict: Record = { 'quota.window.monthly': 'Monthly Limit', 'quota.window.credits': 'Credits', 'quota.window.creditsBalance': 'Credits Balance', + 'quota.window.billingCycle': 'Billing Cycle', + 'quota.window.auto': 'Auto', + 'quota.window.api': 'API', + 'quota.window.planLimit': 'Plan Limit', + 'quota.window.onDemand': 'On-demand', 'quota.window.session': 'Session', 'quota.window.premium': 'Premium Interactions', 'quota.window.chat': 'Chat Requests', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 1248caf3..6c6dad90 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2588,6 +2588,11 @@ export const dict: Record = { 'quota.window.monthly': 'Monthly Limit', 'quota.window.credits': 'Credits', 'quota.window.creditsBalance': 'Credits Balance', + 'quota.window.billingCycle': 'Billing Cycle', + 'quota.window.auto': 'Auto', + 'quota.window.api': 'API', + 'quota.window.planLimit': 'Plan Limit', + 'quota.window.onDemand': 'On-demand', 'quota.window.session': 'Session', 'quota.window.premium': 'Premium Interactions', 'quota.window.chat': 'Chat Requests', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index def98832..9c5390de 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2572,6 +2572,11 @@ export const dict: Record = { "quota.window.monthly": "Monthly Limit", "quota.window.credits": "Credits", "quota.window.creditsBalance": "Credits Balance", + "quota.window.billingCycle": "Billing Cycle", + "quota.window.auto": "Auto", + "quota.window.api": "API", + "quota.window.planLimit": "Plan Limit", + "quota.window.onDemand": "On-demand", "quota.window.session": "Session", "quota.window.premium": "Premium Interactions", "quota.window.chat": "Chat Requests", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 10e8e564..45b6c39b 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2572,6 +2572,11 @@ export const dict: Record = { "quota.window.monthly": "Monthly Limit", "quota.window.credits": "Credits", "quota.window.creditsBalance": "Credits Balance", + "quota.window.billingCycle": "Billing Cycle", + "quota.window.auto": "Auto", + "quota.window.api": "API", + "quota.window.planLimit": "Plan Limit", + "quota.window.onDemand": "On-demand", "quota.window.session": "Session", "quota.window.premium": "Premium Interactions", "quota.window.chat": "Chat Requests", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 49543c30..fa40b361 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2572,6 +2572,11 @@ export const dict: Record = { 'quota.window.monthly': 'Monthly Limit', 'quota.window.credits': 'Credits', 'quota.window.creditsBalance': 'Credits Balance', + 'quota.window.billingCycle': 'Billing Cycle', + 'quota.window.auto': 'Auto', + 'quota.window.api': 'API', + 'quota.window.planLimit': 'Plan Limit', + 'quota.window.onDemand': 'On-demand', 'quota.window.session': 'Session', 'quota.window.premium': 'Premium Interactions', 'quota.window.chat': 'Chat Requests', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index ed212342..c09a0a86 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2571,6 +2571,11 @@ export const dict: Record = { 'quota.window.monthly': 'Monthly Limit', 'quota.window.credits': 'Credits', 'quota.window.creditsBalance': 'Credits Balance', + 'quota.window.billingCycle': 'Billing Cycle', + 'quota.window.auto': 'Auto', + 'quota.window.api': 'API', + 'quota.window.planLimit': 'Plan Limit', + 'quota.window.onDemand': 'On-demand', 'quota.window.session': 'Session', 'quota.window.premium': 'Premium Interactions', 'quota.window.chat': 'Chat Requests', diff --git a/packages/ui/src/lib/quota/providers/index.ts b/packages/ui/src/lib/quota/providers/index.ts index 29654100..30f2b4aa 100644 --- a/packages/ui/src/lib/quota/providers/index.ts +++ b/packages/ui/src/lib/quota/providers/index.ts @@ -8,6 +8,7 @@ export interface QuotaProviderMeta { export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [ { id: 'claude', name: 'Claude' }, { id: 'codex', name: 'Codex' }, + { id: 'cursor', name: 'Cursor' }, { id: 'github-copilot', name: 'GitHub Copilot' }, { id: 'google', name: 'Google' }, { id: 'kimi-for-coding', name: 'Kimi for Coding' }, diff --git a/packages/ui/src/lib/quota/utils.ts b/packages/ui/src/lib/quota/utils.ts index 3cd0dc67..85432784 100644 --- a/packages/ui/src/lib/quota/utils.ts +++ b/packages/ui/src/lib/quota/utils.ts @@ -82,6 +82,11 @@ export const formatWindowLabel = (label: string): string => { if (label === 'monthly') return t('quota.window.monthly'); if (label === 'credits') return t('quota.window.credits'); if (label === 'credits_balance') return t('quota.window.creditsBalance'); + if (label === 'billing_cycle') return t('quota.window.billingCycle'); + if (label === 'auto') return t('quota.window.auto'); + if (label === 'api') return t('quota.window.api'); + if (label === 'plan_limit') return t('quota.window.planLimit'); + if (label === 'on_demand') return t('quota.window.onDemand'); if (label === 'session') return t('quota.window.session'); if (label === 'premium') return t('quota.window.premium'); if (label === 'chat') return t('quota.window.chat'); diff --git a/packages/ui/src/types/quota.ts b/packages/ui/src/types/quota.ts index 06348dba..94778ea4 100644 --- a/packages/ui/src/types/quota.ts +++ b/packages/ui/src/types/quota.ts @@ -1,6 +1,7 @@ export type QuotaProviderId = | 'openai' | 'codex' + | 'cursor' | 'claude' | 'github-copilot' | 'github-copilot-addon' diff --git a/packages/web/server/lib/quota/DOCUMENTATION.md b/packages/web/server/lib/quota/DOCUMENTATION.md index 71d13226..2e98dc74 100644 --- a/packages/web/server/lib/quota/DOCUMENTATION.md +++ b/packages/web/server/lib/quota/DOCUMENTATION.md @@ -19,6 +19,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide | --- | --- | --- | --- | | `claude` | Claude | `providers/claude.js` | `anthropic`, `claude` | | `codex` | Codex | `providers/codex.js` | `openai`, `codex`, `chatgpt` | +| `cursor` | Cursor | `providers/cursor.js` | `CURSOR_TOKEN` / `CURSOR_ACCESS_TOKEN`, `CURSOR_REFRESH_TOKEN`, optional token files, or Cursor desktop SQLite DB | | `google` | Google | `providers/google/index.js` | `google`, `google.oauth`, Antigravity accounts file | | `github-copilot` | GitHub Copilot | `providers/copilot.js` | `github-copilot`, `copilot` | | `github-copilot-addon` | GitHub Copilot Add-on | `providers/copilot.js` | `github-copilot`, `copilot` | diff --git a/packages/web/server/lib/quota/index.js b/packages/web/server/lib/quota/index.js index b11d7d3b..7fab4cf5 100644 --- a/packages/web/server/lib/quota/index.js +++ b/packages/web/server/lib/quota/index.js @@ -12,6 +12,7 @@ export { fetchOpenaiQuota, fetchGoogleQuota, fetchCodexQuota, + fetchCursorQuota, fetchCopilotQuota, fetchCopilotAddonQuota, fetchKimiQuota, diff --git a/packages/web/server/lib/quota/providers/cursor.js b/packages/web/server/lib/quota/providers/cursor.js new file mode 100644 index 00000000..2bb7247f --- /dev/null +++ b/packages/web/server/lib/quota/providers/cursor.js @@ -0,0 +1,319 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import { execFileSync } from 'child_process'; +import { + buildResult, + formatMoney, + toNumber, + toTimestamp, + toUsageWindow +} from '../utils/index.js'; + +const BASE_URL = 'https://api2.cursor.sh'; +const USAGE_URL = `${BASE_URL}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`; +const PLAN_URL = `${BASE_URL}/aiserver.v1.DashboardService/GetPlanInfo`; +const CREDITS_URL = `${BASE_URL}/aiserver.v1.DashboardService/GetCreditGrantsBalance`; +const REFRESH_URL = `${BASE_URL}/oauth/token`; +const CLIENT_ID = 'KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB'; +const REFRESH_BUFFER_MS = 5 * 60 * 1000; +const STATE_DB = join(homedir(), 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb'); + +export const providerId = 'cursor'; +export const providerName = 'Cursor'; +export const aliases = ['cursor']; + +const readJwtPayload = (token) => { + try { + const [, payload] = String(token).split('.'); + if (!payload) return null; + return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); + } catch { + return null; + } +}; + +const readStateValue = (key) => { + if (!existsSync(STATE_DB)) return null; + try { + const escapedKey = String(key).replace(/'/g, "''"); + const rows = execFileSync('sqlite3', [ + '-json', + STATE_DB, + `SELECT value FROM ItemTable WHERE key = '${escapedKey}' LIMIT 1;` + ], { + encoding: 'utf8', + windowsHide: true, + stdio: ['ignore', 'pipe', 'ignore'] + }); + const parsed = JSON.parse(rows || '[]'); + const value = parsed?.[0]?.value; + return typeof value === 'string' && value.trim() ? value.trim() : null; + } catch { + return null; + } +}; + +const writeStateValue = (key, value) => { + if (!existsSync(STATE_DB)) return false; + try { + const escaped = String(value).replace(/'/g, "''"); + const escapedKey = String(key).replace(/'/g, "''"); + execFileSync('sqlite3', [ + STATE_DB, + `INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('${escapedKey}', '${escaped}');` + ], { + encoding: 'utf8', + windowsHide: true, + stdio: ['ignore', 'ignore', 'ignore'] + }); + return true; + } catch { + return false; + } +}; + +const readFileToken = (path) => { + try { + if (!path || !existsSync(path)) return null; + const content = readFileSync(path, 'utf8').trim(); + return content || null; + } catch { + return null; + } +}; + +const writeFileToken = (path, value) => { + try { + if (!path) return false; + writeFileSync(path, `${value}\n`, { encoding: 'utf8', mode: 0o600 }); + return true; + } catch { + return false; + } +}; + +const loadAuthState = () => { + const envAccessToken = process.env.CURSOR_TOKEN || process.env.CURSOR_ACCESS_TOKEN || null; + const envRefreshToken = process.env.CURSOR_REFRESH_TOKEN || null; + const accessTokenPath = process.env.CURSOR_TOKEN_FILE || null; + const refreshTokenPath = process.env.CURSOR_REFRESH_TOKEN_FILE || null; + const fileAccessToken = readFileToken(accessTokenPath); + const fileRefreshToken = readFileToken(refreshTokenPath); + + if (envAccessToken || envRefreshToken) { + return { + accessToken: envAccessToken, + refreshToken: envRefreshToken, + source: 'env' + }; + } + + if (fileAccessToken || fileRefreshToken) { + return { + accessToken: fileAccessToken, + refreshToken: fileRefreshToken, + source: 'file', + accessTokenPath + }; + } + + return { + accessToken: readStateValue('cursorAuth/accessToken'), + refreshToken: readStateValue('cursorAuth/refreshToken'), + source: 'sqlite' + }; +}; + +const tokenNeedsRefresh = (token) => { + if (!token) return true; + const payload = readJwtPayload(token); + const expiresAt = typeof payload?.exp === 'number' ? payload.exp * 1000 : null; + return !expiresAt || expiresAt - Date.now() <= REFRESH_BUFFER_MS; +}; + +const persistAccessToken = (auth, accessToken) => { + if (auth.source === 'sqlite') writeStateValue('cursorAuth/accessToken', accessToken); + if (auth.source === 'file') writeFileToken(auth.accessTokenPath, accessToken); +}; + +const refreshAccessToken = async (auth) => { + if (!auth.refreshToken) return auth.accessToken; + + const response = await fetch(REFRESH_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'refresh_token', + client_id: CLIENT_ID, + refresh_token: auth.refreshToken + }) + }); + + const body = await response.json().catch(() => null); + if (body?.shouldLogout === true) { + throw new Error('Session expired - please sign in to Cursor again'); + } + if (!response.ok) { + throw new Error(response.status === 401 ? 'Cursor session expired' : `API error: ${response.status}`); + } + if (typeof body?.access_token !== 'string' || !body.access_token) { + throw new Error('Cursor refresh response did not include an access token'); + } + + persistAccessToken(auth, body.access_token); + return body.access_token; +}; + +const resolveAccessToken = async () => { + const auth = loadAuthState(); + if (!auth.accessToken && !auth.refreshToken) return null; + if (!tokenNeedsRefresh(auth.accessToken)) return auth.accessToken; + return refreshAccessToken(auth); +}; + +const connectPost = async (url, accessToken) => { + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Connect-Protocol-Version': '1' + }, + body: '{}' + }); + + if (!response.ok) { + throw new Error(response.status === 401 ? 'Cursor session expired' : `API error: ${response.status}`); + } + + return response.json(); +}; + +const centsLabel = (cents) => { + const value = toNumber(cents); + return value === null ? null : `$${formatMoney(value / 100)}`; +}; + +const percentFromSpend = (planUsage) => { + const explicit = toNumber(planUsage?.totalPercentUsed); + if (explicit !== null) return explicit; + const limit = toNumber(planUsage?.limit); + const remaining = toNumber(planUsage?.remaining); + if (!limit || remaining === null) return null; + return Math.min(100, Math.max(0, ((limit - remaining) / limit) * 100)); +}; + +const buildWindows = (usage, plan) => { + const planUsage = usage?.planUsage ?? {}; + const spendLimitUsage = usage?.spendLimitUsage ?? {}; + const resetAt = toTimestamp(usage?.billingCycleEnd ?? plan?.planInfo?.billingCycleEnd); + const windowSeconds = resetAt ? Math.max(0, Math.floor((resetAt - Date.now()) / 1000)) : null; + const windows = {}; + + windows.billing_cycle = toUsageWindow({ + usedPercent: percentFromSpend(planUsage), + windowSeconds, + resetAt, + valueLabel: centsLabel(planUsage.totalSpend) + }); + + const autoPercent = toNumber(planUsage.autoPercentUsed); + if (autoPercent !== null) { + windows.auto = toUsageWindow({ usedPercent: autoPercent, windowSeconds, resetAt }); + } + + const apiPercent = toNumber(planUsage.apiPercentUsed); + if (apiPercent !== null) { + windows.api = toUsageWindow({ usedPercent: apiPercent, windowSeconds, resetAt }); + } + + const planLimit = centsLabel(planUsage.limit); + if (planLimit) { + windows.plan_limit = toUsageWindow({ + usedPercent: null, + windowSeconds, + resetAt, + valueLabel: `${centsLabel(planUsage.remaining) ?? '$0.00'} remaining of ${planLimit}` + }); + } + + const onDemandLimit = toNumber(spendLimitUsage.individualLimit) ?? toNumber(spendLimitUsage.pooledLimit); + if (onDemandLimit && onDemandLimit > 0) { + const remaining = toNumber(spendLimitUsage.individualRemaining) ?? toNumber(spendLimitUsage.pooledRemaining) ?? 0; + windows.on_demand = toUsageWindow({ + usedPercent: Math.min(100, Math.max(0, ((onDemandLimit - remaining) / onDemandLimit) * 100)), + windowSeconds, + resetAt, + valueLabel: `${centsLabel(remaining) ?? '$0.00'} remaining of ${centsLabel(onDemandLimit)}` + }); + } + + return windows; +}; + +const appendCreditsWindow = (windows, credits) => { + const balance = toNumber(credits?.balanceCents ?? credits?.totalBalanceCents ?? credits?.amountCents); + if (balance === null) return; + windows.credits = toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel: centsLabel(balance) + }); +}; + +export const isConfigured = () => { + const auth = loadAuthState(); + return Boolean(auth.accessToken || auth.refreshToken); +}; + +export const fetchQuota = async () => { + const accessToken = await resolveAccessToken(); + if (!accessToken) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: false, + error: 'Not configured' + }); + } + + try { + const [usage, plan, credits] = await Promise.all([ + connectPost(USAGE_URL, accessToken), + connectPost(PLAN_URL, accessToken).catch(() => null), + connectPost(CREDITS_URL, accessToken).catch(() => null) + ]); + + if (usage?.enabled === false || !usage?.planUsage) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: 'No active Cursor subscription' + }); + } + + const windows = buildWindows(usage, plan); + appendCreditsWindow(windows, credits); + + return buildResult({ + providerId, + providerName: plan?.planInfo?.planName ? `Cursor ${plan.planInfo.planName}` : providerName, + ok: true, + configured: true, + usage: { windows } + }); + } catch (error) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed' + }); + } +}; diff --git a/packages/web/server/lib/quota/providers/index.js b/packages/web/server/lib/quota/providers/index.js index a33106a8..0a72cdc6 100644 --- a/packages/web/server/lib/quota/providers/index.js +++ b/packages/web/server/lib/quota/providers/index.js @@ -10,6 +10,7 @@ import { buildResult } from '../utils/index.js'; import * as claude from './claude.js'; import * as codex from './codex.js'; import * as copilot from './copilot.js'; +import * as cursor from './cursor.js'; import * as google from './google/index.js'; import * as kimi from './kimi.js'; import * as nanogpt from './nanogpt.js'; @@ -35,6 +36,12 @@ const registry = { isConfigured: codex.isConfigured, fetchQuota: codex.fetchQuota }, + cursor: { + providerId: cursor.providerId, + providerName: cursor.providerName, + isConfigured: cursor.isConfigured, + fetchQuota: cursor.fetchQuota + }, google: { providerId: 'google', providerName: 'Google', @@ -155,6 +162,7 @@ export const fetchClaudeQuota = claude.fetchQuota; export const fetchOpenaiQuota = openai.fetchQuota; export const fetchGoogleQuota = google.fetchGoogleQuota; export const fetchCodexQuota = codex.fetchQuota; +export const fetchCursorQuota = cursor.fetchQuota; export const fetchCopilotQuota = copilot.fetchQuota; export const fetchCopilotAddonQuota = copilot.fetchQuotaAddon; export const fetchKimiQuota = kimi.fetchQuota;