diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 653baeda..74da0b9d 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -83,6 +83,8 @@ import { getNotificationService } from './services/notification-service.js'; import { createEventHistoryRoutes } from './routes/event-history/index.js'; import { getEventHistoryService } from './services/event-history-service.js'; import { getTestRunnerService } from './services/test-runner-service.js'; +import { createProviderUsageRoutes } from './routes/provider-usage/index.js'; +import { ProviderUsageTracker } from './services/provider-usage-tracker.js'; // Load environment variables dotenv.config(); @@ -236,6 +238,7 @@ const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServ const codexUsageService = new CodexUsageService(codexAppServerService); const mcpTestService = new MCPTestService(settingsService); const ideationService = new IdeationService(events, settingsService, featureLoader); +const providerUsageTracker = new ProviderUsageTracker(codexUsageService); // Initialize DevServerService with event emitter for real-time log streaming const devServerService = getDevServerService(); @@ -347,6 +350,7 @@ app.use('/api/pipeline', createPipelineRoutes(pipelineService)); app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader)); app.use('/api/notifications', createNotificationsRoutes(notificationService)); app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService)); +app.use('/api/provider-usage', createProviderUsageRoutes(providerUsageTracker)); // Create HTTP server const server = createServer(app); diff --git a/apps/server/src/routes/provider-usage/index.ts b/apps/server/src/routes/provider-usage/index.ts new file mode 100644 index 00000000..a550f976 --- /dev/null +++ b/apps/server/src/routes/provider-usage/index.ts @@ -0,0 +1,143 @@ +/** + * Provider Usage Routes + * + * API endpoints for fetching usage data from all AI providers. + * + * Endpoints: + * - GET /api/provider-usage - Get usage for all enabled providers + * - GET /api/provider-usage/:providerId - Get usage for a specific provider + * - GET /api/provider-usage/availability - Check availability of all providers + */ + +import { Router, Request, Response } from 'express'; +import { createLogger } from '@automaker/utils'; +import type { UsageProviderId } from '@automaker/types'; +import { ProviderUsageTracker } from '../../services/provider-usage-tracker.js'; + +const logger = createLogger('ProviderUsageRoutes'); + +// Valid provider IDs +const VALID_PROVIDER_IDS: UsageProviderId[] = [ + 'claude', + 'codex', + 'cursor', + 'gemini', + 'copilot', + 'opencode', + 'minimax', + 'glm', +]; + +export function createProviderUsageRoutes(tracker: ProviderUsageTracker): Router { + const router = Router(); + + /** + * GET /api/provider-usage + * Fetch usage for all enabled providers + */ + router.get('/', async (req: Request, res: Response) => { + try { + const forceRefresh = req.query.refresh === 'true'; + const usage = await tracker.fetchAllUsage(forceRefresh); + res.json({ + success: true, + data: usage, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error fetching all provider usage:', error); + res.status(500).json({ + success: false, + error: message, + }); + } + }); + + /** + * GET /api/provider-usage/availability + * Check which providers are available + */ + router.get('/availability', async (_req: Request, res: Response) => { + try { + const availability = await tracker.checkAvailability(); + res.json({ + success: true, + data: availability, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error checking provider availability:', error); + res.status(500).json({ + success: false, + error: message, + }); + } + }); + + /** + * GET /api/provider-usage/:providerId + * Fetch usage for a specific provider + */ + router.get('/:providerId', async (req: Request, res: Response) => { + try { + const providerId = req.params.providerId as UsageProviderId; + + // Validate provider ID + if (!VALID_PROVIDER_IDS.includes(providerId)) { + res.status(400).json({ + success: false, + error: `Invalid provider ID: ${providerId}. Valid providers: ${VALID_PROVIDER_IDS.join(', ')}`, + }); + return; + } + + // Check if provider is enabled + if (!tracker.isProviderEnabled(providerId)) { + res.status(200).json({ + success: true, + data: { + providerId, + providerName: providerId, + available: false, + lastUpdated: new Date().toISOString(), + error: 'Provider is disabled', + }, + }); + return; + } + + const forceRefresh = req.query.refresh === 'true'; + const usage = await tracker.fetchProviderUsage(providerId, forceRefresh); + + if (!usage) { + res.status(200).json({ + success: true, + data: { + providerId, + providerName: providerId, + available: false, + lastUpdated: new Date().toISOString(), + error: 'Failed to fetch usage data', + }, + }); + return; + } + + res.json({ + success: true, + data: usage, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error(`Error fetching usage for ${req.params.providerId}:`, error); + + // Return 200 with error in data to avoid triggering logout + res.status(200).json({ + success: false, + error: message, + }); + } + }); + + return router; +} diff --git a/apps/server/src/services/copilot-usage-service.ts b/apps/server/src/services/copilot-usage-service.ts new file mode 100644 index 00000000..2149e79d --- /dev/null +++ b/apps/server/src/services/copilot-usage-service.ts @@ -0,0 +1,288 @@ +/** + * GitHub Copilot Usage Service + * + * Fetches usage data from GitHub's Copilot API using GitHub OAuth. + * Based on CodexBar reference implementation. + * + * Authentication methods: + * 1. GitHub CLI token (~/.config/gh/hosts.yml) + * 2. GitHub OAuth device flow (stored in config) + * + * API Endpoints: + * - GET https://api.github.com/copilot_internal/user - Quota and plan info + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execSync } from 'child_process'; +import { createLogger } from '@automaker/utils'; +import type { CopilotProviderUsage, UsageWindow } from '@automaker/types'; + +const logger = createLogger('CopilotUsage'); + +// GitHub API endpoint for Copilot +const COPILOT_USER_ENDPOINT = 'https://api.github.com/copilot_internal/user'; + +interface CopilotQuotaSnapshot { + percentageUsed?: number; + percentageRemaining?: number; + limit?: number; + used?: number; +} + +interface CopilotUserResponse { + copilotPlan?: string; + copilot_plan?: string; + quotaSnapshots?: { + premiumInteractions?: CopilotQuotaSnapshot; + chat?: CopilotQuotaSnapshot; + }; + plan?: string; +} + +export class CopilotUsageService { + private cachedToken: string | null = null; + + /** + * Check if GitHub Copilot credentials are available + */ + async isAvailable(): Promise { + const token = await this.getGitHubToken(); + return !!token; + } + + /** + * Get GitHub token from various sources + */ + private async getGitHubToken(): Promise { + if (this.cachedToken) { + return this.cachedToken; + } + + // 1. Check environment variable + if (process.env.GITHUB_TOKEN) { + this.cachedToken = process.env.GITHUB_TOKEN; + return this.cachedToken; + } + + // 2. Check GH_TOKEN (GitHub CLI uses this) + if (process.env.GH_TOKEN) { + this.cachedToken = process.env.GH_TOKEN; + return this.cachedToken; + } + + // 3. Try to get token from GitHub CLI + try { + const token = execSync('gh auth token', { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + + if (token) { + this.cachedToken = token; + return this.cachedToken; + } + } catch { + logger.debug('Failed to get token from gh CLI'); + } + + // 4. Check GitHub CLI hosts.yml file + const ghHostsPath = path.join(os.homedir(), '.config', 'gh', 'hosts.yml'); + if (fs.existsSync(ghHostsPath)) { + try { + const content = fs.readFileSync(ghHostsPath, 'utf8'); + // Simple YAML parsing for oauth_token + const match = content.match(/oauth_token:\s*(.+)/); + if (match) { + this.cachedToken = match[1].trim(); + return this.cachedToken; + } + } catch (error) { + logger.debug('Failed to read gh hosts.yml:', error); + } + } + + // 5. Check CodexBar config (for users who also use CodexBar) + const codexbarConfigPath = path.join(os.homedir(), '.codexbar', 'config.json'); + if (fs.existsSync(codexbarConfigPath)) { + try { + const content = fs.readFileSync(codexbarConfigPath, 'utf8'); + const config = JSON.parse(content); + if (config.github?.oauth_token) { + this.cachedToken = config.github.oauth_token; + return this.cachedToken; + } + } catch (error) { + logger.debug('Failed to read CodexBar config:', error); + } + } + + return null; + } + + /** + * Make an authenticated request to GitHub Copilot API + */ + private async makeRequest(url: string): Promise { + const token = await this.getGitHubToken(); + if (!token) { + return null; + } + + try { + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `token ${token}`, + Accept: 'application/json', + 'User-Agent': 'automaker/1.0', + // Copilot-specific headers (from CodexBar reference) + 'Editor-Version': 'vscode/1.96.2', + 'Editor-Plugin-Version': 'copilot-chat/0.26.7', + 'X-Github-Api-Version': '2025-04-01', + }, + }); + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + // Clear cached token on auth failure + this.cachedToken = null; + logger.warn('GitHub Copilot API authentication failed'); + return null; + } + if (response.status === 404) { + // User may not have Copilot access + logger.info('GitHub Copilot not available for this user'); + return null; + } + logger.error(`GitHub Copilot API error: ${response.status} ${response.statusText}`); + return null; + } + + return (await response.json()) as T; + } catch (error) { + logger.error('Failed to fetch from GitHub Copilot API:', error); + return null; + } + } + + /** + * Fetch usage data from GitHub Copilot + */ + async fetchUsageData(): Promise { + logger.info('[fetchUsageData] Starting GitHub Copilot usage fetch...'); + + const baseUsage: CopilotProviderUsage = { + providerId: 'copilot', + providerName: 'GitHub Copilot', + available: false, + lastUpdated: new Date().toISOString(), + }; + + // Check if token is available + const hasToken = await this.getGitHubToken(); + if (!hasToken) { + baseUsage.error = 'GitHub authentication not available'; + return baseUsage; + } + + // Fetch Copilot user data + const userResponse = await this.makeRequest(COPILOT_USER_ENDPOINT); + if (!userResponse) { + baseUsage.error = 'Failed to fetch GitHub Copilot usage data'; + return baseUsage; + } + + baseUsage.available = true; + + // Parse quota snapshots + const quotas = userResponse.quotaSnapshots; + if (quotas) { + // Premium interactions quota + if (quotas.premiumInteractions) { + const premium = quotas.premiumInteractions; + const usedPercent = + premium.percentageUsed !== undefined + ? premium.percentageUsed + : premium.percentageRemaining !== undefined + ? 100 - premium.percentageRemaining + : 0; + + const premiumWindow: UsageWindow = { + name: 'Premium Interactions', + usedPercent, + resetsAt: '', // GitHub doesn't provide reset time + resetText: 'Resets monthly', + limit: premium.limit, + used: premium.used, + }; + + baseUsage.primary = premiumWindow; + baseUsage.premiumInteractions = premiumWindow; + } + + // Chat quota + if (quotas.chat) { + const chat = quotas.chat; + const usedPercent = + chat.percentageUsed !== undefined + ? chat.percentageUsed + : chat.percentageRemaining !== undefined + ? 100 - chat.percentageRemaining + : 0; + + const chatWindow: UsageWindow = { + name: 'Chat', + usedPercent, + resetsAt: '', + resetText: 'Resets monthly', + limit: chat.limit, + used: chat.used, + }; + + baseUsage.secondary = chatWindow; + baseUsage.chatQuota = chatWindow; + } + } + + // Parse plan type + const planType = userResponse.copilotPlan || userResponse.copilot_plan || userResponse.plan; + if (planType) { + baseUsage.copilotPlan = planType; + baseUsage.plan = { + type: planType, + displayName: this.formatPlanName(planType), + isPaid: planType.toLowerCase() !== 'free', + }; + } + + logger.info( + `[fetchUsageData] ✓ GitHub Copilot usage: Premium=${baseUsage.premiumInteractions?.usedPercent || 0}%, ` + + `Chat=${baseUsage.chatQuota?.usedPercent || 0}%, Plan=${planType || 'unknown'}` + ); + + return baseUsage; + } + + /** + * Format plan name for display + */ + private formatPlanName(plan: string): string { + const planMap: Record = { + free: 'Free', + individual: 'Individual', + business: 'Business', + enterprise: 'Enterprise', + }; + return planMap[plan.toLowerCase()] || plan; + } + + /** + * Clear cached token + */ + clearCache(): void { + this.cachedToken = null; + } +} diff --git a/apps/server/src/services/cursor-usage-service.ts b/apps/server/src/services/cursor-usage-service.ts new file mode 100644 index 00000000..09597805 --- /dev/null +++ b/apps/server/src/services/cursor-usage-service.ts @@ -0,0 +1,331 @@ +/** + * Cursor Usage Service + * + * Fetches usage data from Cursor's API using session cookies or access token. + * Based on CodexBar reference implementation. + * + * Authentication methods (in priority order): + * 1. Cached session cookie from browser import + * 2. Access token from credentials file + * + * API Endpoints: + * - GET https://cursor.com/api/usage-summary - Plan usage, on-demand, billing dates + * - GET https://cursor.com/api/auth/me - User email and name + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { createLogger } from '@automaker/utils'; +import type { CursorProviderUsage, UsageWindow } from '@automaker/types'; + +const logger = createLogger('CursorUsage'); + +// Cursor API endpoints +const CURSOR_API_BASE = 'https://cursor.com/api'; +const USAGE_SUMMARY_ENDPOINT = `${CURSOR_API_BASE}/usage-summary`; +const AUTH_ME_ENDPOINT = `${CURSOR_API_BASE}/auth/me`; + +// Session cookie names used by Cursor +const SESSION_COOKIE_NAMES = [ + 'WorkosCursorSessionToken', + '__Secure-next-auth.session-token', + 'next-auth.session-token', +]; + +interface CursorUsageSummary { + planUsage?: { + percent: number; + resetAt?: string; + }; + onDemandUsage?: { + percent: number; + costUsd?: number; + }; + billingCycleEnd?: string; + plan?: string; +} + +interface CursorAuthMe { + email?: string; + name?: string; + plan?: string; +} + +export class CursorUsageService { + private cachedSessionCookie: string | null = null; + private cachedAccessToken: string | null = null; + + /** + * Check if Cursor credentials are available + */ + async isAvailable(): Promise { + return await this.hasValidCredentials(); + } + + /** + * Check if we have valid Cursor credentials + */ + private async hasValidCredentials(): Promise { + const token = await this.getAccessToken(); + return !!token; + } + + /** + * Get access token from credentials file + */ + private async getAccessToken(): Promise { + if (this.cachedAccessToken) { + return this.cachedAccessToken; + } + + // Check environment variable first + if (process.env.CURSOR_ACCESS_TOKEN) { + this.cachedAccessToken = process.env.CURSOR_ACCESS_TOKEN; + return this.cachedAccessToken; + } + + // Check credentials files + const credentialPaths = [ + path.join(os.homedir(), '.cursor', 'credentials.json'), + path.join(os.homedir(), '.config', 'cursor', 'credentials.json'), + ]; + + for (const credPath of credentialPaths) { + try { + if (fs.existsSync(credPath)) { + const content = fs.readFileSync(credPath, 'utf8'); + const creds = JSON.parse(content); + if (creds.accessToken) { + this.cachedAccessToken = creds.accessToken; + return this.cachedAccessToken; + } + if (creds.token) { + this.cachedAccessToken = creds.token; + return this.cachedAccessToken; + } + } + } catch (error) { + logger.debug(`Failed to read credentials from ${credPath}:`, error); + } + } + + return null; + } + + /** + * Get session cookie for API calls + * Returns a cookie string like "WorkosCursorSessionToken=xxx" + */ + private async getSessionCookie(): Promise { + if (this.cachedSessionCookie) { + return this.cachedSessionCookie; + } + + // Check for cookie in environment + if (process.env.CURSOR_SESSION_COOKIE) { + this.cachedSessionCookie = process.env.CURSOR_SESSION_COOKIE; + return this.cachedSessionCookie; + } + + // Check for saved session file + const sessionPath = path.join(os.homedir(), '.cursor', 'session.json'); + try { + if (fs.existsSync(sessionPath)) { + const content = fs.readFileSync(sessionPath, 'utf8'); + const session = JSON.parse(content); + for (const cookieName of SESSION_COOKIE_NAMES) { + if (session[cookieName]) { + this.cachedSessionCookie = `${cookieName}=${session[cookieName]}`; + return this.cachedSessionCookie; + } + } + } + } catch (error) { + logger.debug('Failed to read session file:', error); + } + + return null; + } + + /** + * Make an authenticated request to Cursor API + */ + private async makeRequest(url: string): Promise { + const headers: Record = { + Accept: 'application/json', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', + }; + + // Try access token first + const accessToken = await this.getAccessToken(); + if (accessToken) { + headers['Authorization'] = `Bearer ${accessToken}`; + } + + // Try session cookie as fallback + const sessionCookie = await this.getSessionCookie(); + if (sessionCookie) { + headers['Cookie'] = sessionCookie; + } + + if (!accessToken && !sessionCookie) { + logger.warn('No Cursor credentials available for API request'); + return null; + } + + try { + const response = await fetch(url, { + method: 'GET', + headers, + }); + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + // Clear cached credentials on auth failure + this.cachedAccessToken = null; + this.cachedSessionCookie = null; + logger.warn('Cursor API authentication failed'); + return null; + } + logger.error(`Cursor API error: ${response.status} ${response.statusText}`); + return null; + } + + return (await response.json()) as T; + } catch (error) { + logger.error('Failed to fetch from Cursor API:', error); + return null; + } + } + + /** + * Fetch usage data from Cursor + */ + async fetchUsageData(): Promise { + logger.info('[fetchUsageData] Starting Cursor usage fetch...'); + + const baseUsage: CursorProviderUsage = { + providerId: 'cursor', + providerName: 'Cursor', + available: false, + lastUpdated: new Date().toISOString(), + }; + + // Check if credentials are available + const hasCredentials = await this.hasValidCredentials(); + if (!hasCredentials) { + baseUsage.error = 'Cursor credentials not available'; + return baseUsage; + } + + // Fetch usage summary + const usageSummary = await this.makeRequest(USAGE_SUMMARY_ENDPOINT); + if (!usageSummary) { + baseUsage.error = 'Failed to fetch Cursor usage data'; + return baseUsage; + } + + baseUsage.available = true; + + // Parse plan usage + if (usageSummary.planUsage) { + const planWindow: UsageWindow = { + name: 'Plan Usage', + usedPercent: usageSummary.planUsage.percent || 0, + resetsAt: usageSummary.planUsage.resetAt || '', + resetText: usageSummary.planUsage.resetAt + ? this.formatResetTime(usageSummary.planUsage.resetAt) + : '', + }; + baseUsage.primary = planWindow; + baseUsage.planUsage = planWindow; + } + + // Parse on-demand usage + if (usageSummary.onDemandUsage) { + const onDemandWindow: UsageWindow = { + name: 'On-Demand Usage', + usedPercent: usageSummary.onDemandUsage.percent || 0, + resetsAt: usageSummary.billingCycleEnd || '', + resetText: usageSummary.billingCycleEnd + ? this.formatResetTime(usageSummary.billingCycleEnd) + : '', + }; + baseUsage.secondary = onDemandWindow; + baseUsage.onDemandUsage = onDemandWindow; + + if (usageSummary.onDemandUsage.costUsd !== undefined) { + baseUsage.onDemandCostUsd = usageSummary.onDemandUsage.costUsd; + } + } + + // Parse billing cycle end + if (usageSummary.billingCycleEnd) { + baseUsage.billingCycleEnd = usageSummary.billingCycleEnd; + } + + // Parse plan type + if (usageSummary.plan) { + baseUsage.plan = { + type: usageSummary.plan, + displayName: this.formatPlanName(usageSummary.plan), + isPaid: usageSummary.plan.toLowerCase() !== 'free', + }; + } + + logger.info( + `[fetchUsageData] ✓ Cursor usage: Plan=${baseUsage.planUsage?.usedPercent || 0}%, ` + + `OnDemand=${baseUsage.onDemandUsage?.usedPercent || 0}%` + ); + + return baseUsage; + } + + /** + * Format reset time as human-readable string + */ + private formatResetTime(resetAt: string): string { + try { + const date = new Date(resetAt); + const now = new Date(); + const diff = date.getTime() - now.getTime(); + + if (diff < 0) return 'Expired'; + + const hours = Math.floor(diff / 3600000); + const days = Math.floor(hours / 24); + + if (days > 0) { + return `Resets in ${days}d`; + } + if (hours > 0) { + return `Resets in ${hours}h`; + } + return 'Resets soon'; + } catch { + return ''; + } + } + + /** + * Format plan name for display + */ + private formatPlanName(plan: string): string { + const planMap: Record = { + free: 'Free', + pro: 'Pro', + business: 'Business', + enterprise: 'Enterprise', + }; + return planMap[plan.toLowerCase()] || plan; + } + + /** + * Clear cached credentials (useful for logout) + */ + clearCache(): void { + this.cachedAccessToken = null; + this.cachedSessionCookie = null; + } +} diff --git a/apps/server/src/services/gemini-usage-service.ts b/apps/server/src/services/gemini-usage-service.ts new file mode 100644 index 00000000..beba1649 --- /dev/null +++ b/apps/server/src/services/gemini-usage-service.ts @@ -0,0 +1,362 @@ +/** + * Gemini Usage Service + * + * Fetches usage data from Google's Gemini/Cloud Code API using OAuth credentials. + * Based on CodexBar reference implementation. + * + * Authentication methods: + * 1. OAuth credentials from ~/.gemini/oauth_creds.json + * 2. API key (limited - only supports API calls, not quota info) + * + * API Endpoints: + * - POST https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota - Quota info + * - POST https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist - Tier detection + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { createLogger } from '@automaker/utils'; +import type { GeminiProviderUsage, UsageWindow } from '@automaker/types'; + +const logger = createLogger('GeminiUsage'); + +// Gemini API endpoints +const QUOTA_ENDPOINT = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota'; +const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist'; +const TOKEN_REFRESH_ENDPOINT = 'https://oauth2.googleapis.com/token'; + +// Gemini CLI client credentials (from Gemini CLI installation) +// These are embedded in the Gemini CLI and are public +const GEMINI_CLIENT_ID = + '764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com'; +const GEMINI_CLIENT_SECRET = 'd-FL95Q19q7MQmFpd7hHD0Ty'; + +interface GeminiOAuthCreds { + access_token: string; + refresh_token: string; + id_token?: string; + expiry_date: number; +} + +interface GeminiQuotaResponse { + quotas?: Array<{ + remainingFraction: number; + resetTime: string; + modelId?: string; + }>; +} + +interface GeminiCodeAssistResponse { + tier?: string; + claims?: { + hd?: string; + }; +} + +export class GeminiUsageService { + private cachedCreds: GeminiOAuthCreds | null = null; + private settingsPath = path.join(os.homedir(), '.gemini', 'settings.json'); + private credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); + + /** + * Check if Gemini credentials are available + */ + async isAvailable(): Promise { + const creds = await this.getOAuthCreds(); + return !!creds; + } + + /** + * Get authentication type from settings + */ + private getAuthType(): string | null { + try { + if (fs.existsSync(this.settingsPath)) { + const content = fs.readFileSync(this.settingsPath, 'utf8'); + const settings = JSON.parse(content); + return settings.auth_type || settings.authType || null; + } + } catch (error) { + logger.debug('Failed to read Gemini settings:', error); + } + return null; + } + + /** + * Get OAuth credentials from file + */ + private async getOAuthCreds(): Promise { + // Check auth type - only oauth-personal supports quota API + const authType = this.getAuthType(); + if (authType && authType !== 'oauth-personal') { + logger.debug(`Gemini auth type is ${authType}, not oauth-personal - quota API not available`); + return null; + } + + // Check cached credentials + if (this.cachedCreds) { + // Check if expired + if (this.cachedCreds.expiry_date > Date.now()) { + return this.cachedCreds; + } + // Try to refresh + const refreshed = await this.refreshToken(this.cachedCreds.refresh_token); + if (refreshed) { + this.cachedCreds = refreshed; + return this.cachedCreds; + } + } + + // Load from file + try { + if (fs.existsSync(this.credsPath)) { + const content = fs.readFileSync(this.credsPath, 'utf8'); + const creds = JSON.parse(content) as GeminiOAuthCreds; + + // Check if expired + if (creds.expiry_date && creds.expiry_date <= Date.now()) { + // Try to refresh + if (creds.refresh_token) { + const refreshed = await this.refreshToken(creds.refresh_token); + if (refreshed) { + this.cachedCreds = refreshed; + // Save refreshed credentials + this.saveCreds(refreshed); + return this.cachedCreds; + } + } + logger.warn('Gemini OAuth token expired and refresh failed'); + return null; + } + + this.cachedCreds = creds; + return this.cachedCreds; + } + } catch (error) { + logger.debug('Failed to read Gemini OAuth credentials:', error); + } + + return null; + } + + /** + * Refresh OAuth token + */ + private async refreshToken(refreshToken: string): Promise { + try { + const response = await fetch(TOKEN_REFRESH_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: GEMINI_CLIENT_ID, + client_secret: GEMINI_CLIENT_SECRET, + refresh_token: refreshToken, + grant_type: 'refresh_token', + }), + }); + + if (!response.ok) { + logger.error(`Token refresh failed: ${response.status}`); + return null; + } + + const data = (await response.json()) as { + access_token: string; + expires_in: number; + id_token?: string; + }; + + return { + access_token: data.access_token, + refresh_token: refreshToken, + id_token: data.id_token, + expiry_date: Date.now() + data.expires_in * 1000, + }; + } catch (error) { + logger.error('Failed to refresh Gemini token:', error); + return null; + } + } + + /** + * Save credentials to file + */ + private saveCreds(creds: GeminiOAuthCreds): void { + try { + const dir = path.dirname(this.credsPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(this.credsPath, JSON.stringify(creds, null, 2)); + } catch (error) { + logger.warn('Failed to save Gemini credentials:', error); + } + } + + /** + * Make an authenticated request to Gemini API + */ + private async makeRequest(url: string, body?: unknown): Promise { + const creds = await this.getOAuthCreds(); + if (!creds) { + return null; + } + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${creds.access_token}`, + 'Content-Type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + // Clear cached credentials on auth failure + this.cachedCreds = null; + logger.warn('Gemini API authentication failed'); + return null; + } + logger.error(`Gemini API error: ${response.status} ${response.statusText}`); + return null; + } + + return (await response.json()) as T; + } catch (error) { + logger.error('Failed to fetch from Gemini API:', error); + return null; + } + } + + /** + * Fetch usage data from Gemini + */ + async fetchUsageData(): Promise { + logger.info('[fetchUsageData] Starting Gemini usage fetch...'); + + const baseUsage: GeminiProviderUsage = { + providerId: 'gemini', + providerName: 'Gemini', + available: false, + lastUpdated: new Date().toISOString(), + }; + + // Check if credentials are available + const creds = await this.getOAuthCreds(); + if (!creds) { + baseUsage.error = 'Gemini OAuth credentials not available'; + return baseUsage; + } + + // Fetch quota information + const quotaResponse = await this.makeRequest(QUOTA_ENDPOINT, { + projectId: '-', // Use default project + }); + + if (quotaResponse?.quotas && quotaResponse.quotas.length > 0) { + baseUsage.available = true; + + const primaryQuota = quotaResponse.quotas[0]; + + // Convert remaining fraction to used percent + const usedPercent = Math.round((1 - (primaryQuota.remainingFraction || 0)) * 100); + + const quotaWindow: UsageWindow = { + name: 'Quota', + usedPercent, + resetsAt: primaryQuota.resetTime || '', + resetText: primaryQuota.resetTime ? this.formatResetTime(primaryQuota.resetTime) : '', + }; + + baseUsage.primary = quotaWindow; + baseUsage.remainingFraction = primaryQuota.remainingFraction; + baseUsage.modelId = primaryQuota.modelId; + } + + // Fetch tier information + const codeAssistResponse = await this.makeRequest( + CODE_ASSIST_ENDPOINT, + { + metadata: { + ide: 'automaker', + }, + } + ); + + if (codeAssistResponse?.tier) { + baseUsage.tierType = codeAssistResponse.tier; + + // Determine plan info from tier + const tierMap: Record = { + 'standard-tier': { type: 'paid', displayName: 'Paid', isPaid: true }, + 'free-tier': { + type: codeAssistResponse.claims?.hd ? 'workspace' : 'free', + displayName: codeAssistResponse.claims?.hd ? 'Workspace' : 'Free', + isPaid: false, + }, + 'legacy-tier': { type: 'legacy', displayName: 'Legacy', isPaid: false }, + }; + + const tierInfo = tierMap[codeAssistResponse.tier] || { + type: codeAssistResponse.tier, + displayName: codeAssistResponse.tier, + isPaid: false, + }; + + baseUsage.plan = tierInfo; + } + + if (baseUsage.available) { + logger.info( + `[fetchUsageData] ✓ Gemini usage: ${baseUsage.primary?.usedPercent || 0}% used, ` + + `tier=${baseUsage.tierType || 'unknown'}` + ); + } else { + baseUsage.error = 'Failed to fetch Gemini quota data'; + } + + return baseUsage; + } + + /** + * Format reset time as human-readable string + */ + private formatResetTime(resetAt: string): string { + try { + const date = new Date(resetAt); + const now = new Date(); + const diff = date.getTime() - now.getTime(); + + if (diff < 0) return 'Expired'; + + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + return `Resets in ${days}d ${hours % 24}h`; + } + if (hours > 0) { + return `Resets in ${hours}h ${minutes % 60}m`; + } + if (minutes > 0) { + return `Resets in ${minutes}m`; + } + return 'Resets soon'; + } catch { + return ''; + } + } + + /** + * Clear cached credentials + */ + clearCache(): void { + this.cachedCreds = null; + } +} diff --git a/apps/server/src/services/glm-usage-service.ts b/apps/server/src/services/glm-usage-service.ts new file mode 100644 index 00000000..29f7b1d9 --- /dev/null +++ b/apps/server/src/services/glm-usage-service.ts @@ -0,0 +1,140 @@ +/** + * GLM (z.AI) Usage Service + * + * Fetches usage data from z.AI's API. + * GLM is a Claude-compatible provider offered by z.AI. + * + * Authentication: + * - API Token from provider config or GLM_API_KEY environment variable + * + * Note: z.AI's API may not expose a dedicated usage endpoint. + * This service checks for API availability and reports basic status. + */ + +import { createLogger } from '@automaker/utils'; +import type { GLMProviderUsage, ClaudeCompatibleProvider } from '@automaker/types'; + +const logger = createLogger('GLMUsage'); + +// GLM API base (z.AI) +const GLM_API_BASE = 'https://api.z.ai'; + +export class GLMUsageService { + private providerConfig: ClaudeCompatibleProvider | null = null; + private cachedApiKey: string | null = null; + + /** + * Set the provider config (called from settings) + */ + setProviderConfig(config: ClaudeCompatibleProvider | null): void { + this.providerConfig = config; + this.cachedApiKey = null; + } + + /** + * Check if GLM is available + */ + async isAvailable(): Promise { + const apiKey = this.getApiKey(); + return !!apiKey; + } + + /** + * Get API key from various sources + */ + private getApiKey(): string | null { + if (this.cachedApiKey) { + return this.cachedApiKey; + } + + // 1. Check environment variable + if (process.env.GLM_API_KEY) { + this.cachedApiKey = process.env.GLM_API_KEY; + return this.cachedApiKey; + } + + // 2. Check provider config + if (this.providerConfig?.apiKey) { + this.cachedApiKey = this.providerConfig.apiKey; + return this.cachedApiKey; + } + + return null; + } + + /** + * Fetch usage data from GLM + * + * Note: z.AI may not have a public usage API. + * This returns basic availability status. + */ + async fetchUsageData(): Promise { + logger.info('[fetchUsageData] Starting GLM usage fetch...'); + + const baseUsage: GLMProviderUsage = { + providerId: 'glm', + providerName: 'z.AI GLM', + available: false, + lastUpdated: new Date().toISOString(), + }; + + const apiKey = this.getApiKey(); + if (!apiKey) { + baseUsage.error = 'GLM API key not available'; + return baseUsage; + } + + // GLM/z.AI is available if we have an API key + // z.AI doesn't appear to have a public usage endpoint + baseUsage.available = true; + + // Check if API key is valid by making a simple request + try { + const baseUrl = this.providerConfig?.baseUrl || GLM_API_BASE; + const response = await fetch(`${baseUrl}/api/anthropic/v1/messages`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'GLM-4.7', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }), + }); + + // We just want to check if auth works, not actually make a request + // A 400 with invalid request is fine - it means auth worked + if (response.status === 401 || response.status === 403) { + baseUsage.available = false; + baseUsage.error = 'GLM API authentication failed'; + } + } catch (error) { + // Network error or other issue - still mark as available since we have the key + logger.debug('GLM API check failed (may be fine):', error); + } + + // Note: z.AI doesn't appear to expose usage metrics via API + // Users should check their z.AI dashboard for detailed usage + if (baseUsage.available) { + baseUsage.plan = { + type: 'api', + displayName: 'API Access', + isPaid: true, + }; + } + + logger.info(`[fetchUsageData] GLM available: ${baseUsage.available}`); + + return baseUsage; + } + + /** + * Clear cached credentials + */ + clearCache(): void { + this.cachedApiKey = null; + } +} diff --git a/apps/server/src/services/minimax-usage-service.ts b/apps/server/src/services/minimax-usage-service.ts new file mode 100644 index 00000000..8cad41c2 --- /dev/null +++ b/apps/server/src/services/minimax-usage-service.ts @@ -0,0 +1,260 @@ +/** + * MiniMax Usage Service + * + * Fetches usage data from MiniMax's coding plan API. + * Based on CodexBar reference implementation. + * + * Authentication methods: + * 1. API Token (MINIMAX_API_KEY environment variable or provider config) + * 2. Cookie-based authentication (from platform login) + * + * API Endpoints: + * - GET https://api.minimax.io/v1/coding_plan/remains - Token-based usage + * - GET https://platform.minimax.io/v1/api/openplatform/coding_plan/remains - Fallback + * + * For China mainland: platform.minimaxi.com + */ + +import { createLogger } from '@automaker/utils'; +import type { MiniMaxProviderUsage, UsageWindow, ClaudeCompatibleProvider } from '@automaker/types'; + +const logger = createLogger('MiniMaxUsage'); + +// MiniMax API endpoints +const MINIMAX_API_BASE = 'https://api.minimax.io'; +const MINIMAX_PLATFORM_BASE = 'https://platform.minimax.io'; +const MINIMAX_CHINA_BASE = 'https://platform.minimaxi.com'; + +const CODING_PLAN_ENDPOINT = '/v1/coding_plan/remains'; +const PLATFORM_CODING_PLAN_ENDPOINT = '/v1/api/openplatform/coding_plan/remains'; + +interface MiniMaxCodingPlanResponse { + base_resp?: { + status_code?: number; + status_msg?: string; + }; + model_remains?: Array<{ + model: string; + used: number; + total: number; + }>; + remains_time?: number; // Seconds until reset + start_time?: string; + end_time?: string; +} + +export class MiniMaxUsageService { + private providerConfig: ClaudeCompatibleProvider | null = null; + private cachedApiKey: string | null = null; + + /** + * Set the provider config (called from settings) + */ + setProviderConfig(config: ClaudeCompatibleProvider | null): void { + this.providerConfig = config; + this.cachedApiKey = null; // Clear cache when config changes + } + + /** + * Check if MiniMax is available + */ + async isAvailable(): Promise { + const apiKey = this.getApiKey(); + return !!apiKey; + } + + /** + * Get API key from various sources + */ + private getApiKey(): string | null { + if (this.cachedApiKey) { + return this.cachedApiKey; + } + + // 1. Check environment variable + if (process.env.MINIMAX_API_KEY) { + this.cachedApiKey = process.env.MINIMAX_API_KEY; + return this.cachedApiKey; + } + + // 2. Check provider config + if (this.providerConfig?.apiKey) { + this.cachedApiKey = this.providerConfig.apiKey; + return this.cachedApiKey; + } + + return null; + } + + /** + * Determine if we should use China endpoint + */ + private isChina(): boolean { + if (this.providerConfig?.baseUrl) { + return this.providerConfig.baseUrl.includes('minimaxi.com'); + } + return false; + } + + /** + * Make an authenticated request to MiniMax API + */ + private async makeRequest(url: string): Promise { + const apiKey = this.getApiKey(); + if (!apiKey) { + return null; + } + + try { + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + this.cachedApiKey = null; + logger.warn('MiniMax API authentication failed'); + return null; + } + logger.error(`MiniMax API error: ${response.status} ${response.statusText}`); + return null; + } + + return (await response.json()) as T; + } catch (error) { + logger.error('Failed to fetch from MiniMax API:', error); + return null; + } + } + + /** + * Fetch usage data from MiniMax + */ + async fetchUsageData(): Promise { + logger.info('[fetchUsageData] Starting MiniMax usage fetch...'); + + const baseUsage: MiniMaxProviderUsage = { + providerId: 'minimax', + providerName: 'MiniMax', + available: false, + lastUpdated: new Date().toISOString(), + }; + + const apiKey = this.getApiKey(); + if (!apiKey) { + baseUsage.error = 'MiniMax API key not available'; + return baseUsage; + } + + // Determine the correct endpoint + const isChina = this.isChina(); + const baseUrl = isChina ? MINIMAX_CHINA_BASE : MINIMAX_API_BASE; + const endpoint = `${baseUrl}${CODING_PLAN_ENDPOINT}`; + + // Fetch coding plan data + let codingPlan = await this.makeRequest(endpoint); + + // Try fallback endpoint if primary fails + if (!codingPlan) { + const platformBase = isChina ? MINIMAX_CHINA_BASE : MINIMAX_PLATFORM_BASE; + const fallbackEndpoint = `${platformBase}${PLATFORM_CODING_PLAN_ENDPOINT}`; + codingPlan = await this.makeRequest(fallbackEndpoint); + } + + if (!codingPlan) { + baseUsage.error = 'Failed to fetch MiniMax usage data'; + return baseUsage; + } + + // Check for error response + if (codingPlan.base_resp?.status_code && codingPlan.base_resp.status_code !== 0) { + baseUsage.error = codingPlan.base_resp.status_msg || 'MiniMax API error'; + return baseUsage; + } + + baseUsage.available = true; + + // Parse model remains + if (codingPlan.model_remains && codingPlan.model_remains.length > 0) { + let totalUsed = 0; + let totalLimit = 0; + + for (const model of codingPlan.model_remains) { + totalUsed += model.used; + totalLimit += model.total; + } + + const usedPercent = totalLimit > 0 ? Math.round((totalUsed / totalLimit) * 100) : 0; + + // Calculate reset time + const resetsAt = codingPlan.remains_time + ? new Date(Date.now() + codingPlan.remains_time * 1000).toISOString() + : codingPlan.end_time || ''; + + const usageWindow: UsageWindow = { + name: 'Coding Plan', + usedPercent, + resetsAt, + resetText: resetsAt ? this.formatResetTime(resetsAt) : '', + used: totalUsed, + limit: totalLimit, + }; + + baseUsage.primary = usageWindow; + baseUsage.tokenRemains = totalLimit - totalUsed; + baseUsage.totalTokens = totalLimit; + } + + // Parse plan times + if (codingPlan.start_time) { + baseUsage.planStartTime = codingPlan.start_time; + } + if (codingPlan.end_time) { + baseUsage.planEndTime = codingPlan.end_time; + } + + logger.info( + `[fetchUsageData] ✓ MiniMax usage: ${baseUsage.primary?.usedPercent || 0}% used, ` + + `${baseUsage.tokenRemains || 0} tokens remaining` + ); + + return baseUsage; + } + + /** + * Format reset time as human-readable string + */ + private formatResetTime(resetAt: string): string { + try { + const date = new Date(resetAt); + const now = new Date(); + const diff = date.getTime() - now.getTime(); + + if (diff < 0) return 'Expired'; + + const hours = Math.floor(diff / 3600000); + const days = Math.floor(hours / 24); + + if (days > 0) { + return `Resets in ${days}d`; + } + if (hours > 0) { + return `Resets in ${hours}h`; + } + return 'Resets soon'; + } catch { + return ''; + } + } + + /** + * Clear cached credentials + */ + clearCache(): void { + this.cachedApiKey = null; + } +} diff --git a/apps/server/src/services/opencode-usage-service.ts b/apps/server/src/services/opencode-usage-service.ts new file mode 100644 index 00000000..a4b8c334 --- /dev/null +++ b/apps/server/src/services/opencode-usage-service.ts @@ -0,0 +1,144 @@ +/** + * OpenCode Usage Service + * + * Fetches usage data from OpenCode's server API. + * Based on CodexBar reference implementation. + * + * Note: OpenCode usage tracking is limited as they use a proprietary + * server function API that requires browser cookies for authentication. + * This service provides basic status checking based on local config. + * + * API Endpoints (require browser cookies): + * - POST https://opencode.ai/_server - Server functions + * - workspaces: Get workspace info + * - subscription.get: Get usage data + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { createLogger } from '@automaker/utils'; +import type { OpenCodeProviderUsage, UsageWindow } from '@automaker/types'; + +const logger = createLogger('OpenCodeUsage'); + +// OpenCode config locations +const OPENCODE_CONFIG_PATHS = [ + path.join(os.homedir(), '.opencode', 'config.json'), + path.join(os.homedir(), '.config', 'opencode', 'config.json'), +]; + +interface OpenCodeConfig { + workspaceId?: string; + email?: string; + authenticated?: boolean; +} + +interface OpenCodeUsageData { + rollingUsage?: { + usagePercent: number; + resetInSec: number; + }; + weeklyUsage?: { + usagePercent: number; + resetInSec: number; + }; +} + +export class OpenCodeUsageService { + private cachedConfig: OpenCodeConfig | null = null; + + /** + * Check if OpenCode is available + */ + async isAvailable(): Promise { + const config = this.getConfig(); + return !!config?.authenticated; + } + + /** + * Get OpenCode config from disk + */ + private getConfig(): OpenCodeConfig | null { + if (this.cachedConfig) { + return this.cachedConfig; + } + + // Check environment variable for workspace ID + if (process.env.OPENCODE_WORKSPACE_ID) { + this.cachedConfig = { + workspaceId: process.env.OPENCODE_WORKSPACE_ID, + authenticated: true, + }; + return this.cachedConfig; + } + + // Check config files + for (const configPath of OPENCODE_CONFIG_PATHS) { + try { + if (fs.existsSync(configPath)) { + const content = fs.readFileSync(configPath, 'utf8'); + const config = JSON.parse(content) as OpenCodeConfig; + this.cachedConfig = config; + return this.cachedConfig; + } + } catch (error) { + logger.debug(`Failed to read OpenCode config from ${configPath}:`, error); + } + } + + return null; + } + + /** + * Fetch usage data from OpenCode + * + * Note: OpenCode's usage API requires browser cookies which we don't have access to. + * This implementation returns basic availability status. + * For full usage tracking, users should check the OpenCode dashboard. + */ + async fetchUsageData(): Promise { + logger.info('[fetchUsageData] Starting OpenCode usage fetch...'); + + const baseUsage: OpenCodeProviderUsage = { + providerId: 'opencode', + providerName: 'OpenCode', + available: false, + lastUpdated: new Date().toISOString(), + }; + + const config = this.getConfig(); + if (!config) { + baseUsage.error = 'OpenCode not configured'; + return baseUsage; + } + + if (!config.authenticated) { + baseUsage.error = 'OpenCode not authenticated'; + return baseUsage; + } + + // OpenCode is available but we can't get detailed usage without browser cookies + baseUsage.available = true; + baseUsage.workspaceId = config.workspaceId; + + // Note: Full usage tracking requires browser cookie authentication + // which is not available in a server-side context. + // Users should check the OpenCode dashboard for detailed usage. + baseUsage.error = + 'Usage details require browser authentication. Check opencode.ai for details.'; + + logger.info( + `[fetchUsageData] OpenCode available, workspace: ${config.workspaceId || 'unknown'}` + ); + + return baseUsage; + } + + /** + * Clear cached config + */ + clearCache(): void { + this.cachedConfig = null; + } +} diff --git a/apps/server/src/services/provider-usage-tracker.ts b/apps/server/src/services/provider-usage-tracker.ts new file mode 100644 index 00000000..6f789096 --- /dev/null +++ b/apps/server/src/services/provider-usage-tracker.ts @@ -0,0 +1,447 @@ +/** + * Provider Usage Tracker + * + * Unified service that aggregates usage data from all supported AI providers. + * Manages caching, polling, and coordination of individual usage services. + * + * Supported providers: + * - Claude (via ClaudeUsageService) + * - Codex (via CodexUsageService) + * - Cursor (via CursorUsageService) + * - Gemini (via GeminiUsageService) + * - GitHub Copilot (via CopilotUsageService) + * - OpenCode (via OpenCodeUsageService) + * - MiniMax (via MiniMaxUsageService) + * - GLM (via GLMUsageService) + */ + +import { createLogger } from '@automaker/utils'; +import type { + UsageProviderId, + ProviderUsage, + AllProvidersUsage, + ClaudeProviderUsage, + CodexProviderUsage, + ClaudeCompatibleProvider, +} from '@automaker/types'; +import { ClaudeUsageService } from './claude-usage-service.js'; +import { CodexUsageService, type CodexUsageData } from './codex-usage-service.js'; +import { CursorUsageService } from './cursor-usage-service.js'; +import { GeminiUsageService } from './gemini-usage-service.js'; +import { CopilotUsageService } from './copilot-usage-service.js'; +import { OpenCodeUsageService } from './opencode-usage-service.js'; +import { MiniMaxUsageService } from './minimax-usage-service.js'; +import { GLMUsageService } from './glm-usage-service.js'; +import type { ClaudeUsage } from '../routes/claude/types.js'; + +const logger = createLogger('ProviderUsageTracker'); + +// Cache TTL in milliseconds (1 minute) +const CACHE_TTL_MS = 60 * 1000; + +interface CachedUsage { + data: ProviderUsage; + fetchedAt: number; +} + +export class ProviderUsageTracker { + private claudeService: ClaudeUsageService; + private codexService: CodexUsageService; + private cursorService: CursorUsageService; + private geminiService: GeminiUsageService; + private copilotService: CopilotUsageService; + private opencodeService: OpenCodeUsageService; + private minimaxService: MiniMaxUsageService; + private glmService: GLMUsageService; + + private cache: Map = new Map(); + private enabledProviders: Set = new Set([ + 'claude', + 'codex', + 'cursor', + 'gemini', + 'copilot', + 'opencode', + 'minimax', + 'glm', + ]); + + constructor(codexService?: CodexUsageService) { + this.claudeService = new ClaudeUsageService(); + this.codexService = codexService || new CodexUsageService(); + this.cursorService = new CursorUsageService(); + this.geminiService = new GeminiUsageService(); + this.copilotService = new CopilotUsageService(); + this.opencodeService = new OpenCodeUsageService(); + this.minimaxService = new MiniMaxUsageService(); + this.glmService = new GLMUsageService(); + } + + /** + * Set enabled providers (called when settings change) + */ + setEnabledProviders(providers: UsageProviderId[]): void { + this.enabledProviders = new Set(providers); + } + + /** + * Update custom provider configs (MiniMax, GLM) + */ + updateCustomProviderConfigs(providers: ClaudeCompatibleProvider[]): void { + const minimaxConfig = providers.find( + (p) => p.providerType === 'minimax' && p.enabled !== false + ); + const glmConfig = providers.find((p) => p.providerType === 'glm' && p.enabled !== false); + + this.minimaxService.setProviderConfig(minimaxConfig || null); + this.glmService.setProviderConfig(glmConfig || null); + } + + /** + * Check if a provider is enabled + */ + isProviderEnabled(providerId: UsageProviderId): boolean { + return this.enabledProviders.has(providerId); + } + + /** + * Check if cached data is still fresh + */ + private isCacheFresh(providerId: UsageProviderId): boolean { + const cached = this.cache.get(providerId); + if (!cached) return false; + return Date.now() - cached.fetchedAt < CACHE_TTL_MS; + } + + /** + * Get cached data for a provider + */ + private getCached(providerId: UsageProviderId): ProviderUsage | null { + const cached = this.cache.get(providerId); + return cached?.data || null; + } + + /** + * Set cached data for a provider + */ + private setCached(providerId: UsageProviderId, data: ProviderUsage): void { + this.cache.set(providerId, { + data, + fetchedAt: Date.now(), + }); + } + + /** + * Convert Claude usage to unified format + */ + private convertClaudeUsage(usage: ClaudeUsage): ClaudeProviderUsage { + return { + providerId: 'claude', + providerName: 'Claude', + available: true, + lastUpdated: usage.lastUpdated, + userTimezone: usage.userTimezone, + primary: { + name: 'Session (5-hour)', + usedPercent: usage.sessionPercentage, + resetsAt: usage.sessionResetTime, + resetText: usage.sessionResetText, + }, + secondary: { + name: 'Weekly (All Models)', + usedPercent: usage.weeklyPercentage, + resetsAt: usage.weeklyResetTime, + resetText: usage.weeklyResetText, + }, + sessionWindow: { + name: 'Session (5-hour)', + usedPercent: usage.sessionPercentage, + resetsAt: usage.sessionResetTime, + resetText: usage.sessionResetText, + }, + weeklyWindow: { + name: 'Weekly (All Models)', + usedPercent: usage.weeklyPercentage, + resetsAt: usage.weeklyResetTime, + resetText: usage.weeklyResetText, + }, + sonnetWindow: { + name: 'Weekly (Sonnet)', + usedPercent: usage.sonnetWeeklyPercentage, + resetsAt: usage.weeklyResetTime, + resetText: usage.sonnetResetText, + }, + cost: + usage.costUsed !== null + ? { + used: usage.costUsed, + limit: usage.costLimit, + currency: usage.costCurrency || 'USD', + } + : undefined, + }; + } + + /** + * Convert Codex usage to unified format + */ + private convertCodexUsage(usage: CodexUsageData): CodexProviderUsage { + const result: CodexProviderUsage = { + providerId: 'codex', + providerName: 'Codex', + available: true, + lastUpdated: usage.lastUpdated, + planType: usage.rateLimits?.planType, + }; + + if (usage.rateLimits?.primary) { + result.primary = { + name: `${usage.rateLimits.primary.windowDurationMins}min Window`, + usedPercent: usage.rateLimits.primary.usedPercent, + resetsAt: new Date(usage.rateLimits.primary.resetsAt * 1000).toISOString(), + resetText: this.formatResetTime(usage.rateLimits.primary.resetsAt * 1000), + windowDurationMins: usage.rateLimits.primary.windowDurationMins, + }; + } + + if (usage.rateLimits?.secondary) { + result.secondary = { + name: `${usage.rateLimits.secondary.windowDurationMins}min Window`, + usedPercent: usage.rateLimits.secondary.usedPercent, + resetsAt: new Date(usage.rateLimits.secondary.resetsAt * 1000).toISOString(), + resetText: this.formatResetTime(usage.rateLimits.secondary.resetsAt * 1000), + windowDurationMins: usage.rateLimits.secondary.windowDurationMins, + }; + } + + if (usage.rateLimits?.planType) { + result.plan = { + type: usage.rateLimits.planType, + displayName: + usage.rateLimits.planType.charAt(0).toUpperCase() + usage.rateLimits.planType.slice(1), + isPaid: usage.rateLimits.planType !== 'free', + }; + } + + return result; + } + + /** + * Format reset time as human-readable string + */ + private formatResetTime(resetAtMs: number): string { + const diff = resetAtMs - Date.now(); + if (diff < 0) return 'Expired'; + + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `Resets in ${days}d ${hours % 24}h`; + if (hours > 0) return `Resets in ${hours}h ${minutes % 60}m`; + if (minutes > 0) return `Resets in ${minutes}m`; + return 'Resets soon'; + } + + /** + * Fetch usage for a specific provider + */ + async fetchProviderUsage( + providerId: UsageProviderId, + forceRefresh = false + ): Promise { + // Check cache first + if (!forceRefresh && this.isCacheFresh(providerId)) { + return this.getCached(providerId); + } + + try { + let usage: ProviderUsage | null = null; + + switch (providerId) { + case 'claude': { + if (await this.claudeService.isAvailable()) { + const claudeUsage = await this.claudeService.fetchUsageData(); + usage = this.convertClaudeUsage(claudeUsage); + } else { + usage = { + providerId: 'claude', + providerName: 'Claude', + available: false, + lastUpdated: new Date().toISOString(), + error: 'Claude CLI not available', + }; + } + break; + } + + case 'codex': { + if (await this.codexService.isAvailable()) { + const codexUsage = await this.codexService.fetchUsageData(); + usage = this.convertCodexUsage(codexUsage); + } else { + usage = { + providerId: 'codex', + providerName: 'Codex', + available: false, + lastUpdated: new Date().toISOString(), + error: 'Codex CLI not available', + }; + } + break; + } + + case 'cursor': { + usage = await this.cursorService.fetchUsageData(); + break; + } + + case 'gemini': { + usage = await this.geminiService.fetchUsageData(); + break; + } + + case 'copilot': { + usage = await this.copilotService.fetchUsageData(); + break; + } + + case 'opencode': { + usage = await this.opencodeService.fetchUsageData(); + break; + } + + case 'minimax': { + usage = await this.minimaxService.fetchUsageData(); + break; + } + + case 'glm': { + usage = await this.glmService.fetchUsageData(); + break; + } + } + + if (usage) { + this.setCached(providerId, usage); + } + + return usage; + } catch (error) { + logger.error(`Failed to fetch usage for ${providerId}:`, error); + return { + providerId, + providerName: this.getProviderName(providerId), + available: false, + lastUpdated: new Date().toISOString(), + error: error instanceof Error ? error.message : 'Unknown error', + } as ProviderUsage; + } + } + + /** + * Get provider display name + */ + private getProviderName(providerId: UsageProviderId): string { + const names: Record = { + claude: 'Claude', + codex: 'Codex', + cursor: 'Cursor', + gemini: 'Gemini', + copilot: 'GitHub Copilot', + opencode: 'OpenCode', + minimax: 'MiniMax', + glm: 'z.AI GLM', + }; + return names[providerId] || providerId; + } + + /** + * Fetch usage for all enabled providers + */ + async fetchAllUsage(forceRefresh = false): Promise { + const providers: Partial> = {}; + const errors: Array<{ providerId: UsageProviderId; message: string }> = []; + + // Fetch all enabled providers in parallel + const enabledList = Array.from(this.enabledProviders); + const results = await Promise.allSettled( + enabledList.map((providerId) => this.fetchProviderUsage(providerId, forceRefresh)) + ); + + results.forEach((result, index) => { + const providerId = enabledList[index]; + + if (result.status === 'fulfilled' && result.value) { + providers[providerId] = result.value; + if (result.value.error) { + errors.push({ + providerId, + message: result.value.error, + }); + } + } else if (result.status === 'rejected') { + errors.push({ + providerId, + message: result.reason?.message || 'Unknown error', + }); + } + }); + + return { + providers, + lastUpdated: new Date().toISOString(), + errors, + }; + } + + /** + * Check availability for all providers + */ + async checkAvailability(): Promise> { + const availability: Record = {}; + + const checks = await Promise.allSettled([ + this.claudeService.isAvailable(), + this.codexService.isAvailable(), + this.cursorService.isAvailable(), + this.geminiService.isAvailable(), + this.copilotService.isAvailable(), + this.opencodeService.isAvailable(), + this.minimaxService.isAvailable(), + this.glmService.isAvailable(), + ]); + + const providerIds: UsageProviderId[] = [ + 'claude', + 'codex', + 'cursor', + 'gemini', + 'copilot', + 'opencode', + 'minimax', + 'glm', + ]; + + checks.forEach((result, index) => { + availability[providerIds[index]] = + result.status === 'fulfilled' ? result.value : false; + }); + + return availability as Record; + } + + /** + * Clear all caches + */ + clearCache(): void { + this.cache.clear(); + this.claudeService = new ClaudeUsageService(); // Reset Claude service + this.cursorService.clearCache(); + this.geminiService.clearCache(); + this.copilotService.clearCache(); + this.opencodeService.clearCache(); + this.minimaxService.clearCache(); + this.glmService.clearCache(); + } +} diff --git a/apps/ui/src/components/provider-usage-bar.tsx b/apps/ui/src/components/provider-usage-bar.tsx new file mode 100644 index 00000000..adfe513f --- /dev/null +++ b/apps/ui/src/components/provider-usage-bar.tsx @@ -0,0 +1,389 @@ +/** + * Provider Usage Bar + * + * A compact usage bar that displays usage statistics for all enabled AI providers. + * Shows a unified view with individual provider usage indicators. + */ + +import { useState, useMemo } from 'react'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Button } from '@/components/ui/button'; +import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; +import { cn } from '@/lib/utils'; +import { + AnthropicIcon, + OpenAIIcon, + CursorIcon, + GeminiIcon, + OpenCodeIcon, + MiniMaxIcon, + GlmIcon, +} from '@/components/ui/provider-icon'; +import { useAllProvidersUsage } from '@/hooks/queries'; +import type { UsageProviderId, ProviderUsage } from '@automaker/types'; +import { getMaxUsagePercent } from '@automaker/types'; + +// GitHub icon component +function GitHubIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +// Provider icon mapping +const PROVIDER_ICONS: Record> = { + claude: AnthropicIcon, + codex: OpenAIIcon, + cursor: CursorIcon, + gemini: GeminiIcon, + copilot: GitHubIcon, + opencode: OpenCodeIcon, + minimax: MiniMaxIcon, + glm: GlmIcon, +}; + +// Provider dashboard URLs +const PROVIDER_DASHBOARD_URLS: Record = { + claude: 'https://status.claude.com', + codex: 'https://platform.openai.com/usage', + cursor: 'https://cursor.com/settings', + gemini: 'https://aistudio.google.com', + copilot: 'https://github.com/settings/copilot', + opencode: 'https://opencode.ai', + minimax: 'https://platform.minimax.io/user-center/payment/coding-plan', + glm: 'https://z.ai/account', +}; + +// Helper to get status color based on percentage +function getStatusInfo(percentage: number) { + if (percentage >= 90) return { color: 'text-red-500', icon: XCircle, bg: 'bg-red-500' }; + if (percentage >= 75) return { color: 'text-orange-500', icon: AlertTriangle, bg: 'bg-orange-500' }; + if (percentage >= 50) return { color: 'text-yellow-500', icon: AlertTriangle, bg: 'bg-yellow-500' }; + return { color: 'text-green-500', icon: CheckCircle, bg: 'bg-green-500' }; +} + +// Progress bar component +function ProgressBar({ percentage, colorClass }: { percentage: number; colorClass: string }) { + return ( +
+
+
+ ); +} + +// Usage card component +function UsageCard({ + title, + subtitle, + percentage, + resetText, + isPrimary = false, + stale = false, +}: { + title: string; + subtitle: string; + percentage: number; + resetText?: string; + isPrimary?: boolean; + stale?: boolean; +}) { + const isValidPercentage = + typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage); + const safePercentage = isValidPercentage ? percentage : 0; + + const status = getStatusInfo(safePercentage); + const StatusIcon = status.icon; + + return ( +
+
+
+

