diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index acff315e..c27bac18 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -68,6 +68,7 @@ import { CodexAppServerService } from './services/codex-app-server-service.js'; import { CodexModelCacheService } from './services/codex-model-cache-service.js'; import { createZaiRoutes } from './routes/zai/index.js'; import { ZaiUsageService } from './services/zai-usage-service.js'; +import { createGeminiRoutes } from './routes/gemini/index.js'; import { createGitHubRoutes } from './routes/github/index.js'; import { createContextRoutes } from './routes/context/index.js'; import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js'; @@ -438,6 +439,7 @@ app.use('/api/settings', createSettingsRoutes(settingsService)); app.use('/api/claude', createClaudeRoutes(claudeUsageService)); app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService)); app.use('/api/zai', createZaiRoutes(zaiUsageService, settingsService)); +app.use('/api/gemini', createGeminiRoutes()); app.use('/api/github', createGitHubRoutes(events, settingsService)); app.use('/api/context', createContextRoutes(settingsService)); app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService)); diff --git a/apps/server/src/routes/gemini/index.ts b/apps/server/src/routes/gemini/index.ts new file mode 100644 index 00000000..c543d827 --- /dev/null +++ b/apps/server/src/routes/gemini/index.ts @@ -0,0 +1,60 @@ +import { Router, Request, Response } from 'express'; +import { GeminiProvider } from '../../providers/gemini-provider.js'; +import { getGeminiUsageService } from '../../services/gemini-usage-service.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('Gemini'); + +export function createGeminiRoutes(): Router { + const router = Router(); + + // Get current usage/quota data from Google Cloud API + router.get('/usage', async (_req: Request, res: Response) => { + try { + const usageService = getGeminiUsageService(); + const usageData = await usageService.fetchUsageData(); + + res.json(usageData); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error fetching Gemini usage:', error); + + // Return error in a format the UI expects + res.status(200).json({ + authenticated: false, + authMethod: 'none', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: `Failed to fetch Gemini usage: ${message}`, + }); + } + }); + + // Check if Gemini is available + router.get('/status', async (_req: Request, res: Response) => { + try { + const provider = new GeminiProvider(); + const status = await provider.detectInstallation(); + + const authMethod = + (status as any).authMethod || + (status.authenticated ? (status.hasApiKey ? 'api_key' : 'cli_login') : 'none'); + + res.json({ + success: true, + installed: status.installed, + version: status.version || null, + path: status.path || null, + authenticated: status.authenticated || false, + authMethod, + hasCredentialsFile: (status as any).hasCredentialsFile || false, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} 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..966d09a4 --- /dev/null +++ b/apps/server/src/services/gemini-usage-service.ts @@ -0,0 +1,761 @@ +/** + * Gemini Usage Service + * + * Service for tracking Gemini CLI usage and quota. + * Uses the internal Google Cloud quota API (same as CodexBar). + * See: https://github.com/steipete/CodexBar/blob/main/docs/gemini.md + * + * OAuth credentials are extracted from the Gemini CLI installation, + * not hardcoded, to ensure compatibility with CLI updates. + */ + +import { createLogger } from '@automaker/utils'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execSync } from 'child_process'; + +const logger = createLogger('GeminiUsage'); + +// Quota API endpoint (internal Google Cloud API) +const QUOTA_API_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota'; + +// Code Assist endpoint for getting project ID and tier info +const CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist'; + +// Google OAuth endpoints for token refresh +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; + +export interface GeminiQuotaBucket { + /** Model ID this quota applies to */ + modelId: string; + /** Remaining fraction (0-1) */ + remainingFraction: number; + /** ISO-8601 reset time */ + resetTime: string; +} + +/** Simplified quota info for a model tier (Flash or Pro) */ +export interface GeminiTierQuota { + /** Used percentage (0-100) */ + usedPercent: number; + /** Remaining percentage (0-100) */ + remainingPercent: number; + /** Reset time as human-readable string */ + resetText?: string; + /** ISO-8601 reset time */ + resetTime?: string; +} + +export interface GeminiUsageData { + /** Whether authenticated via CLI */ + authenticated: boolean; + /** Authentication method */ + authMethod: 'cli_login' | 'api_key' | 'none'; + /** Usage percentage (100 - remainingFraction * 100) - overall most constrained */ + usedPercent: number; + /** Remaining percentage - overall most constrained */ + remainingPercent: number; + /** Reset time as human-readable string */ + resetText?: string; + /** ISO-8601 reset time */ + resetTime?: string; + /** Model ID with lowest remaining quota */ + constrainedModel?: string; + /** Flash tier quota (aggregated from all flash models) */ + flashQuota?: GeminiTierQuota; + /** Pro tier quota (aggregated from all pro models) */ + proQuota?: GeminiTierQuota; + /** Raw quota buckets for detailed view */ + quotaBuckets?: GeminiQuotaBucket[]; + /** When this data was last fetched */ + lastUpdated: string; + /** Optional error message */ + error?: string; +} + +interface OAuthCredentials { + access_token?: string; + id_token?: string; + refresh_token?: string; + token_type?: string; + expiry_date?: number; + client_id?: string; + client_secret?: string; +} + +interface OAuthClientCredentials { + clientId: string; + clientSecret: string; +} + +interface QuotaResponse { + // The actual API returns 'buckets', not 'quotaBuckets' + buckets?: Array<{ + modelId?: string; + remainingFraction?: number; + resetTime?: string; + tokenType?: string; + }>; + // Legacy field name (in case API changes) + quotaBuckets?: Array<{ + modelId?: string; + remainingFraction?: number; + resetTime?: string; + tokenType?: string; + }>; +} + +/** + * Gemini Usage Service + * + * Provides real usage/quota data for Gemini CLI users. + * Extracts OAuth credentials from the Gemini CLI installation. + */ +export class GeminiUsageService { + private cachedCredentials: OAuthCredentials | null = null; + private cachedClientCredentials: OAuthClientCredentials | null = null; + private credentialsPath: string; + + constructor() { + // Default credentials path for Gemini CLI + this.credentialsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); + } + + /** + * Check if Gemini CLI is authenticated + */ + async isAvailable(): Promise { + const creds = await this.loadCredentials(); + return Boolean(creds?.access_token || creds?.refresh_token); + } + + /** + * Fetch quota/usage data from Google Cloud API + */ + async fetchUsageData(): Promise { + logger.info('[fetchUsageData] Starting...'); + + const creds = await this.loadCredentials(); + + if (!creds || (!creds.access_token && !creds.refresh_token)) { + logger.info('[fetchUsageData] No credentials found'); + return { + authenticated: false, + authMethod: 'none', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: 'Not authenticated. Run "gemini auth login" to authenticate.', + }; + } + + try { + // Get a valid access token (refresh if needed) + const accessToken = await this.getValidAccessToken(creds); + + if (!accessToken) { + return { + authenticated: false, + authMethod: 'none', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: 'Failed to obtain access token. Try running "gemini auth login" again.', + }; + } + + // First, get the project ID from loadCodeAssist endpoint + // This is required to get accurate quota data + let projectId: string | undefined; + try { + const codeAssistResponse = await fetch(CODE_ASSIST_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + + if (codeAssistResponse.ok) { + const codeAssistData = (await codeAssistResponse.json()) as { + cloudaicompanionProject?: string; + currentTier?: { id?: string; name?: string }; + }; + projectId = codeAssistData.cloudaicompanionProject; + logger.debug('[fetchUsageData] Got project ID:', projectId); + } + } catch (e) { + logger.debug('[fetchUsageData] Failed to get project ID:', e); + } + + // Fetch quota from Google Cloud API + // Pass project ID to get accurate quota (without it, returns default 100%) + const response = await fetch(QUOTA_API_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(projectId ? { project: projectId } : {}), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + logger.error('[fetchUsageData] Quota API error:', response.status, errorText); + + // Still authenticated, but quota API failed + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: `Quota API unavailable (${response.status})`, + }; + } + + const data = (await response.json()) as QuotaResponse; + + // API returns 'buckets', with fallback to 'quotaBuckets' for compatibility + const apiBuckets = data.buckets || data.quotaBuckets; + + logger.debug('[fetchUsageData] Raw buckets:', JSON.stringify(apiBuckets)); + + if (!apiBuckets || apiBuckets.length === 0) { + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + }; + } + + // Group buckets into Flash and Pro tiers + // Flash: any model with "flash" in the name + // Pro: any model with "pro" in the name + let flashLowestRemaining = 1.0; + let flashResetTime: string | undefined; + let hasFlashModels = false; + let proLowestRemaining = 1.0; + let proResetTime: string | undefined; + let hasProModels = false; + let overallLowestRemaining = 1.0; + let constrainedModel: string | undefined; + let overallResetTime: string | undefined; + + const quotaBuckets: GeminiQuotaBucket[] = apiBuckets.map((bucket) => { + const remaining = bucket.remainingFraction ?? 1.0; + const modelId = bucket.modelId?.toLowerCase() || ''; + + // Track overall lowest + if (remaining < overallLowestRemaining) { + overallLowestRemaining = remaining; + constrainedModel = bucket.modelId; + overallResetTime = bucket.resetTime; + } + + // Group into Flash or Pro tier + if (modelId.includes('flash')) { + hasFlashModels = true; + if (remaining < flashLowestRemaining) { + flashLowestRemaining = remaining; + flashResetTime = bucket.resetTime; + } + // Also track reset time even if at 100% + if (!flashResetTime && bucket.resetTime) { + flashResetTime = bucket.resetTime; + } + } else if (modelId.includes('pro')) { + hasProModels = true; + if (remaining < proLowestRemaining) { + proLowestRemaining = remaining; + proResetTime = bucket.resetTime; + } + // Also track reset time even if at 100% + if (!proResetTime && bucket.resetTime) { + proResetTime = bucket.resetTime; + } + } + + return { + modelId: bucket.modelId || 'unknown', + remainingFraction: remaining, + resetTime: bucket.resetTime || '', + }; + }); + + const usedPercent = Math.round((1 - overallLowestRemaining) * 100); + const remainingPercent = Math.round(overallLowestRemaining * 100); + + // Build tier quotas (only include if we found models for that tier) + const flashQuota: GeminiTierQuota | undefined = hasFlashModels + ? { + usedPercent: Math.round((1 - flashLowestRemaining) * 100), + remainingPercent: Math.round(flashLowestRemaining * 100), + resetText: flashResetTime ? this.formatResetTime(flashResetTime) : undefined, + resetTime: flashResetTime, + } + : undefined; + + const proQuota: GeminiTierQuota | undefined = hasProModels + ? { + usedPercent: Math.round((1 - proLowestRemaining) * 100), + remainingPercent: Math.round(proLowestRemaining * 100), + resetText: proResetTime ? this.formatResetTime(proResetTime) : undefined, + resetTime: proResetTime, + } + : undefined; + + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent, + remainingPercent, + resetText: overallResetTime ? this.formatResetTime(overallResetTime) : undefined, + resetTime: overallResetTime, + constrainedModel, + flashQuota, + proQuota, + quotaBuckets, + lastUpdated: new Date().toISOString(), + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[fetchUsageData] Error:', errorMsg); + + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: `Failed to fetch quota: ${errorMsg}`, + }; + } + } + + /** + * Load OAuth credentials from file + */ + private async loadCredentials(): Promise { + if (this.cachedCredentials) { + return this.cachedCredentials; + } + + // Check multiple possible paths + const possiblePaths = [ + this.credentialsPath, + path.join(os.homedir(), '.gemini', 'oauth_creds.json'), + path.join(os.homedir(), '.config', 'gemini', 'oauth_creds.json'), + ]; + + for (const credPath of possiblePaths) { + try { + if (fs.existsSync(credPath)) { + const content = fs.readFileSync(credPath, 'utf8'); + const creds = JSON.parse(content); + + // Handle different credential formats + if (creds.access_token || creds.refresh_token) { + this.cachedCredentials = creds; + logger.info('[loadCredentials] Loaded from:', credPath); + return creds; + } + + // Some formats nest credentials under 'web' or 'installed' + if (creds.web?.client_id || creds.installed?.client_id) { + const clientCreds = creds.web || creds.installed; + this.cachedCredentials = { + client_id: clientCreds.client_id, + client_secret: clientCreds.client_secret, + }; + return this.cachedCredentials; + } + } + } catch (error) { + logger.debug('[loadCredentials] Failed to load from', credPath, error); + } + } + + return null; + } + + /** + * Find the Gemini CLI binary path + */ + private findGeminiBinaryPath(): string | null { + try { + // Try 'which' on Unix-like systems + const whichResult = execSync('which gemini 2>/dev/null', { encoding: 'utf8' }).trim(); + if (whichResult && fs.existsSync(whichResult)) { + return whichResult; + } + } catch { + // Ignore errors from 'which' + } + + // Check common installation paths + const possiblePaths = [ + // npm global installs + path.join(os.homedir(), '.npm-global', 'bin', 'gemini'), + '/usr/local/bin/gemini', + '/usr/bin/gemini', + // Homebrew + '/opt/homebrew/bin/gemini', + '/usr/local/opt/gemini/bin/gemini', + // nvm/fnm node installs + path.join(os.homedir(), '.nvm', 'versions', 'node'), + path.join(os.homedir(), '.fnm', 'node-versions'), + // Windows + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini.cmd'), + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini'), + ]; + + for (const p of possiblePaths) { + if (fs.existsSync(p)) { + return p; + } + } + + return null; + } + + /** + * Extract OAuth client credentials from Gemini CLI installation + * This mimics CodexBar's approach of finding oauth2.js in the CLI + */ + private extractOAuthClientCredentials(): OAuthClientCredentials | null { + if (this.cachedClientCredentials) { + return this.cachedClientCredentials; + } + + const geminiBinary = this.findGeminiBinaryPath(); + if (!geminiBinary) { + logger.debug('[extractOAuthClientCredentials] Gemini binary not found'); + return null; + } + + // Resolve symlinks to find actual location + let resolvedPath = geminiBinary; + try { + resolvedPath = fs.realpathSync(geminiBinary); + } catch { + // Use original path if realpath fails + } + + const baseDir = path.dirname(resolvedPath); + logger.debug('[extractOAuthClientCredentials] Base dir:', baseDir); + + // Possible locations for oauth2.js relative to the binary + // Based on CodexBar's search patterns + const possibleOAuth2Paths = [ + // npm global install structure + path.join( + baseDir, + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + path.join( + baseDir, + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + // Homebrew/libexec structure + path.join( + baseDir, + '..', + 'libexec', + 'lib', + 'node_modules', + '@google', + 'gemini-cli', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + path.join( + baseDir, + '..', + 'libexec', + 'lib', + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + // Direct sibling + path.join(baseDir, '..', 'gemini-cli-core', 'dist', 'src', 'code_assist', 'oauth2.js'), + path.join(baseDir, '..', 'gemini-cli', 'dist', 'src', 'code_assist', 'oauth2.js'), + // Alternative node_modules structures + path.join( + baseDir, + '..', + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + path.join( + baseDir, + '..', + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + ]; + + for (const oauth2Path of possibleOAuth2Paths) { + try { + const normalizedPath = path.normalize(oauth2Path); + if (fs.existsSync(normalizedPath)) { + logger.debug('[extractOAuthClientCredentials] Found oauth2.js at:', normalizedPath); + const content = fs.readFileSync(normalizedPath, 'utf8'); + const creds = this.parseOAuthCredentialsFromSource(content); + if (creds) { + this.cachedClientCredentials = creds; + logger.info('[extractOAuthClientCredentials] Extracted credentials from CLI'); + return creds; + } + } + } catch (error) { + logger.debug('[extractOAuthClientCredentials] Failed to read', oauth2Path, error); + } + } + + // Try finding oauth2.js by searching in node_modules + try { + const searchResult = execSync( + `find ${baseDir}/.. -name "oauth2.js" -path "*gemini*" -path "*code_assist*" 2>/dev/null | head -1`, + { encoding: 'utf8', timeout: 5000 } + ).trim(); + + if (searchResult && fs.existsSync(searchResult)) { + logger.debug('[extractOAuthClientCredentials] Found via search:', searchResult); + const content = fs.readFileSync(searchResult, 'utf8'); + const creds = this.parseOAuthCredentialsFromSource(content); + if (creds) { + this.cachedClientCredentials = creds; + logger.info( + '[extractOAuthClientCredentials] Extracted credentials from CLI (via search)' + ); + return creds; + } + } + } catch { + // Ignore search errors + } + + logger.warn('[extractOAuthClientCredentials] Could not extract credentials from CLI'); + return null; + } + + /** + * Parse OAuth client credentials from oauth2.js source code + */ + private parseOAuthCredentialsFromSource(content: string): OAuthClientCredentials | null { + // Patterns based on CodexBar's regex extraction + // Look for: OAUTH_CLIENT_ID = "..." or const clientId = "..." + const clientIdPatterns = [ + /OAUTH_CLIENT_ID\s*=\s*["']([^"']+)["']/, + /clientId\s*[:=]\s*["']([^"']+)["']/, + /client_id\s*[:=]\s*["']([^"']+)["']/, + /"clientId"\s*:\s*["']([^"']+)["']/, + ]; + + const clientSecretPatterns = [ + /OAUTH_CLIENT_SECRET\s*=\s*["']([^"']+)["']/, + /clientSecret\s*[:=]\s*["']([^"']+)["']/, + /client_secret\s*[:=]\s*["']([^"']+)["']/, + /"clientSecret"\s*:\s*["']([^"']+)["']/, + ]; + + let clientId: string | null = null; + let clientSecret: string | null = null; + + for (const pattern of clientIdPatterns) { + const match = content.match(pattern); + if (match && match[1]) { + clientId = match[1]; + break; + } + } + + for (const pattern of clientSecretPatterns) { + const match = content.match(pattern); + if (match && match[1]) { + clientSecret = match[1]; + break; + } + } + + if (clientId && clientSecret) { + logger.debug('[parseOAuthCredentialsFromSource] Found client credentials'); + return { clientId, clientSecret }; + } + + return null; + } + + /** + * Get a valid access token, refreshing if necessary + */ + private async getValidAccessToken(creds: OAuthCredentials): Promise { + // Check if current token is still valid (with 5 min buffer) + if (creds.access_token && creds.expiry_date) { + const now = Date.now(); + if (creds.expiry_date > now + 5 * 60 * 1000) { + logger.debug('[getValidAccessToken] Using existing token (not expired)'); + return creds.access_token; + } + } + + // If we have a refresh token, try to refresh + if (creds.refresh_token) { + // Try to extract credentials from CLI first + const extractedCreds = this.extractOAuthClientCredentials(); + + // Use extracted credentials, then fall back to credentials in file + const clientId = extractedCreds?.clientId || creds.client_id; + const clientSecret = extractedCreds?.clientSecret || creds.client_secret; + + if (!clientId || !clientSecret) { + logger.error('[getValidAccessToken] No client credentials available for token refresh'); + // Return existing token even if expired - it might still work + return creds.access_token || null; + } + + try { + logger.debug('[getValidAccessToken] Refreshing token...'); + const response = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: creds.refresh_token, + grant_type: 'refresh_token', + }), + }); + + if (response.ok) { + const data = (await response.json()) as { access_token?: string; expires_in?: number }; + const newAccessToken = data.access_token; + const expiresIn = data.expires_in || 3600; + + if (newAccessToken) { + logger.info('[getValidAccessToken] Token refreshed successfully'); + + // Update cached credentials + this.cachedCredentials = { + ...creds, + access_token: newAccessToken, + expiry_date: Date.now() + expiresIn * 1000, + }; + + // Save back to file + try { + fs.writeFileSync( + this.credentialsPath, + JSON.stringify(this.cachedCredentials, null, 2) + ); + } catch (e) { + logger.debug('[getValidAccessToken] Could not save refreshed token:', e); + } + + return newAccessToken; + } + } else { + const errorText = await response.text().catch(() => ''); + logger.error('[getValidAccessToken] Token refresh failed:', response.status, errorText); + } + } catch (error) { + logger.error('[getValidAccessToken] Token refresh error:', error); + } + } + + // Return current access token even if it might be expired + return creds.access_token || null; + } + + /** + * Format reset time as human-readable string + */ + private formatResetTime(isoTime: string): string { + try { + const resetDate = new Date(isoTime); + const now = new Date(); + const diff = resetDate.getTime() - now.getTime(); + + if (diff < 0) { + return 'Resetting soon'; + } + + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + const remainingMins = minutes % 60; + return remainingMins > 0 ? `Resets in ${hours}h ${remainingMins}m` : `Resets in ${hours}h`; + } + + return `Resets in ${minutes}m`; + } catch { + return ''; + } + } + + /** + * Clear cached credentials (useful after logout) + */ + clearCache(): void { + this.cachedCredentials = null; + this.cachedClientCredentials = null; + } +} + +// Singleton instance +let usageServiceInstance: GeminiUsageService | null = null; + +/** + * Get the singleton instance of GeminiUsageService + */ +export function getGeminiUsageService(): GeminiUsageService { + if (!usageServiceInstance) { + usageServiceInstance = new GeminiUsageService(); + } + return usageServiceInstance; +} diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx index 31bb6d5a..58c6fd27 100644 --- a/apps/ui/src/components/usage-popover.tsx +++ b/apps/ui/src/components/usage-popover.tsx @@ -6,8 +6,8 @@ import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } f import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { useSetupStore } from '@/store/setup-store'; -import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon'; -import { useClaudeUsage, useCodexUsage, useZaiUsage } from '@/hooks/queries'; +import { AnthropicIcon, OpenAIIcon, ZaiIcon, GeminiIcon } from '@/components/ui/provider-icon'; +import { useClaudeUsage, useCodexUsage, useZaiUsage, useGeminiUsage } from '@/hooks/queries'; // Error codes for distinguishing failure modes const ERROR_CODES = { @@ -81,14 +81,16 @@ export function UsagePopover() { const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus); + const geminiAuthStatus = useSetupStore((state) => state.geminiAuthStatus); const [open, setOpen] = useState(false); - const [activeTab, setActiveTab] = useState<'claude' | 'codex' | 'zai'>('claude'); + const [activeTab, setActiveTab] = useState<'claude' | 'codex' | 'zai' | 'gemini'>('claude'); // Check authentication status const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated; const isCodexAuthenticated = codexAuthStatus?.authenticated; const isZaiAuthenticated = zaiAuthStatus?.authenticated; + const isGeminiAuthenticated = geminiAuthStatus?.authenticated; // Use React Query hooks for usage data // Only enable polling when popover is open AND the tab is active @@ -116,6 +118,14 @@ export function UsagePopover() { refetch: refetchZai, } = useZaiUsage(open && activeTab === 'zai' && isZaiAuthenticated); + const { + data: geminiUsage, + isLoading: geminiLoading, + error: geminiQueryError, + dataUpdatedAt: geminiUsageLastUpdated, + refetch: refetchGemini, + } = useGeminiUsage(open && activeTab === 'gemini' && isGeminiAuthenticated); + // Parse errors into structured format const claudeError = useMemo((): UsageError | null => { if (!claudeQueryError) return null; @@ -157,6 +167,19 @@ export function UsagePopover() { return { code: ERROR_CODES.AUTH_ERROR, message }; }, [zaiQueryError]); + const geminiError = useMemo((): UsageError | null => { + if (!geminiQueryError) return null; + const message = + geminiQueryError instanceof Error ? geminiQueryError.message : String(geminiQueryError); + if (message.includes('not configured') || message.includes('not authenticated')) { + return { code: ERROR_CODES.NOT_AVAILABLE, message }; + } + if (message.includes('API bridge')) { + return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message }; + } + return { code: ERROR_CODES.AUTH_ERROR, message }; + }, [geminiQueryError]); + // Determine which tab to show by default useEffect(() => { if (isClaudeAuthenticated) { @@ -165,8 +188,10 @@ export function UsagePopover() { setActiveTab('codex'); } else if (isZaiAuthenticated) { setActiveTab('zai'); + } else if (isGeminiAuthenticated) { + setActiveTab('gemini'); } - }, [isClaudeAuthenticated, isCodexAuthenticated, isZaiAuthenticated]); + }, [isClaudeAuthenticated, isCodexAuthenticated, isZaiAuthenticated, isGeminiAuthenticated]); // Check if data is stale (older than 2 minutes) const isClaudeStale = useMemo(() => { @@ -181,10 +206,15 @@ export function UsagePopover() { return !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000; }, [zaiUsageLastUpdated]); + const isGeminiStale = useMemo(() => { + return !geminiUsageLastUpdated || Date.now() - geminiUsageLastUpdated > 2 * 60 * 1000; + }, [geminiUsageLastUpdated]); + // Refetch functions for manual refresh const fetchClaudeUsage = () => refetchClaude(); const fetchCodexUsage = () => refetchCodex(); const fetchZaiUsage = () => refetchZai(); + const fetchGeminiUsage = () => refetchGemini(); // Derived status color/icon helper const getStatusInfo = (percentage: number) => { @@ -275,6 +305,23 @@ export function UsagePopover() { // Calculate max percentage for header button const claudeSessionPercentage = claudeUsage?.sessionPercentage || 0; + const codexMaxPercentage = codexUsage?.rateLimits + ? Math.max( + codexUsage.rateLimits.primary?.usedPercent || 0, + codexUsage.rateLimits.secondary?.usedPercent || 0 + ) + : 0; + + const zaiMaxPercentage = zaiUsage?.quotaLimits + ? Math.max( + zaiUsage.quotaLimits.tokens?.usedPercent || 0, + zaiUsage.quotaLimits.mcp?.usedPercent || 0 + ) + : 0; + + // Gemini quota from Google Cloud API (if available) + const geminiMaxPercentage = geminiUsage?.usedPercent ?? (geminiUsage?.authenticated ? 0 : 100); + const getProgressBarColor = (percentage: number) => { if (percentage >= 80) return 'bg-red-500'; if (percentage >= 50) return 'bg-yellow-500'; @@ -299,33 +346,43 @@ export function UsagePopover() { const indicatorInfo = activeTab === 'claude' ? { - icon: AnthropicIcon, - percentage: claudeSessionPercentage, - isStale: isClaudeStale, - title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, - } - : activeTab === 'codex' ? { - icon: OpenAIIcon, - percentage: codexWindowUsage ?? 0, - isStale: isCodexStale, - title: `Usage (${codexWindowLabel})`, - } : activeTab === 'zai' ? { - icon: ZaiIcon, - percentage: zaiMaxPercentage, - isStale: isZaiStale, - title: `Usage (z.ai)`, - } : null; + icon: AnthropicIcon, + percentage: claudeSessionPercentage, + isStale: isClaudeStale, + title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, + } + : activeTab === 'codex' + ? { + icon: OpenAIIcon, + percentage: codexWindowUsage ?? 0, + isStale: isCodexStale, + } + : activeTab === 'zai' + ? { + icon: ZaiIcon, + percentage: zaiMaxPercentage, + isStale: isZaiStale, + title: `Usage (z.ai)`, + } + : activeTab === 'gemini' + ? { + icon: GeminiIcon, + percentage: geminiMaxPercentage, + isStale: isGeminiStale, + title: `Usage (Gemini)`, + } + : null; const statusColor = getStatusInfo(indicatorInfo.percentage).color; const ProviderIcon = indicatorInfo.icon; const trigger = ( + )} + + + {/* Content */} +
+ {geminiError ? ( +
+ +
+

+ {geminiError.code === ERROR_CODES.NOT_AVAILABLE + ? 'Gemini not configured' + : geminiError.message} +

+

+ {geminiError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? ( + 'Ensure the Electron bridge is running or restart the app' + ) : geminiError.code === ERROR_CODES.NOT_AVAILABLE ? ( + <> + Run{' '} + gemini auth login{' '} + to authenticate with your Google account + + ) : ( + <>Check your Gemini CLI configuration + )} +

+
+
+ ) : !geminiUsage ? ( +
+ +

Loading usage data...

+
+ ) : geminiUsage.authenticated ? ( + <> + {/* Show Flash and Pro quota tiers */} + {geminiUsage.flashQuota || geminiUsage.proQuota ? ( +
+ {geminiUsage.flashQuota && ( + + )} + {geminiUsage.proQuota && ( + + )} +
+ ) : ( + <> + {/* No quota data available - show connected status */} +
+
+ +
+
+

Connected

+

+ Authenticated via{' '} + + {geminiUsage.authMethod === 'cli_login' + ? 'CLI Login' + : geminiUsage.authMethod === 'api_key_env' + ? 'API Key (Environment)' + : geminiUsage.authMethod === 'api_key' + ? 'API Key' + : 'Unknown'} + +

+
+
+ +
+

+ {geminiUsage.error ? ( + <>Quota API: {geminiUsage.error} + ) : ( + <>No usage yet or quota data unavailable + )} +

+
+ + )} + + ) : ( +
+ +

Not authenticated

+

+ Run gemini auth login{' '} + to authenticate +

+
+ )} +
+ + {/* Footer */} +
+ + Google AI + + 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 05303b85..8e3654e3 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -82,6 +82,7 @@ export function BoardHeader({ ); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus); + const geminiAuthStatus = useSetupStore((state) => state.geminiAuthStatus); // Worktree panel visibility (per-project) const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject); @@ -116,6 +117,9 @@ export function BoardHeader({ // z.ai usage tracking visibility logic const showZaiUsage = !!zaiAuthStatus?.authenticated; + // Gemini usage tracking visibility logic + const showGeminiUsage = !!geminiAuthStatus?.authenticated; + // State for mobile actions panel const [showActionsPanel, setShowActionsPanel] = useState(false); const [isRefreshingBoard, setIsRefreshingBoard] = useState(false); @@ -163,9 +167,11 @@ export function BoardHeader({ )} {/* Usage Popover - show if any provider is authenticated, only on desktop */} - {isMounted && !isTablet && (showClaudeUsage || showCodexUsage || showZaiUsage) && ( - - )} + {isMounted && + !isTablet && + (showClaudeUsage || showCodexUsage || showZaiUsage || showGeminiUsage) && ( + + )} {/* Tablet/Mobile view: show hamburger menu with all controls */} {isMounted && isTablet && ( @@ -185,6 +191,7 @@ export function BoardHeader({ showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} showZaiUsage={showZaiUsage} + showGeminiUsage={showGeminiUsage} /> )} diff --git a/apps/ui/src/components/views/board-view/header-mobile-menu.tsx b/apps/ui/src/components/views/board-view/header-mobile-menu.tsx index 184e436a..3eed7c0e 100644 --- a/apps/ui/src/components/views/board-view/header-mobile-menu.tsx +++ b/apps/ui/src/components/views/board-view/header-mobile-menu.tsx @@ -31,6 +31,7 @@ interface HeaderMobileMenuProps { showClaudeUsage: boolean; showCodexUsage: boolean; showZaiUsage?: boolean; + showGeminiUsage?: boolean; } export function HeaderMobileMenu({ @@ -49,13 +50,14 @@ export function HeaderMobileMenu({ showClaudeUsage, showCodexUsage, showZaiUsage = false, + showGeminiUsage = false, }: HeaderMobileMenuProps) { return ( <> {/* Usage Bar - show if any provider is authenticated */} - {(showClaudeUsage || showCodexUsage || showZaiUsage) && ( + {(showClaudeUsage || showCodexUsage || showZaiUsage || showGeminiUsage) && (
Usage @@ -64,6 +66,7 @@ export function HeaderMobileMenu({ showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} showZaiUsage={showZaiUsage} + showGeminiUsage={showGeminiUsage} />
)} diff --git a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx index 28225b50..4755dfbb 100644 --- a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx +++ b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx @@ -4,12 +4,14 @@ import { cn } from '@/lib/utils'; import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; -import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon'; +import { AnthropicIcon, OpenAIIcon, ZaiIcon, GeminiIcon } from '@/components/ui/provider-icon'; +import type { GeminiUsage } from '@/store/app-store'; interface MobileUsageBarProps { showClaudeUsage: boolean; showCodexUsage: boolean; showZaiUsage?: boolean; + showGeminiUsage?: boolean; } // Helper to get progress bar color based on percentage @@ -152,6 +154,7 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage, showZaiUsage = false, + showGeminiUsage = false, }: MobileUsageBarProps) { const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore(); const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore(); @@ -159,12 +162,17 @@ export function MobileUsageBar({ const [isClaudeLoading, setIsClaudeLoading] = useState(false); const [isCodexLoading, setIsCodexLoading] = useState(false); const [isZaiLoading, setIsZaiLoading] = useState(false); + const [isGeminiLoading, setIsGeminiLoading] = useState(false); + const [geminiUsage, setGeminiUsage] = useState(null); + const [geminiUsageLastUpdated, setGeminiUsageLastUpdated] = useState(null); // Check if data is stale (older than 2 minutes) const isClaudeStale = !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000; const isCodexStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000; const isZaiStale = !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000; + const isGeminiStale = + !geminiUsageLastUpdated || Date.now() - geminiUsageLastUpdated > 2 * 60 * 1000; const fetchClaudeUsage = useCallback(async () => { setIsClaudeLoading(true); @@ -214,6 +222,23 @@ export function MobileUsageBar({ } }, [setZaiUsage]); + const fetchGeminiUsage = useCallback(async () => { + setIsGeminiLoading(true); + try { + const api = getElectronAPI(); + if (!api.gemini) return; + const data = await api.gemini.getUsage(); + if (!('error' in data)) { + setGeminiUsage(data); + setGeminiUsageLastUpdated(Date.now()); + } + } catch { + // Silently fail - usage display is optional + } finally { + setIsGeminiLoading(false); + } + }, []); + const getCodexWindowLabel = (durationMins: number) => { if (durationMins < 60) return `${durationMins}m Window`; if (durationMins < 1440) return `${Math.round(durationMins / 60)}h Window`; @@ -239,8 +264,14 @@ export function MobileUsageBar({ } }, [showZaiUsage, isZaiStale, fetchZaiUsage]); + useEffect(() => { + if (showGeminiUsage && isGeminiStale) { + fetchGeminiUsage(); + } + }, [showGeminiUsage, isGeminiStale, fetchGeminiUsage]); + // Don't render if there's nothing to show - if (!showClaudeUsage && !showCodexUsage && !showZaiUsage) { + if (!showClaudeUsage && !showCodexUsage && !showZaiUsage && !showGeminiUsage) { return null; } @@ -340,6 +371,58 @@ export function MobileUsageBar({ )} )} + + {showGeminiUsage && ( + + {geminiUsage ? ( + geminiUsage.authenticated ? ( + geminiUsage.flashQuota || geminiUsage.proQuota ? ( + <> + {geminiUsage.flashQuota && ( + + )} + {geminiUsage.proQuota && ( + + )} + + ) : ( +
+

