Initial Commit

This commit is contained in:
Draculabo committed 2025-12-23 01:38:53 +08:00
commit d0417339c1
158 files changed
+31368

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
import { CloudAccountRepo } from '../ipc/database/cloudHandler';
import { CloudAccount } from '../types/cloudAccount';
import { switchCloudAccount } from '../ipc/cloud/handler';
import { logger } from '../utils/logger';
export class AutoSwitchService {
/**
* Finds the best cloud account to switch to.
* Criteria:
* 1. Not the current account (unless it's the only one).
* 2. Status is 'active'.
* 3. Has quota > 5% for all models (or at least gemini-pro).
* 4. Sorted by highest quota then last_used (least recently used preferred for rotation? or most? Let's say highest quota first).
*/
static async findBestAccount(currentAccountId: string): Promise<CloudAccount | null> {
const accounts = await CloudAccountRepo.getAccounts();
// Filter potential candidates
const candidates = accounts.filter((acc) => {
if (acc.id === currentAccountId) return false;
if (acc.status !== 'active') return false; // Rate limited or expired accounts are skipped
// Check quota
// We assume simple check: if any model has < 5%, we skip it.
// Or better: check average? NO, check critical models.
// For now, let's just check if quota object exists.
if (!acc.quota) return false; // No quota data means risky
const models = Object.values(acc.quota.models);
// If any model is depleted (< 5%), skip.
const isDepleted = models.some((m) => m.percentage < 5);
return !isDepleted;
});
if (candidates.length === 0) return null;
// Sort by "Best"
// Heuristic: Highest average quota availability
candidates.sort((a, b) => {
const avgA = this.calculateAverageQuota(a);
const avgB = this.calculateAverageQuota(b);
return avgB - avgA; // Descending
});
return candidates[0];
}
private static calculateAverageQuota(account: CloudAccount): number {
if (!account.quota) return 0;
const values = Object.values(account.quota.models).map((m) => m.percentage);
if (values.length === 0) return 0;
const sum = values.reduce((a, b) => a + b, 0);
return sum / values.length;
}
/**
* Triggered by Monitor Service or UI.
* Checks if we need to switch from the current account.
*/
static async checkAndSwitchIfNeeded(): Promise<boolean> {
const enabled = CloudAccountRepo.getSetting<boolean>('auto_switch_enabled', false);
if (!enabled) return false;
// Get current active account
const accounts = await CloudAccountRepo.getAccounts();
const currentAccount = accounts.find((a) => a.is_active);
// If no active account, maybe we should pick one?
// For now, assume user manually picked first one.
if (!currentAccount) return false;
// Check if current is depleted
const isDepleted = this.isAccountDepleted(currentAccount);
if (isDepleted || currentAccount.status === 'rate_limited') {
logger.info(
`AutoSwitch: Current account ${currentAccount.email} is depleted or rate limited.`,
);
const nextAccount = await this.findBestAccount(currentAccount.id);
if (nextAccount) {
logger.info(`AutoSwitch: Switching to ${nextAccount.email}...`);
// Notify user (via toast? we are in main process... IPC event?)
// Ideally we send an IPC event to renderer.
// For now, logic first.
await switchCloudAccount(nextAccount.id);
// We might want to send a notification to user desktop?
// require('electron').Notification ...
return true;
} else {
logger.warn('AutoSwitch: No healthy accounts available to switch to.');
}
}
return false;
}
static isAccountDepleted(account: CloudAccount): boolean {
if (!account.quota) return false; // Unknown, assume fine or let fetchQuota find out
// Threshold = 5%
const THRESHOLD = 5;
return Object.values(account.quota.models).some((m) => m.percentage < THRESHOLD);
}
}
+65
View File
@@ -0,0 +1,65 @@
import { CloudAccountRepo } from '../ipc/database/cloudHandler';
import { GoogleAPIService } from './GoogleAPIService';
import { AutoSwitchService } from './AutoSwitchService';
import { logger } from '../utils/logger';
export class CloudMonitorService {
private static intervalId: NodeJS.Timeout | null = null;
private static POLL_INTERVAL = 1000 * 60 * 5; // 5 minutes
static start() {
if (this.intervalId) return;
logger.info('Starting CloudMonitorService...');
// Initial Poll
this.poll().catch((e) => logger.error('Initial poll failed', e));
this.intervalId = setInterval(() => {
this.poll().catch((e) => logger.error('Scheduled poll failed', e));
}, this.POLL_INTERVAL);
}
static stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
logger.info('Stopped CloudMonitorService');
}
}
static async poll() {
logger.info('CloudMonitor: Polling quotas...');
const accounts = await CloudAccountRepo.getAccounts();
const now = Math.floor(Date.now() / 1000);
for (const account of accounts) {
try {
// 1. Check/Refresh Token if needed (give it a 10 min buffer here for safety)
let accessToken = account.token.access_token;
if (account.token.expiry_timestamp < now + 600) {
logger.info(`Monitor: Refreshing token for ${account.email}`);
const newToken = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
account.token.access_token = newToken.access_token;
account.token.expires_in = newToken.expires_in;
account.token.expiry_timestamp = now + newToken.expires_in;
await CloudAccountRepo.updateToken(account.id, account.token);
accessToken = newToken.access_token;
}
// 2. Fetch Quota
// We delay slightly between requests to act human/avoid spike
await new Promise((r) => setTimeout(r, 1000));
const quota = await GoogleAPIService.fetchQuota(accessToken);
// 3. Update DB
await CloudAccountRepo.updateQuota(account.id, quota);
} catch (error) {
logger.error(`Monitor: Failed to update ${account.email}`, error);
// Could mark status as 'error' or 'rate_limited' if 429
}
}
// 4. Check for Auto-Switch
await AutoSwitchService.checkAndSwitchIfNeeded();
}
}
+301
View File
@@ -0,0 +1,301 @@
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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';
declare const fetch: any;
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';
// --- 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(): any {
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<TokenResponse> {
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,
...this.getFetchOptions(),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Token exchange failed: ${text}`);
}
return response.json() as Promise<TokenResponse>;
}
/**
* Refreshes an access token using a refresh token.
*/
static async refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
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,
...this.getFetchOptions(),
});
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<UserInfo> {
const response = await fetch(URLS.USER_INFO, {
headers: { Authorization: `Bearer ${accessToken}` },
...this.getFetchOptions(),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to fetch user info: ${text}`);
}
return response.json() as Promise<UserInfo>;
}
/**
* Core logic: Fetches the internal project ID needed for quota checks.
*/
public static async fetchProjectId(accessToken: string): Promise<string | null> {
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<QuotaData> {
// 1. Get Project ID (Critical)
const projectId = await this.fetchProjectId(accessToken);
// 2. Build Payload
const payload: Record<string, any> = {};
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<string, ModelInfoRaw> };
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: any) {
console.warn(
`[GoogleAPIService] Request failed: ${e.message} (Attempt ${attempt}/${maxRetries})`,
);
lastError = e;
if (attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 1000));
}
}
}
throw lastError || new Error('Quota check failed');
}
}