import { ConfigManager } from '../ipc/config/manager'; import { ProxyAgent } from 'undici'; // --- Constants & Config --- const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com'; const CLIENT_SECRET = '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'; const URLS = { TOKEN: 'https://oauth2.googleapis.com/token', USER_INFO: 'https://www.googleapis.com/oauth2/v2/userinfo', AUTH: 'https://accounts.google.com/o/oauth2/v2/auth', QUOTA: 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels', LOAD_PROJECT: 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist', }; // Internal API masquerading const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64'; const REDIRECT_URI = 'http://localhost:8888/oauth-callback'; // Request timeout in milliseconds (30 seconds) const REQUEST_TIMEOUT_MS = 30000; /** * Creates an AbortSignal that times out after the specified duration. */ function createTimeoutSignal(ms: number): AbortSignal { const controller = new AbortController(); setTimeout(() => controller.abort(), ms); return controller.signal; } // --- Types --- export interface TokenResponse { access_token: string; expires_in: number; token_type: string; refresh_token?: string; scope?: string; } export interface UserInfo { id: string; email: string; verified_email: boolean; name: string; given_name: string; family_name: string; picture: string; } export interface QuotaData { models: Record< string, { percentage: number; resetTime: string; } >; } // Internal types for API parsing interface ModelInfoRaw { quotaInfo?: { remainingFraction?: number; resetTime?: string; }; } interface LoadProjectResponse { cloudaicompanionProject?: string; } // --- Service Implementation --- export class GoogleAPIService { private static getFetchOptions() { try { const config = ConfigManager.loadConfig(); if (config.proxy?.upstream_proxy?.enabled && config.proxy.upstream_proxy.url) { return { dispatcher: new ProxyAgent(config.proxy.upstream_proxy.url), }; } } catch (e) { // Fallback or log if config load fails (shouldn't happen usually) console.warn('[GoogleAPIService] Failed to load proxy config', e); } return {}; } /** * Generates the OAuth2 authorization URL. */ static getAuthUrl(): string { const scopes = [ 'https://www.googleapis.com/auth/cloud-platform', 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/cclog', 'https://www.googleapis.com/auth/experimentsandconfigs', ].join(' '); const params = new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: 'code', scope: scopes, access_type: 'offline', prompt: 'consent', include_granted_scopes: 'true', }); return `${URLS.AUTH}?${params.toString()}`; } /** * Exchanges an authorization code for tokens. */ static async exchangeCode(code: string): Promise { const params = new URLSearchParams({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code: code, redirect_uri: REDIRECT_URI, grant_type: 'authorization_code', }); const response = await fetch(URLS.TOKEN, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, signal: createTimeoutSignal(REQUEST_TIMEOUT_MS), ...this.getFetchOptions(), }).catch((err: unknown) => { if (err instanceof Error) { if (err.name === 'AbortError') { throw new Error( 'Token exchange timed out. Please check your network connection and try again.', ); } } throw err; }); if (!response.ok) { const text = await response.text(); throw new Error(`Token exchange failed: ${text}`); } return response.json() as Promise; } /** * Refreshes an access token using a refresh token. */ static async refreshAccessToken(refreshToken: string): Promise { const params = new URLSearchParams({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, refresh_token: refreshToken, grant_type: 'refresh_token', }); const response = await fetch(URLS.TOKEN, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, signal: createTimeoutSignal(REQUEST_TIMEOUT_MS), ...this.getFetchOptions(), }).catch((err: unknown) => { if (err instanceof Error) { if (err.name === 'AbortError') { throw new Error( 'Token refresh timed out. Please check your network connection and try again.', ); } } throw err; }); if (!response.ok) { const text = await response.text(); throw new Error(`Token refresh failed: ${text}`); } const data = (await response.json()) as TokenResponse; return data; } /** * Fetches user profile information. */ static async getUserInfo(accessToken: string): Promise { const response = await fetch(URLS.USER_INFO, { headers: { Authorization: `Bearer ${accessToken}` }, signal: createTimeoutSignal(REQUEST_TIMEOUT_MS), ...this.getFetchOptions(), }).catch((err: unknown) => { if (err instanceof Error) { if (err.name === 'AbortError') { throw new Error( 'User info request timed out. Please check your network connection and try again.', ); } } throw err; }); if (!response.ok) { const text = await response.text(); throw new Error(`Failed to fetch user info: ${text}`); } return response.json() as Promise; } /** * Core logic: Fetches the internal project ID needed for quota checks. */ public static async fetchProjectId(accessToken: string): Promise { const body = { metadata: { ideType: 'ANTIGRAVITY' }, }; // Simple retry logic (2 attempts) for (let i = 0; i < 2; i++) { try { const response = await fetch(URLS.LOAD_PROJECT, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'User-Agent': USER_AGENT, 'Content-Type': 'application/json', }, body: JSON.stringify(body), ...this.getFetchOptions(), }); if (response.ok) { const data = (await response.json()) as LoadProjectResponse; if (data.cloudaicompanionProject) { return data.cloudaicompanionProject; } } } catch (e) { console.warn(`[GoogleAPIService] Failed to fetch project ID (Attempt ${i + 1}):`, e); await new Promise((r) => setTimeout(r, 500)); // Sleep 500ms } } return null; } /** * Core logic: Fetches detailed model quota information. */ static async fetchQuota(accessToken: string): Promise { // 1. Get Project ID (Critical) const projectId = await this.fetchProjectId(accessToken); // 2. Build Payload const payload: Record = {}; if (projectId) { payload['project'] = projectId; } // 3. Send Request with Retries const maxRetries = 3; let lastError: Error | null = null; const fetchOptions = this.getFetchOptions(); // Reuse options for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const response = await fetch(URLS.QUOTA, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'User-Agent': USER_AGENT, 'Content-Type': 'application/json', }, body: JSON.stringify(payload), ...fetchOptions, }); if (!response.ok) { const text = await response.text(); const status = response.status; if (status === 403) { throw new Error('FORBIDDEN'); } if (status === 401) { throw new Error('UNAUTHORIZED'); } const errorMsg = `HTTP ${status} - ${text}`; console.warn( `[GoogleAPIService] API Error: ${errorMsg} (Attempt ${attempt}/${maxRetries})`, ); if (attempt < maxRetries) { await new Promise((r) => setTimeout(r, 1000)); continue; } else { throw new Error(errorMsg); } } const data = (await response.json()) as { models: Record }; const result: QuotaData = { models: {} }; // Parse relevant models for (const [name, info] of Object.entries(data.models || {})) { if (info.quotaInfo) { const fraction = info.quotaInfo.remainingFraction ?? 0; const percentage = Math.floor(fraction * 100); const resetTime = info.quotaInfo.resetTime || ''; if (name.toLowerCase().includes('gemini') || name.toLowerCase().includes('claude')) { result.models[name] = { percentage, resetTime }; } } } return result; } catch (e: unknown) { if (e instanceof Error) { console.warn( `[GoogleAPIService] Request failed: ${e.message} (Attempt ${attempt}/${maxRetries})`, ); } lastError = e instanceof Error ? e : new Error(String(e)); if (attempt < maxRetries) { await new Promise((r) => setTimeout(r, 1000)); } } } throw lastError || new Error('Quota check failed'); } }