+ Connected via{' '} + {geminiUsage.authMethod === 'cli_login' + ? 'CLI Login' + : geminiUsage.authMethod === 'api_key' + ? 'API Key' + : geminiUsage.authMethod} +

+

+ {geminiUsage.error || 'No usage yet'} +

+
+ ) + ) : ( +

Not authenticated

+ ) + ) : ( +

Loading usage data...

+ )} +
+ )} ); } diff --git a/apps/ui/src/hooks/queries/index.ts b/apps/ui/src/hooks/queries/index.ts index 186b5b4e..5a5730ac 100644 --- a/apps/ui/src/hooks/queries/index.ts +++ b/apps/ui/src/hooks/queries/index.ts @@ -23,7 +23,7 @@ export { } from './use-github'; // Usage -export { useClaudeUsage, useCodexUsage, useZaiUsage } from './use-usage'; +export { useClaudeUsage, useCodexUsage, useZaiUsage, useGeminiUsage } 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 c159ac06..18fedfa7 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, Codex, and z.ai API usage data. + * React Query hooks for fetching Claude, Codex, z.ai, and Gemini API usage data. * These hooks include automatic polling for real-time usage updates. */ @@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query'; import { getElectronAPI } from '@/lib/electron'; import { queryKeys } from '@/lib/query-keys'; import { STALE_TIMES } from '@/lib/query-client'; -import type { ClaudeUsage, CodexUsage, ZaiUsage } from '@/store/app-store'; +import type { ClaudeUsage, CodexUsage, ZaiUsage, GeminiUsage } from '@/store/app-store'; /** Polling interval for usage data (60 seconds) */ const USAGE_POLLING_INTERVAL = 60 * 1000; @@ -33,7 +33,7 @@ export function useClaudeUsage(enabled = true) { queryFn: async (): Promise => { const api = getElectronAPI(); if (!api.claude) { - throw new Error('Claude API not available'); + throw new Error('Claude API bridge unavailable'); } const result = await api.claude.getUsage(); // Check if result is an error response @@ -69,7 +69,7 @@ export function useCodexUsage(enabled = true) { queryFn: async (): Promise => { const api = getElectronAPI(); if (!api.codex) { - throw new Error('Codex API not available'); + throw new Error('Codex API bridge unavailable'); } const result = await api.codex.getUsage(); // Check if result is an error response @@ -104,6 +104,9 @@ export function useZaiUsage(enabled = true) { queryKey: queryKeys.usage.zai(), queryFn: async (): Promise => { const api = getElectronAPI(); + if (!api.zai) { + throw new Error('z.ai API bridge unavailable'); + } const result = await api.zai.getUsage(); // Check if result is an error response if ('error' in result) { @@ -120,3 +123,37 @@ export function useZaiUsage(enabled = true) { refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, }); } + +/** + * Fetch Gemini API usage/status data + * + * @param enabled - Whether the query should run (default: true) + * @returns Query result with Gemini usage data + * + * @example + * ```tsx + * const { data: usage, isLoading } = useGeminiUsage(isPopoverOpen); + * ``` + */ +export function useGeminiUsage(enabled = true) { + return useQuery({ + queryKey: queryKeys.usage.gemini(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + if (!api.gemini) { + throw new Error('Gemini API bridge unavailable'); + } + const result = await api.gemini.getUsage(); + // Server always returns a response with 'authenticated' field, even on error + // So we can safely cast to GeminiUsage + return result as GeminiUsage; + }, + enabled, + staleTime: STALE_TIMES.USAGE, + refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false, + // Keep previous data while refetching + placeholderData: (previousData) => previousData, + refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS, + refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, + }); +} diff --git a/apps/ui/src/hooks/use-provider-auth-init.ts b/apps/ui/src/hooks/use-provider-auth-init.ts index c784e7bd..f8919b1e 100644 --- a/apps/ui/src/hooks/use-provider-auth-init.ts +++ b/apps/ui/src/hooks/use-provider-auth-init.ts @@ -4,6 +4,7 @@ import { type ClaudeAuthMethod, type CodexAuthMethod, type ZaiAuthMethod, + type GeminiAuthMethod, } from '@/store/setup-store'; import { getHttpApiClient } from '@/lib/http-api-client'; import { createLogger } from '@automaker/utils/logger'; @@ -11,7 +12,7 @@ import { createLogger } from '@automaker/utils/logger'; const logger = createLogger('ProviderAuthInit'); /** - * Hook to initialize Claude, Codex, and z.ai authentication statuses on app startup. + * Hook to initialize Claude, Codex, z.ai, and Gemini authentication statuses on app startup. * This ensures that usage tracking information is available in the board header * without needing to visit the settings page first. */ @@ -20,9 +21,12 @@ export function useProviderAuthInit() { setClaudeAuthStatus, setCodexAuthStatus, setZaiAuthStatus, + setGeminiCliStatus, + setGeminiAuthStatus, claudeAuthStatus, codexAuthStatus, zaiAuthStatus, + geminiAuthStatus, } = useSetupStore(); const initialized = useRef(false); @@ -121,18 +125,74 @@ export function useProviderAuthInit() { } catch (error) { logger.error('Failed to init z.ai auth status:', error); } - }, [setClaudeAuthStatus, setCodexAuthStatus, setZaiAuthStatus]); + + // 4. Gemini Auth Status + try { + const result = await api.setup.getGeminiStatus(); + if (result.success) { + // Set CLI status + setGeminiCliStatus({ + installed: result.installed ?? false, + version: result.version, + path: result.status, + }); + + // Set Auth status - always set a status to mark initialization as complete + if (result.auth) { + const auth = result.auth; + const validMethods: GeminiAuthMethod[] = ['cli_login', 'api_key_env', 'api_key', 'none']; + + const method = validMethods.includes(auth.method as GeminiAuthMethod) + ? (auth.method as GeminiAuthMethod) + : ((auth.authenticated ? 'cli_login' : 'none') as GeminiAuthMethod); + + setGeminiAuthStatus({ + authenticated: auth.authenticated, + method, + hasApiKey: auth.hasApiKey ?? false, + hasEnvApiKey: auth.hasEnvApiKey ?? false, + }); + } else { + // No auth info available, set default unauthenticated status + setGeminiAuthStatus({ + authenticated: false, + method: 'none', + hasApiKey: false, + hasEnvApiKey: false, + }); + } + } + } catch (error) { + logger.error('Failed to init Gemini auth status:', error); + // Set default status on error to prevent infinite retries + setGeminiAuthStatus({ + authenticated: false, + method: 'none', + hasApiKey: false, + hasEnvApiKey: false, + }); + } + }, [ + setClaudeAuthStatus, + setCodexAuthStatus, + setZaiAuthStatus, + setGeminiCliStatus, + setGeminiAuthStatus, + ]); useEffect(() => { // Only initialize once per session if not already set if ( initialized.current || - (claudeAuthStatus !== null && codexAuthStatus !== null && zaiAuthStatus !== null) + (claudeAuthStatus !== null && + codexAuthStatus !== null && + zaiAuthStatus !== null && + geminiAuthStatus !== null) ) { return; } initialized.current = true; void refreshStatuses(); - }, [refreshStatuses, claudeAuthStatus, codexAuthStatus, zaiAuthStatus]); + }, [refreshStatuses, claudeAuthStatus, codexAuthStatus, zaiAuthStatus, geminiAuthStatus]); } diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 14568453..54ea0c45 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1,6 +1,11 @@ // Type definitions for Electron IPC API import type { SessionListItem, Message } from '@/types/electron'; -import type { ClaudeUsageResponse, CodexUsageResponse, ZaiUsageResponse } from '@/store/app-store'; +import type { + ClaudeUsageResponse, + CodexUsageResponse, + ZaiUsageResponse, + GeminiUsageResponse, +} from '@/store/app-store'; import type { IssueValidationVerdict, IssueValidationConfidence, @@ -874,6 +879,9 @@ export interface ElectronAPI { error?: string; }>; }; + gemini?: { + getUsage: () => Promise; + }; settings?: { getStatus: () => Promise<{ success: boolean; @@ -1418,6 +1426,20 @@ const _getMockElectronAPI = (): ElectronAPI => { }; }, }, + + // Mock Gemini API + gemini: { + getUsage: async () => { + console.log('[Mock] Getting Gemini usage'); + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + }; + }, + }, }; }; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index b65ab872..37b6b416 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -41,7 +41,11 @@ import type { Notification, } from '@automaker/types'; import type { Message, SessionListItem } from '@/types/electron'; -import type { ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store'; +import type { + ClaudeUsageResponse, + CodexUsageResponse, + GeminiUsage, +} from '@/store/app-store'; import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron'; import type { ModelId, ThinkingLevel, ReasoningEffort, Feature } from '@automaker/types'; import { getGlobalFileBrowser } from '@/contexts/file-browser-context'; @@ -2688,6 +2692,11 @@ export class HttpApiClient implements ElectronAPI { }, }; + // Gemini API + gemini = { + getUsage: (): Promise => this.get('/api/gemini/usage'), + }; + // Context API context = { describeImage: ( diff --git a/apps/ui/src/lib/query-keys.ts b/apps/ui/src/lib/query-keys.ts index aad0208d..70c2679a 100644 --- a/apps/ui/src/lib/query-keys.ts +++ b/apps/ui/src/lib/query-keys.ts @@ -101,6 +101,8 @@ export const queryKeys = { codex: () => ['usage', 'codex'] as const, /** z.ai API usage */ zai: () => ['usage', 'zai'] as const, + /** Gemini API usage */ + gemini: () => ['usage', 'gemini'] as const, }, // ============================================ diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 4d4868b6..cc5fd64b 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -98,6 +98,10 @@ import { type ZaiQuotaLimit, type ZaiUsage, type ZaiUsageResponse, + type GeminiQuotaBucket, + type GeminiTierQuota, + type GeminiUsage, + type GeminiUsageResponse, } from './types'; // Import utility functions from modular utils files @@ -181,6 +185,10 @@ export type { ZaiQuotaLimit, ZaiUsage, ZaiUsageResponse, + GeminiQuotaBucket, + GeminiTierQuota, + GeminiUsage, + GeminiUsageResponse, }; // Re-export values from ./types for backward compatibility @@ -210,7 +218,7 @@ export { defaultBackgroundSettings, defaultTerminalState, MAX_INIT_OUTPUT_LINES // - Terminal types (./types/terminal-types.ts) // - ClaudeModel, Feature, FileTreeNode, ProjectAnalysis (./types/project-types.ts) // - InitScriptState, AutoModeActivity, AppState, AppActions (./types/state-types.ts) -// - Claude/Codex usage types (./types/usage-types.ts) +// - Claude/Codex/Zai/Gemini usage types (./types/usage-types.ts) // The following utility functions have been moved to ./utils/: // - Theme utilities: THEME_STORAGE_KEY, getStoredTheme, getStoredFontSans, getStoredFontMono, etc. (./utils/theme-utils.ts) // - Shortcut utilities: parseShortcut, formatShortcut, DEFAULT_KEYBOARD_SHORTCUTS (./utils/shortcut-utils.ts) @@ -220,6 +228,9 @@ export { defaultBackgroundSettings, defaultTerminalState, MAX_INIT_OUTPUT_LINES // - defaultBackgroundSettings (./defaults/background-settings.ts) // - defaultTerminalState (./defaults/terminal-defaults.ts) +// Type definitions are imported from ./types/state-types.ts +// AppActions interface is defined in ./types/state-types.ts + const initialState: AppState = { projects: [], currentProject: null, diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index 27a9bdac..aae357ea 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -127,6 +127,22 @@ export interface ZaiAuthStatus { error?: string; } +// Gemini Auth Method +export type GeminiAuthMethod = + | 'cli_login' // Gemini CLI is installed and authenticated + | 'api_key_env' // GOOGLE_API_KEY or GEMINI_API_KEY environment variable + | 'api_key' // Manually stored API key + | 'none'; + +// Gemini Auth Status +export interface GeminiAuthStatus { + authenticated: boolean; + method: GeminiAuthMethod; + hasApiKey?: boolean; + hasEnvApiKey?: boolean; + error?: string; +} + // Claude Auth Method - all possible authentication sources export type ClaudeAuthMethod = | 'oauth_token_env' @@ -200,6 +216,7 @@ export interface SetupState { // Gemini CLI state geminiCliStatus: GeminiCliStatus | null; + geminiAuthStatus: GeminiAuthStatus | null; // Copilot SDK state copilotCliStatus: CopilotCliStatus | null; @@ -243,6 +260,7 @@ export interface SetupActions { // Gemini CLI setGeminiCliStatus: (status: GeminiCliStatus | null) => void; + setGeminiAuthStatus: (status: GeminiAuthStatus | null) => void; // Copilot SDK setCopilotCliStatus: (status: CopilotCliStatus | null) => void; @@ -284,6 +302,7 @@ const initialState: SetupState = { opencodeCliStatus: null, geminiCliStatus: null, + geminiAuthStatus: null, copilotCliStatus: null, @@ -363,6 +382,7 @@ export const useSetupStore = create()((set, get) => ( // Gemini CLI setGeminiCliStatus: (status) => set({ geminiCliStatus: status }), + setGeminiAuthStatus: (status) => set({ geminiAuthStatus: status }), // Copilot SDK setCopilotCliStatus: (status) => set({ copilotCliStatus: status }), diff --git a/apps/ui/src/store/types/usage-types.ts b/apps/ui/src/store/types/usage-types.ts index e7c47a5d..0b6536f3 100644 --- a/apps/ui/src/store/types/usage-types.ts +++ b/apps/ui/src/store/types/usage-types.ts @@ -82,3 +82,55 @@ export interface ZaiUsage { // Response type for z.ai usage API (can be success or error) export type ZaiUsageResponse = ZaiUsage | { error: string; message?: string }; + +// Gemini Usage types - uses internal Google Cloud quota API +export interface GeminiQuotaBucket { + /** Model ID this quota applies to */ + modelId: string; + /** Remaining fraction (0-1) */ + remainingFraction: number; + /** ISO-8601 reset time */ + resetTime: string; +} + +/** Simplified quota info for a model tier (Flash or Pro) */ +export interface GeminiTierQuota { + /** Used percentage (0-100) */ + usedPercent: number; + /** Remaining percentage (0-100) */ + remainingPercent: number; + /** Reset time as human-readable string */ + resetText?: string; + /** ISO-8601 reset time */ + resetTime?: string; +} + +export interface GeminiUsage { + /** Whether the user is authenticated (via CLI or API key) */ + authenticated: boolean; + /** Authentication method: 'cli_login' | 'api_key' | 'api_key_env' | 'none' */ + authMethod: string; + /** Usage percentage (100 - remainingFraction * 100) - overall most constrained */ + usedPercent: number; + /** Remaining percentage - overall most constrained */ + remainingPercent: number; + /** Reset time as human-readable string */ + resetText?: string; + /** ISO-8601 reset time */ + resetTime?: string; + /** Model ID with lowest remaining quota */ + constrainedModel?: string; + /** Flash tier quota (aggregated from all flash models) */ + flashQuota?: GeminiTierQuota; + /** Pro tier quota (aggregated from all pro models) */ + proQuota?: GeminiTierQuota; + /** Raw quota buckets for detailed view */ + quotaBuckets?: GeminiQuotaBucket[]; + /** When this data was last fetched */ + lastUpdated: string; + /** Optional error message */ + error?: string; +} + +// Response type for Gemini usage API (can be success or error) +export type GeminiUsageResponse = GeminiUsage | { error: string; message?: string }; diff --git a/package-lock.json b/package-lock.json index 8804b479..96c4ff7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11475,7 +11475,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11497,7 +11496,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11519,7 +11517,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11541,7 +11538,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11563,7 +11559,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11585,7 +11580,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11607,7 +11601,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11629,7 +11622,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11651,7 +11643,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11673,7 +11664,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11695,7 +11685,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" },