{title}

+

{subtitle}

+
+ {isValidPercentage ? ( +
+ + + {Math.round(safePercentage)}% + +
+ ) : ( + N/A + )} +
+ + {resetText && ( +
+

+ + {resetText} +

+
+ )} +
+ ); +} + +// Provider usage panel component +function ProviderUsagePanel({ + providerId, + usage, + isStale, +}: { + providerId: UsageProviderId; + usage: ProviderUsage; + isStale: boolean; +}) { + const ProviderIcon = PROVIDER_ICONS[providerId]; + const dashboardUrl = PROVIDER_DASHBOARD_URLS[providerId]; + + if (!usage.available) { + return ( +
+
+ + {usage.providerName} +
+
+ +

+ {usage.error || 'Not available'} +

+
+
+ ); + } + + return ( +
+
+
+ + {usage.providerName} +
+ {usage.plan && ( + + {usage.plan.displayName} + + )} +
+ + {usage.primary && ( + + )} + + {usage.secondary && ( + + )} + + {!usage.primary && !usage.secondary && ( +
+ {dashboardUrl ? ( + <> + Check{' '} + + dashboard + {' '} + for details + + ) : ( + 'No usage data available' + )} +
+ )} +
+ ); +} + +export function ProviderUsageBar() { + const [open, setOpen] = useState(false); + + const { + data: allUsage, + isLoading, + error, + dataUpdatedAt, + refetch, + } = useAllProvidersUsage(open); + + // Calculate overall max usage percentage + const { maxPercent, maxProviderId, availableCount } = useMemo(() => { + if (!allUsage?.providers) { + return { maxPercent: 0, maxProviderId: null as UsageProviderId | null, availableCount: 0 }; + } + + let max = 0; + let maxId: UsageProviderId | null = null; + let count = 0; + + for (const [id, usage] of Object.entries(allUsage.providers)) { + if (usage?.available) { + count++; + const percent = getMaxUsagePercent(usage); + if (percent > max) { + max = percent; + maxId = id as UsageProviderId; + } + } + } + + return { maxPercent: max, maxProviderId: maxId, availableCount: count }; + }, [allUsage]); + + // Check if data is stale (older than 2 minutes) + const isStale = !dataUpdatedAt || Date.now() - dataUpdatedAt > 2 * 60 * 1000; + + const getProgressBarColor = (percentage: number) => { + if (percentage >= 90) return 'bg-red-500'; + if (percentage >= 75) return 'bg-orange-500'; + if (percentage >= 50) return 'bg-yellow-500'; + return 'bg-green-500'; + }; + + // Get the icon for the provider with highest usage + const MaxProviderIcon = maxProviderId ? PROVIDER_ICONS[maxProviderId] : AnthropicIcon; + const statusColor = getStatusInfo(maxPercent).color; + + // Get list of available providers for the dropdown + const availableProviders = useMemo(() => { + if (!allUsage?.providers) return []; + return Object.entries(allUsage.providers) + .filter(([_, usage]) => usage?.available) + .map(([id, usage]) => ({ id: id as UsageProviderId, usage: usage! })); + }, [allUsage]); + + const trigger = ( + + ); + + return ( + + {trigger} + + {/* Header */} +
+ Provider Usage + +
+ + {/* Content */} +
+ {isLoading && !allUsage ? ( +
+ +

Loading usage data...

+
+ ) : error ? ( +
+ +
+

Failed to load usage

+

+ {error instanceof Error ? error.message : 'Unknown error'} +

+
+
+ ) : availableProviders.length === 0 ? ( +
+ +
+

No providers available

+

+ Configure providers in Settings to track usage +

+
+
+ ) : ( + availableProviders.map(({ id, usage }) => ( + + )) + )} +
+ + {/* Footer */} +
+ + {availableCount} provider{availableCount !== 1 ? 's' : ''} active + + Updates every minute +
+
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index 77a272c9..a8d0424c 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -2,7 +2,7 @@ import { useCallback, useState } from 'react'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react'; -import { UsagePopover } from '@/components/usage-popover'; +import { ProviderUsageBar } from '@/components/provider-usage-bar'; import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { useIsTablet } from '@/hooks/use-media-query'; @@ -127,8 +127,8 @@ export function BoardHeader({
- {/* Usage Popover - show if either provider is authenticated, only on desktop */} - {isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && } + {/* Provider Usage Bar - shows all available providers, only on desktop */} + {isMounted && !isTablet && } {/* Tablet/Mobile view: show hamburger menu with all controls */} {isMounted && isTablet && ( diff --git a/apps/ui/src/hooks/queries/index.ts b/apps/ui/src/hooks/queries/index.ts index e58b5945..26c3e864 100644 --- a/apps/ui/src/hooks/queries/index.ts +++ b/apps/ui/src/hooks/queries/index.ts @@ -23,7 +23,13 @@ export { } from './use-github'; // Usage -export { useClaudeUsage, useCodexUsage } from './use-usage'; +export { + useClaudeUsage, + useCodexUsage, + useAllProvidersUsage, + useProviderUsage, + useProviderAvailability, +} from './use-usage'; // Running Agents export { useRunningAgents, useRunningAgentsCount } from './use-running-agents'; diff --git a/apps/ui/src/hooks/queries/use-usage.ts b/apps/ui/src/hooks/queries/use-usage.ts index 21f0267d..09c7d539 100644 --- a/apps/ui/src/hooks/queries/use-usage.ts +++ b/apps/ui/src/hooks/queries/use-usage.ts @@ -1,7 +1,7 @@ /** * Usage Query Hooks * - * React Query hooks for fetching Claude and Codex API usage data. + * React Query hooks for fetching Claude, Codex, and all provider API usage data. * These hooks include automatic polling for real-time usage updates. */ @@ -10,6 +10,7 @@ import { getElectronAPI } from '@/lib/electron'; import { queryKeys } from '@/lib/query-keys'; import { STALE_TIMES } from '@/lib/query-client'; import type { ClaudeUsage, CodexUsage } from '@/store/app-store'; +import type { AllProvidersUsage, UsageProviderId } from '@automaker/types'; /** Polling interval for usage data (60 seconds) */ const USAGE_POLLING_INTERVAL = 60 * 1000; @@ -81,3 +82,85 @@ export function useCodexUsage(enabled = true) { refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, }); } + +/** + * Fetch usage data for all enabled AI providers + * + * @param enabled - Whether the query should run (default: true) + * @returns Query result with all providers usage data + * + * @example + * ```tsx + * const { data: allUsage, isLoading } = useAllProvidersUsage(isPopoverOpen); + * ``` + */ +export function useAllProvidersUsage(enabled = true) { + return useQuery({ + queryKey: queryKeys.usage.all(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.providerUsage.getAll(); + if (!result.success || !result.data) { + throw new Error(result.error || 'Failed to fetch provider usage'); + } + return result.data; + }, + enabled, + staleTime: STALE_TIMES.USAGE, + refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false, + placeholderData: (previousData) => previousData, + refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS, + refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, + }); +} + +/** + * Fetch usage data for a specific provider + * + * @param providerId - The provider to fetch usage for + * @param enabled - Whether the query should run (default: true) + * @returns Query result with provider usage data + */ +export function useProviderUsage(providerId: UsageProviderId, enabled = true) { + return useQuery({ + queryKey: queryKeys.usage.provider(providerId), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.providerUsage.getProvider(providerId); + if (!result.success || !result.data) { + throw new Error(result.error || 'Failed to fetch provider usage'); + } + return result.data; + }, + enabled, + staleTime: STALE_TIMES.USAGE, + refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false, + placeholderData: (previousData) => previousData, + refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS, + refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, + }); +} + +/** + * Check availability of all providers + * + * @param enabled - Whether the query should run (default: true) + * @returns Query result with provider availability map + */ +export function useProviderAvailability(enabled = true) { + return useQuery({ + queryKey: queryKeys.usage.availability(), + queryFn: async (): Promise> => { + const api = getElectronAPI(); + const result = await api.providerUsage.getAvailability(); + if (!result.success || !result.data) { + throw new Error(result.error || 'Failed to fetch provider availability'); + } + return result.data; + }, + enabled, + staleTime: STALE_TIMES.STATUS, + refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS, + refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, + }); +} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index b2065b2b..8682e372 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -840,6 +840,26 @@ export interface ElectronAPI { error?: string; }>; }; + providerUsage?: { + getAll: (refresh?: boolean) => Promise<{ + success: boolean; + data?: import('@automaker/types').AllProvidersUsage; + error?: string; + }>; + getProvider: ( + providerId: import('@automaker/types').UsageProviderId, + refresh?: boolean + ) => Promise<{ + success: boolean; + data?: import('@automaker/types').ProviderUsage; + error?: string; + }>; + getAvailability: () => Promise<{ + success: boolean; + data?: Record; + error?: string; + }>; + }; settings?: { getStatus: () => Promise<{ success: boolean; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 5282374d..2730622c 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -2596,6 +2596,38 @@ export class HttpApiClient implements ElectronAPI { }, }; + // Provider Usage API (unified usage tracking for all providers) + providerUsage = { + getAll: ( + refresh = false + ): Promise<{ + success: boolean; + data?: import('@automaker/types').AllProvidersUsage; + error?: string; + }> => { + const url = `/api/provider-usage${refresh ? '?refresh=true' : ''}`; + return this.get(url); + }, + + getProvider: ( + providerId: import('@automaker/types').UsageProviderId, + refresh = false + ): Promise<{ + success: boolean; + data?: import('@automaker/types').ProviderUsage; + error?: string; + }> => { + const url = `/api/provider-usage/${providerId}${refresh ? '?refresh=true' : ''}`; + return this.get(url); + }, + + getAvailability: (): Promise<{ + success: boolean; + data?: Record; + error?: string; + }> => this.get('/api/provider-usage/availability'), + }; + // Context API context = { describeImage: ( diff --git a/apps/ui/src/lib/query-keys.ts b/apps/ui/src/lib/query-keys.ts index 794515c4..ebd56292 100644 --- a/apps/ui/src/lib/query-keys.ts +++ b/apps/ui/src/lib/query-keys.ts @@ -99,6 +99,12 @@ export const queryKeys = { claude: () => ['usage', 'claude'] as const, /** Codex API usage */ codex: () => ['usage', 'codex'] as const, + /** All providers usage */ + all: () => ['usage', 'all'] as const, + /** Single provider usage */ + provider: (providerId: string) => ['usage', 'provider', providerId] as const, + /** Provider availability */ + availability: () => ['usage', 'availability'] as const, }, // ============================================ diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 54d8cf3c..5ed7d607 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -353,3 +353,31 @@ export type { TerminalInfo } from './terminal.js'; // Test runner types export type { TestRunnerInfo } from './test-runner.js'; + +// Provider usage types +export type { + UsageWindow, + ProviderPlan, + UsageCost, + UsageProviderId, + BaseProviderUsage, + ClaudeProviderUsage, + CodexProviderUsage, + CursorProviderUsage, + GeminiProviderUsage, + CopilotProviderUsage, + OpenCodeProviderUsage, + MiniMaxProviderUsage, + GLMProviderUsage, + ProviderUsage, + AllProvidersUsage, + ProviderUsageResponse, + ProviderUsageOptions, + ProviderDisplayInfo, +} from './provider-usage.js'; +export { + PROVIDER_DISPLAY_INFO, + getMaxUsagePercent, + getUsageStatusColor, + formatResetTime, +} from './provider-usage.js'; diff --git a/libs/types/src/provider-usage.ts b/libs/types/src/provider-usage.ts new file mode 100644 index 00000000..863908ba --- /dev/null +++ b/libs/types/src/provider-usage.ts @@ -0,0 +1,375 @@ +/** + * Provider Usage Types + * + * Unified type definitions for tracking usage across all AI providers. + * Each provider can have different usage metrics, but all share a common + * structure for display in the UI. + */ + +/** + * Common usage window structure - represents a time-bounded usage period + * Used by Claude (session/weekly), Codex (rate limits), Cursor, Gemini, etc. + */ +export interface UsageWindow { + /** Display name for this window (e.g., "5-hour Session", "Weekly Limit") */ + name: string; + /** Percentage of quota used (0-100) */ + usedPercent: number; + /** When this window resets (ISO date string) */ + resetsAt: string; + /** Human-readable reset text (e.g., "Resets in 2h 15m") */ + resetText: string; + /** Window duration in minutes (if applicable) */ + windowDurationMins?: number; + /** Raw limit value (if available) */ + limit?: number; + /** Raw used value (if available) */ + used?: number; + /** Raw remaining value (if available) */ + remaining?: number; +} + +/** + * Plan/tier information for a provider + */ +export interface ProviderPlan { + /** Plan type identifier (e.g., "free", "pro", "max", "team", "enterprise") */ + type: string; + /** Display name for the plan */ + displayName: string; + /** Whether this is a paid plan */ + isPaid?: boolean; +} + +/** + * Cost/billing information (for pay-per-use providers) + */ +export interface UsageCost { + /** Amount used in current billing period */ + used: number; + /** Limit for current billing period (null if unlimited) */ + limit: number | null; + /** Currency code (e.g., "USD") */ + currency: string; +} + +/** + * Provider identifiers for usage tracking + */ +export type UsageProviderId = + | 'claude' + | 'codex' + | 'cursor' + | 'gemini' + | 'copilot' + | 'opencode' + | 'minimax' + | 'glm'; + +/** + * Base interface for all provider usage data + */ +export interface BaseProviderUsage { + /** Provider identifier */ + providerId: UsageProviderId; + /** Provider display name */ + providerName: string; + /** Whether this provider is available and authenticated */ + available: boolean; + /** Primary usage window (most important metric) */ + primary?: UsageWindow; + /** Secondary usage window (if applicable) */ + secondary?: UsageWindow; + /** Additional usage windows (for providers with more than 2) */ + additional?: UsageWindow[]; + /** Plan/tier information */ + plan?: ProviderPlan; + /** Cost/billing information */ + cost?: UsageCost; + /** Last time usage was fetched (ISO date string) */ + lastUpdated: string; + /** Error message if fetching failed */ + error?: string; +} + +/** + * Claude-specific usage data + */ +export interface ClaudeProviderUsage extends BaseProviderUsage { + providerId: 'claude'; + /** Session (5-hour) usage window */ + sessionWindow?: UsageWindow; + /** Weekly (all models) usage window */ + weeklyWindow?: UsageWindow; + /** Sonnet-specific weekly usage window */ + sonnetWindow?: UsageWindow; + /** User's timezone */ + userTimezone?: string; +} + +/** + * Codex-specific usage data + */ +export interface CodexProviderUsage extends BaseProviderUsage { + providerId: 'codex'; + /** Plan type (free, plus, pro, team, enterprise, edu) */ + planType?: string; +} + +/** + * Cursor-specific usage data + */ +export interface CursorProviderUsage extends BaseProviderUsage { + providerId: 'cursor'; + /** Included plan usage (fast requests) */ + planUsage?: UsageWindow; + /** On-demand/overage usage */ + onDemandUsage?: UsageWindow; + /** On-demand cost in USD */ + onDemandCostUsd?: number; + /** Billing cycle end date */ + billingCycleEnd?: string; +} + +/** + * Gemini-specific usage data + */ +export interface GeminiProviderUsage extends BaseProviderUsage { + providerId: 'gemini'; + /** Quota remaining fraction (0-1) */ + remainingFraction?: number; + /** Model ID for quota */ + modelId?: string; + /** Tier type (standard, free, workspace, legacy) */ + tierType?: string; +} + +/** + * GitHub Copilot-specific usage data + */ +export interface CopilotProviderUsage extends BaseProviderUsage { + providerId: 'copilot'; + /** Premium interactions quota */ + premiumInteractions?: UsageWindow; + /** Chat quota */ + chatQuota?: UsageWindow; + /** Copilot plan type */ + copilotPlan?: string; +} + +/** + * OpenCode-specific usage data + */ +export interface OpenCodeProviderUsage extends BaseProviderUsage { + providerId: 'opencode'; + /** Rolling 5-hour usage window */ + rollingWindow?: UsageWindow; + /** Weekly usage window */ + weeklyWindow?: UsageWindow; + /** Workspace ID */ + workspaceId?: string; +} + +/** + * MiniMax-specific usage data + */ +export interface MiniMaxProviderUsage extends BaseProviderUsage { + providerId: 'minimax'; + /** Coding plan token remains */ + tokenRemains?: number; + /** Total tokens in plan */ + totalTokens?: number; + /** Plan start time */ + planStartTime?: string; + /** Plan end time */ + planEndTime?: string; +} + +/** + * GLM (z.AI)-specific usage data + */ +export interface GLMProviderUsage extends BaseProviderUsage { + providerId: 'glm'; + /** Coding plan usage similar to MiniMax */ + tokenRemains?: number; + totalTokens?: number; + planStartTime?: string; + planEndTime?: string; +} + +/** + * Union type of all provider usage types + */ +export type ProviderUsage = + | ClaudeProviderUsage + | CodexProviderUsage + | CursorProviderUsage + | GeminiProviderUsage + | CopilotProviderUsage + | OpenCodeProviderUsage + | MiniMaxProviderUsage + | GLMProviderUsage; + +/** + * Aggregated usage data from all providers + */ +export interface AllProvidersUsage { + /** Usage data by provider ID */ + providers: Partial>; + /** Last time any usage was fetched */ + lastUpdated: string; + /** List of providers that are enabled but had errors */ + errors: Array<{ providerId: UsageProviderId; message: string }>; +} + +/** + * Response type for the unified usage endpoint + */ +export interface ProviderUsageResponse { + success: boolean; + data?: AllProvidersUsage; + error?: string; +} + +/** + * Request options for fetching provider usage + */ +export interface ProviderUsageOptions { + /** Which providers to fetch usage for (empty = all enabled) */ + providers?: UsageProviderId[]; + /** Force refresh even if cached data is fresh */ + forceRefresh?: boolean; +} + +/** + * Provider display information for UI + */ +export interface ProviderDisplayInfo { + id: UsageProviderId; + name: string; + icon: string; + color: string; + statusUrl?: string; + dashboardUrl?: string; +} + +/** + * Provider display metadata + */ +export const PROVIDER_DISPLAY_INFO: Record = { + claude: { + id: 'claude', + name: 'Claude', + icon: 'anthropic', + color: '#D97706', + statusUrl: 'https://status.claude.com', + dashboardUrl: 'https://console.anthropic.com', + }, + codex: { + id: 'codex', + name: 'Codex', + icon: 'openai', + color: '#10A37F', + statusUrl: 'https://status.openai.com', + dashboardUrl: 'https://platform.openai.com/usage', + }, + cursor: { + id: 'cursor', + name: 'Cursor', + icon: 'cursor', + color: '#6366F1', + dashboardUrl: 'https://cursor.com/settings', + }, + gemini: { + id: 'gemini', + name: 'Gemini', + icon: 'google', + color: '#4285F4', + dashboardUrl: 'https://aistudio.google.com', + }, + copilot: { + id: 'copilot', + name: 'GitHub Copilot', + icon: 'github', + color: '#24292E', + dashboardUrl: 'https://github.com/settings/copilot', + }, + opencode: { + id: 'opencode', + name: 'OpenCode', + icon: 'opencode', + color: '#FF6B6B', + dashboardUrl: 'https://opencode.ai', + }, + minimax: { + id: 'minimax', + name: 'MiniMax', + icon: 'minimax', + color: '#FF4081', + dashboardUrl: 'https://platform.minimax.io/user-center/payment/coding-plan', + }, + glm: { + id: 'glm', + name: 'z.AI GLM', + icon: 'glm', + color: '#00BFA5', + dashboardUrl: 'https://z.ai/account', + }, +}; + +/** + * Helper to calculate the maximum usage percentage across all windows + */ +export function getMaxUsagePercent(usage: ProviderUsage): number { + let max = 0; + if (usage.primary?.usedPercent !== undefined) { + max = Math.max(max, usage.primary.usedPercent); + } + if (usage.secondary?.usedPercent !== undefined) { + max = Math.max(max, usage.secondary.usedPercent); + } + if (usage.additional) { + for (const window of usage.additional) { + if (window.usedPercent !== undefined) { + max = Math.max(max, window.usedPercent); + } + } + } + return max; +} + +/** + * Helper to get usage status color based on percentage + */ +export function getUsageStatusColor(percent: number): 'green' | 'yellow' | 'orange' | 'red' { + if (percent >= 90) return 'red'; + if (percent >= 75) return 'orange'; + if (percent >= 50) return 'yellow'; + return 'green'; +} + +/** + * Helper to format reset time as human-readable string + */ +export function formatResetTime(resetAt: string | Date): string { + const date = typeof resetAt === 'string' ? new Date(resetAt) : resetAt; + const now = new Date(); + const diff = date.getTime() - now.getTime(); + + if (diff < 0) return 'Expired'; + + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + return `Resets in ${days}d ${hours % 24}h`; + } + if (hours > 0) { + return `Resets in ${hours}h ${minutes % 60}m`; + } + if (minutes > 0) { + return `Resets in ${minutes}m`; + } + return 'Resets soon'; +}