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
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -2572,6 +2572,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2571,6 +2571,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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',
|
||||
|
||||
@@ -2588,6 +2588,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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',
|
||||
|
||||
@@ -2572,6 +2572,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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",
|
||||
|
||||
@@ -2572,6 +2572,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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",
|
||||
|
||||
@@ -2572,6 +2572,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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',
|
||||
|
||||
@@ -2571,6 +2571,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type QuotaProviderId =
|
||||
| 'openai'
|
||||
| 'codex'
|
||||
| 'cursor'
|
||||
| 'claude'
|
||||
| 'github-copilot'
|
||||
| 'github-copilot-addon'
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -12,6 +12,7 @@ export {
|
||||
fetchOpenaiQuota,
|
||||
fetchGoogleQuota,
|
||||
fetchCodexQuota,
|
||||
fetchCursorQuota,
|
||||
fetchCopilotQuota,
|
||||
fetchCopilotAddonQuota,
|
||||
fetchKimiQuota,
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user