- GoogleAPIService: try loadCodeAssist first for AI credits since fetchCredits returns 404 for many accounts; keep fetchCredits as fallback, removing the spurious warn on every poll cycle - CloudAccountRepo: auto-activate the most-recently-used account when no account has is_active=1; downgrade noisy [DEBUG] log from info to debug level - StatusBar: increase process-status refetch interval from 10s to 30s to reduce redundant IPC calls and log noise
1004 lines
30 KiB
TypeScript
1004 lines
30 KiB
TypeScript
import { z } from 'zod';
|
|
import { ConfigManager } from '../ipc/config/manager';
|
|
import { AuthServer } from '../ipc/cloud/authServer';
|
|
import { EnvHttpProxyAgent, ProxyAgent } from 'undici';
|
|
import { logger } from '../utils/logger';
|
|
import {
|
|
buildUserAgent,
|
|
FALLBACK_VERSION,
|
|
resolveLocalInstalledVersion,
|
|
} from '@/server/modules/proxy/request-user-agent';
|
|
import { isEmpty, isNumber, isString, isUndefined } from 'lodash-es';
|
|
|
|
// --- Constants & Config ---
|
|
|
|
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
|
const CLIENT_SECRET = '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';
|
|
const OAUTH_CLIENTS_ENV = 'ANTIGRAVITY_OAUTH_CLIENTS';
|
|
const ACTIVE_OAUTH_CLIENT_ENV = 'ANTIGRAVITY_OAUTH_CLIENT_KEY';
|
|
const DEFAULT_OAUTH_CLIENT_KEY = 'geminiNexus_enterprise';
|
|
|
|
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',
|
|
LOAD_PROJECT: 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist',
|
|
DAILY_LOAD_PROJECT: 'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist',
|
|
FETCH_CREDITS: 'https://cloudcode-pa.googleapis.com/v1internal:fetchCredits',
|
|
};
|
|
|
|
const QUOTA_API_ENDPOINTS = [
|
|
'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels',
|
|
'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
|
|
'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
|
|
] as const;
|
|
|
|
// Request timeout in milliseconds (30 seconds)
|
|
const REQUEST_TIMEOUT_MS = 30000;
|
|
|
|
interface OAuthClientConfig {
|
|
key: string;
|
|
label: string;
|
|
client_id: string;
|
|
client_secret: string;
|
|
is_builtin: boolean;
|
|
}
|
|
|
|
interface OAuthClientRegistry {
|
|
clients: OAuthClientConfig[];
|
|
activeKey: string;
|
|
}
|
|
|
|
let cachedOAuthClientRegistry: OAuthClientRegistry | null = null;
|
|
|
|
function normalizeClientKey(key: string): string {
|
|
return key.trim().toLowerCase();
|
|
}
|
|
|
|
function getClientByKey(
|
|
clients: OAuthClientConfig[],
|
|
clientKey: string | undefined,
|
|
): OAuthClientConfig | null {
|
|
if (!clientKey) {
|
|
return null;
|
|
}
|
|
const normalizedKey = normalizeClientKey(clientKey);
|
|
return clients.find((client) => client.key === normalizedKey) ?? null;
|
|
}
|
|
|
|
function buildOAuthClientRegistry(): OAuthClientRegistry {
|
|
const clients: OAuthClientConfig[] = [
|
|
{
|
|
key: normalizeClientKey(DEFAULT_OAUTH_CLIENT_KEY),
|
|
label: 'Gemini Nexus Enterprise',
|
|
client_id: CLIENT_ID,
|
|
client_secret: CLIENT_SECRET,
|
|
is_builtin: true,
|
|
},
|
|
];
|
|
|
|
const rawExtraClients = process.env[OAUTH_CLIENTS_ENV];
|
|
if (isString(rawExtraClients) && !isEmpty(rawExtraClients.trim())) {
|
|
for (const entry of rawExtraClients.split(';')) {
|
|
const trimmed = entry.trim();
|
|
if (trimmed === '') {
|
|
continue;
|
|
}
|
|
|
|
const parts = trimmed.split('|').map((part) => part.trim());
|
|
if (parts.length < 3) {
|
|
logger.warn(
|
|
`[GoogleAPIService] Ignored invalid OAuth client entry in ${OAUTH_CLIENTS_ENV}: ${trimmed}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const key = normalizeClientKey(parts[0]);
|
|
const clientId = parts[1];
|
|
const clientSecret = parts[2];
|
|
if (key === '' || clientId === '' || clientSecret === '') {
|
|
logger.warn(
|
|
`[GoogleAPIService] Ignored incomplete OAuth client entry in ${OAUTH_CLIENTS_ENV}: ${trimmed}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const clientConfig: OAuthClientConfig = {
|
|
key,
|
|
label: parts[3] && parts[3] !== '' ? parts[3] : key,
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
is_builtin: false,
|
|
};
|
|
|
|
const existingIndex = clients.findIndex((client) => client.key === key);
|
|
if (existingIndex >= 0) {
|
|
clients[existingIndex] = clientConfig;
|
|
} else {
|
|
clients.push(clientConfig);
|
|
}
|
|
}
|
|
}
|
|
|
|
let activeKey = normalizeClientKey(
|
|
process.env[ACTIVE_OAUTH_CLIENT_ENV] || DEFAULT_OAUTH_CLIENT_KEY,
|
|
);
|
|
if (!clients.some((client) => client.key === activeKey)) {
|
|
activeKey = clients[0]?.key ?? normalizeClientKey(DEFAULT_OAUTH_CLIENT_KEY);
|
|
}
|
|
|
|
return {
|
|
clients,
|
|
activeKey,
|
|
};
|
|
}
|
|
|
|
function getOAuthClientRegistry(): OAuthClientRegistry {
|
|
if (cachedOAuthClientRegistry) {
|
|
return cachedOAuthClientRegistry;
|
|
}
|
|
cachedOAuthClientRegistry = buildOAuthClientRegistry();
|
|
return cachedOAuthClientRegistry;
|
|
}
|
|
|
|
function getCandidateClients(preferredClientKey?: string): OAuthClientConfig[] {
|
|
const registry = getOAuthClientRegistry();
|
|
const candidates: OAuthClientConfig[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
const pushCandidate = (candidate: OAuthClientConfig | null) => {
|
|
if (!candidate || seen.has(candidate.key)) {
|
|
return;
|
|
}
|
|
seen.add(candidate.key);
|
|
candidates.push(candidate);
|
|
};
|
|
|
|
const preferred = getClientByKey(registry.clients, preferredClientKey);
|
|
if (preferredClientKey && !preferred) {
|
|
logger.warn(
|
|
`[GoogleAPIService] Preferred OAuth client '${preferredClientKey}' not found; fallback to active client list`,
|
|
);
|
|
}
|
|
|
|
pushCandidate(preferred);
|
|
pushCandidate(getClientByKey(registry.clients, registry.activeKey));
|
|
|
|
for (const client of registry.clients) {
|
|
pushCandidate(client);
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
|
|
function selectAuthClient(clientKey?: string): OAuthClientConfig {
|
|
const registry = getOAuthClientRegistry();
|
|
if (registry.clients.length === 0) {
|
|
throw new Error('No OAuth clients configured');
|
|
}
|
|
|
|
if (isString(clientKey) && !isEmpty(clientKey.trim())) {
|
|
const selected = getClientByKey(registry.clients, clientKey);
|
|
if (!selected) {
|
|
throw new Error(`Unknown OAuth client key: ${clientKey}`);
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
return getClientByKey(registry.clients, registry.activeKey) ?? registry.clients[0];
|
|
}
|
|
|
|
function isClientMismatchError(status: number, errorText: string): boolean {
|
|
const text = errorText.toLowerCase();
|
|
return (
|
|
status === 400 ||
|
|
status === 401 ||
|
|
status === 403 ||
|
|
text.includes('unauthorized_client') ||
|
|
text.includes('invalid_client')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
oauth_client_key?: string;
|
|
}
|
|
|
|
export interface OAuthClientDescriptor {
|
|
key: string;
|
|
label: string;
|
|
client_id: string;
|
|
is_active: boolean;
|
|
is_builtin: boolean;
|
|
}
|
|
|
|
export interface UserInfo {
|
|
id: string;
|
|
email: string;
|
|
verified_email: boolean;
|
|
name: string;
|
|
given_name?: string;
|
|
family_name?: string;
|
|
picture?: string;
|
|
}
|
|
|
|
export const UserInfoSchema = z.object({
|
|
id: z.string(),
|
|
email: z.string(),
|
|
verified_email: z.boolean().optional().default(false),
|
|
name: z.string().optional(),
|
|
given_name: z.string().optional(),
|
|
family_name: z.string().optional(),
|
|
picture: z.string().optional(),
|
|
});
|
|
|
|
export interface QuotaData {
|
|
models: Record<string, ModelQuotaInfo>;
|
|
model_forwarding_rules?: Record<string, string>;
|
|
subscription_tier?: string;
|
|
is_forbidden?: boolean;
|
|
ai_credits?: { credits: number; expiryDate: string };
|
|
}
|
|
|
|
export interface ModelQuotaInfo {
|
|
percentage: number;
|
|
resetTime: string;
|
|
display_name?: string;
|
|
supports_images?: boolean;
|
|
supports_thinking?: boolean;
|
|
thinking_budget?: number;
|
|
recommended?: boolean;
|
|
max_tokens?: number;
|
|
max_output_tokens?: number;
|
|
supported_mime_types?: Record<string, boolean>;
|
|
}
|
|
|
|
// Internal types for API parsing
|
|
interface ModelInfoRaw {
|
|
quotaInfo?: {
|
|
remainingFraction?: number;
|
|
resetTime?: string;
|
|
};
|
|
displayName?: string;
|
|
supportsImages?: boolean;
|
|
supportsThinking?: boolean;
|
|
thinkingBudget?: number;
|
|
recommended?: boolean;
|
|
maxTokens?: number;
|
|
maxOutputTokens?: number;
|
|
supportedMimeTypes?: Record<string, boolean>;
|
|
}
|
|
|
|
interface DeprecatedModelInfoRaw {
|
|
newModelId?: string;
|
|
}
|
|
|
|
interface IneligibleTierRaw {
|
|
reasonCode?: string;
|
|
}
|
|
|
|
interface TierRaw {
|
|
is_default?: boolean;
|
|
id?: string;
|
|
quotaTier?: string;
|
|
name?: string;
|
|
slug?: string;
|
|
availableCredits?: AvailableCreditRaw[];
|
|
}
|
|
|
|
interface AvailableCreditRaw {
|
|
creditType?: string;
|
|
creditAmount?: string | number;
|
|
minimumCreditAmountForUsage?: string | number;
|
|
}
|
|
|
|
interface LoadProjectResponse {
|
|
cloudaicompanionProject?: string;
|
|
currentTier?: TierRaw;
|
|
paidTier?: TierRaw;
|
|
allowedTiers?: TierRaw[];
|
|
ineligibleTiers?: IneligibleTierRaw[];
|
|
}
|
|
|
|
interface FetchModelsResponse {
|
|
models?: Record<string, ModelInfoRaw>;
|
|
deprecatedModelIds?: Record<string, DeprecatedModelInfoRaw>;
|
|
}
|
|
|
|
interface ProjectContext {
|
|
projectId?: string;
|
|
subscriptionTier?: string;
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
function buildInternalApiHeaders(accessToken: string): Record<string, string> {
|
|
const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
|
|
return {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'User-Agent': buildUserAgent(discoveryVersion),
|
|
'Content-Type': 'application/json',
|
|
};
|
|
}
|
|
|
|
function resolveSubscriptionTier(payload: LoadProjectResponse): string | undefined {
|
|
const paidTier = payload.paidTier;
|
|
if (isString(paidTier?.name) && !isEmpty(paidTier.name.trim())) {
|
|
return paidTier.name;
|
|
}
|
|
if (isString(paidTier?.id) && !isEmpty(paidTier.id.trim())) {
|
|
return paidTier.id;
|
|
}
|
|
|
|
const ineligible = Array.isArray(payload.ineligibleTiers) && payload.ineligibleTiers.length > 0;
|
|
if (!ineligible) {
|
|
const currentTier = payload.currentTier;
|
|
if (isString(currentTier?.name) && !isEmpty(currentTier.name.trim())) {
|
|
return currentTier.name;
|
|
}
|
|
if (isString(currentTier?.id) && !isEmpty(currentTier.id.trim())) {
|
|
return currentTier.id;
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(payload.allowedTiers)) {
|
|
const preferredAllowedTier =
|
|
payload.allowedTiers.find((tier) => tier.is_default === true) ?? payload.allowedTiers[0];
|
|
if (isString(preferredAllowedTier?.name) && !isEmpty(preferredAllowedTier.name.trim())) {
|
|
return ineligible ? `${preferredAllowedTier.name} (Restricted)` : preferredAllowedTier.name;
|
|
}
|
|
if (isString(preferredAllowedTier?.id) && !isEmpty(preferredAllowedTier.id.trim())) {
|
|
return ineligible ? `${preferredAllowedTier.id} (Restricted)` : preferredAllowedTier.id;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function isTrackedModel(modelName: string): boolean {
|
|
return /^(gemini|claude|gpt|image|imagen)/i.test(modelName);
|
|
}
|
|
|
|
function toModelQuotaInfo(modelName: string, info: ModelInfoRaw): ModelQuotaInfo | null {
|
|
if (!isTrackedModel(modelName) || !info.quotaInfo) {
|
|
return null;
|
|
}
|
|
|
|
const fraction = info.quotaInfo.remainingFraction ?? 0;
|
|
return {
|
|
percentage: Math.floor(fraction * 100),
|
|
resetTime: info.quotaInfo.resetTime || '',
|
|
display_name: info.displayName,
|
|
supports_images: info.supportsImages,
|
|
supports_thinking: info.supportsThinking,
|
|
thinking_budget: info.thinkingBudget,
|
|
recommended: info.recommended,
|
|
max_tokens: info.maxTokens,
|
|
max_output_tokens: info.maxOutputTokens,
|
|
supported_mime_types: info.supportedMimeTypes,
|
|
};
|
|
}
|
|
|
|
function toModelForwardingRules(
|
|
deprecatedModelIds: FetchModelsResponse['deprecatedModelIds'],
|
|
): Record<string, string> | undefined {
|
|
if (!deprecatedModelIds || Object.keys(deprecatedModelIds).length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
const forwardingRules: Record<string, string> = {};
|
|
for (const [oldModelId, deprecatedInfo] of Object.entries(deprecatedModelIds)) {
|
|
if (isString(deprecatedInfo.newModelId) && deprecatedInfo.newModelId !== '') {
|
|
forwardingRules[oldModelId] = deprecatedInfo.newModelId;
|
|
}
|
|
}
|
|
|
|
return Object.keys(forwardingRules).length > 0 ? forwardingRules : undefined;
|
|
}
|
|
|
|
function parseCreditAmount(value: string | number | undefined): number {
|
|
if (isNumber(value)) {
|
|
return value;
|
|
}
|
|
|
|
if (isString(value)) {
|
|
const parsed = Number.parseInt(value, 10);
|
|
if (Number.isFinite(parsed)) {
|
|
return parsed;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
function toAiCredits(
|
|
payload: Partial<{
|
|
credits: unknown;
|
|
remainingCredits: unknown;
|
|
expiryDate: unknown;
|
|
expirationDate: unknown;
|
|
}>,
|
|
): { credits: number; expiryDate: string } | null {
|
|
const creditsValue =
|
|
isNumber(payload.credits) || isString(payload.credits)
|
|
? payload.credits
|
|
: isNumber(payload.remainingCredits) || isString(payload.remainingCredits)
|
|
? payload.remainingCredits
|
|
: undefined;
|
|
|
|
if (isUndefined(creditsValue)) {
|
|
return null;
|
|
}
|
|
|
|
const expiryDate = isString(payload.expiryDate)
|
|
? payload.expiryDate
|
|
: isString(payload.expirationDate)
|
|
? payload.expirationDate
|
|
: '';
|
|
|
|
return {
|
|
credits: parseCreditAmount(creditsValue),
|
|
expiryDate,
|
|
};
|
|
}
|
|
|
|
function extractAiCreditsFromProjectContext(
|
|
payload: LoadProjectResponse,
|
|
): { credits: number; expiryDate: string } | null {
|
|
const availableCredit = payload.paidTier?.availableCredits?.[0];
|
|
if (!availableCredit) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
credits: parseCreditAmount(availableCredit.creditAmount),
|
|
expiryDate: '',
|
|
};
|
|
}
|
|
|
|
// --- Service Implementation ---
|
|
|
|
export class GoogleAPIService {
|
|
static listOAuthClients(): OAuthClientDescriptor[] {
|
|
const registry = getOAuthClientRegistry();
|
|
return registry.clients.map((client) => {
|
|
return {
|
|
key: client.key,
|
|
label: client.label,
|
|
client_id: client.client_id,
|
|
is_active: client.key === registry.activeKey,
|
|
is_builtin: client.is_builtin,
|
|
};
|
|
});
|
|
}
|
|
|
|
static getActiveOAuthClientKey(): string {
|
|
const registry = getOAuthClientRegistry();
|
|
return registry.activeKey;
|
|
}
|
|
|
|
static setActiveOAuthClientKey(clientKey: string): void {
|
|
const registry = getOAuthClientRegistry();
|
|
const normalized = normalizeClientKey(clientKey);
|
|
const exists = registry.clients.some((client) => client.key === normalized);
|
|
if (!exists) {
|
|
const available = registry.clients.map((client) => client.key).join(', ');
|
|
throw new Error(`Unknown OAuth client key '${clientKey}'. Available: ${available}`);
|
|
}
|
|
registry.activeKey = normalized;
|
|
process.env[ACTIVE_OAUTH_CLIENT_ENV] = normalized;
|
|
}
|
|
|
|
static normalizeRefreshedOAuthClientKey(
|
|
currentToken: { oauth_client_key?: string; project_id?: string },
|
|
refreshedClientKey?: string,
|
|
): string | undefined {
|
|
const resolved = refreshedClientKey ?? currentToken.oauth_client_key;
|
|
const projectMissing =
|
|
!isString(currentToken.project_id) || isEmpty(currentToken.project_id.trim());
|
|
|
|
if (
|
|
!isString(currentToken.oauth_client_key) &&
|
|
projectMissing &&
|
|
resolved &&
|
|
normalizeClientKey(resolved) === DEFAULT_OAUTH_CLIENT_KEY
|
|
) {
|
|
logger.warn(
|
|
'[GoogleAPIService] Refreshed token via enterprise client for a legacy account without project_id; keep oauth_client_key unset to avoid accidental enterprise lock',
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
return resolved ? normalizeClientKey(resolved) : undefined;
|
|
}
|
|
|
|
private static getFetchOptions(proxyUrl?: string) {
|
|
const proxyTraceEnabled = process.env.DEBUG_PROXY_TRACE === '1';
|
|
|
|
if (proxyUrl && proxyUrl.length > 0) {
|
|
if (proxyTraceEnabled) {
|
|
logger.info('[GoogleAPIService] Proxy source: account proxy_url');
|
|
}
|
|
return {
|
|
dispatcher: new ProxyAgent(proxyUrl),
|
|
};
|
|
}
|
|
try {
|
|
const config = ConfigManager.loadConfig();
|
|
if (config.proxy?.upstream_proxy?.enabled) {
|
|
if (!config.proxy.upstream_proxy.url) {
|
|
throw new Error('Upstream proxy is enabled but URL is not configured');
|
|
}
|
|
if (proxyTraceEnabled) {
|
|
logger.info('[GoogleAPIService] Proxy source: config.proxy.upstream_proxy.url');
|
|
}
|
|
return {
|
|
dispatcher: new ProxyAgent(config.proxy.upstream_proxy.url),
|
|
};
|
|
}
|
|
} catch (e) {
|
|
logger.error('[GoogleAPIService] Proxy configuration error', e);
|
|
throw e;
|
|
}
|
|
|
|
const httpProxy = process.env.http_proxy?.trim() || process.env.HTTP_PROXY?.trim();
|
|
const httpsProxy = process.env.https_proxy?.trim() || process.env.HTTPS_PROXY?.trim();
|
|
const noProxy = process.env.no_proxy?.trim() || process.env.NO_PROXY?.trim();
|
|
const electronProxyServer = process.env.ELECTRON_PROXY_SERVER?.trim();
|
|
|
|
if (httpProxy || httpsProxy) {
|
|
if (proxyTraceEnabled) {
|
|
logger.info(
|
|
`[GoogleAPIService] Proxy source: HTTP(S)_PROXY env (http: ${httpProxy ?? 'none'}, https: ${httpsProxy ?? 'none'})`,
|
|
);
|
|
}
|
|
return {
|
|
dispatcher: new EnvHttpProxyAgent({
|
|
httpProxy,
|
|
httpsProxy,
|
|
noProxy,
|
|
}),
|
|
};
|
|
}
|
|
|
|
if (electronProxyServer) {
|
|
if (proxyTraceEnabled) {
|
|
logger.info(
|
|
`[GoogleAPIService] Proxy source: ELECTRON_PROXY_SERVER env (${electronProxyServer})`,
|
|
);
|
|
}
|
|
return {
|
|
dispatcher: new ProxyAgent(electronProxyServer),
|
|
};
|
|
}
|
|
|
|
if (proxyTraceEnabled) {
|
|
logger.info('[GoogleAPIService] Proxy source: none');
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
/**
|
|
* Generates the OAuth2 authorization URL.
|
|
*/
|
|
static getAuthUrl(oauthClientKey?: string): 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 oauthClient = selectAuthClient(oauthClientKey);
|
|
const redirectUri = AuthServer.getRedirectUri();
|
|
|
|
const params = new URLSearchParams({
|
|
client_id: oauthClient.client_id,
|
|
redirect_uri: redirectUri,
|
|
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,
|
|
proxyUrl?: string,
|
|
preferredClientKey?: string,
|
|
): Promise<TokenResponse> {
|
|
const redirectUri = AuthServer.getRedirectUri();
|
|
const candidates = getCandidateClients(preferredClientKey);
|
|
if (candidates.length === 0) {
|
|
throw new Error('No OAuth clients configured');
|
|
}
|
|
|
|
const attemptErrors: string[] = [];
|
|
|
|
for (const client of candidates) {
|
|
const params = new URLSearchParams({
|
|
client_id: client.client_id,
|
|
client_secret: client.client_secret,
|
|
code,
|
|
redirect_uri: redirectUri,
|
|
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(proxyUrl),
|
|
}).catch((err: unknown) => {
|
|
if (err instanceof Error && err.name === 'AbortError') {
|
|
throw new Error(
|
|
'Token exchange timed out. Please check your network connection and try again.',
|
|
);
|
|
}
|
|
throw err;
|
|
});
|
|
|
|
if (response.ok) {
|
|
const tokenResponse = (await response.json()) as TokenResponse;
|
|
tokenResponse.oauth_client_key = client.key;
|
|
return tokenResponse;
|
|
}
|
|
|
|
const text = await response.text();
|
|
attemptErrors.push(`${client.key} => ${text}`);
|
|
if (isClientMismatchError(response.status, text)) {
|
|
logger.warn(
|
|
`[GoogleAPIService] Token exchange failed for OAuth client '${client.key}', trying next client`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
throw new Error(`Token exchange failed for client [${client.key}]: ${text}`);
|
|
}
|
|
|
|
throw new Error(`Token exchange failed for all OAuth clients: ${attemptErrors.join(' | ')}`);
|
|
}
|
|
|
|
/**
|
|
* Refreshes an access token using a refresh token.
|
|
*/
|
|
static async refreshAccessToken(
|
|
refreshToken: string,
|
|
proxyUrl?: string,
|
|
preferredClientKey?: string,
|
|
): Promise<TokenResponse> {
|
|
const candidates = getCandidateClients(preferredClientKey);
|
|
if (candidates.length === 0) {
|
|
throw new Error('No OAuth clients configured');
|
|
}
|
|
|
|
const attemptErrors: string[] = [];
|
|
|
|
for (const client of candidates) {
|
|
const params = new URLSearchParams({
|
|
client_id: client.client_id,
|
|
client_secret: client.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(proxyUrl),
|
|
}).catch((err: unknown) => {
|
|
if (err instanceof Error && err.name === 'AbortError') {
|
|
throw new Error(
|
|
'Token refresh timed out. Please check your network connection and try again.',
|
|
);
|
|
}
|
|
throw err;
|
|
});
|
|
|
|
if (response.ok) {
|
|
const tokenResponse = (await response.json()) as TokenResponse;
|
|
tokenResponse.oauth_client_key = client.key;
|
|
return tokenResponse;
|
|
}
|
|
|
|
const text = await response.text();
|
|
attemptErrors.push(`${client.key} => ${text}`);
|
|
if (isClientMismatchError(response.status, text)) {
|
|
logger.warn(
|
|
`[GoogleAPIService] Token refresh failed for OAuth client '${client.key}', trying next client`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
throw new Error(`Token refresh failed for client [${client.key}]: ${text}`);
|
|
}
|
|
|
|
throw new Error(`Token refresh failed for all OAuth clients: ${attemptErrors.join(' | ')}`);
|
|
}
|
|
|
|
/**
|
|
* Fetches user profile information.
|
|
*/
|
|
static async getUserInfo(accessToken: string, proxyUrl?: string): Promise<UserInfo> {
|
|
const response = await fetch(URLS.USER_INFO, {
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
|
...this.getFetchOptions(proxyUrl),
|
|
}).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}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
try {
|
|
const parsed = UserInfoSchema.parse(data);
|
|
|
|
return {
|
|
...parsed,
|
|
// Google may omit profile claims such as family_name for accounts with limited profile data.
|
|
name: parsed.name ?? parsed.email,
|
|
};
|
|
} catch (err) {
|
|
logger.error('[GoogleAPIService] Malformed user info response:', err);
|
|
throw new Error('Received malformed user info from Google APIs');
|
|
}
|
|
}
|
|
|
|
public static async fetchProjectContext(
|
|
accessToken: string,
|
|
proxyUrl?: string,
|
|
): Promise<ProjectContext> {
|
|
const body = {
|
|
metadata: { ideType: 'ANTIGRAVITY' },
|
|
};
|
|
|
|
let projectId: string | undefined;
|
|
let subscriptionTier: string | undefined;
|
|
let lastError: any;
|
|
|
|
for (let i = 0; i < 2; i++) {
|
|
try {
|
|
const response = await fetch(URLS.LOAD_PROJECT, {
|
|
method: 'POST',
|
|
headers: buildInternalApiHeaders(accessToken),
|
|
body: JSON.stringify(body),
|
|
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
|
...this.getFetchOptions(proxyUrl),
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = (await response.json()) as LoadProjectResponse;
|
|
if (isString(data.cloudaicompanionProject)) {
|
|
projectId = data.cloudaicompanionProject;
|
|
}
|
|
subscriptionTier = resolveSubscriptionTier(data);
|
|
break;
|
|
} else {
|
|
lastError = new Error(`HTTP ${response.status}: ${await response.text()}`);
|
|
}
|
|
} catch (error) {
|
|
lastError = error;
|
|
logger.warn(`[GoogleAPIService] Failed to fetch project ID (Attempt ${i + 1}):`, error);
|
|
await sleep(500);
|
|
}
|
|
}
|
|
|
|
if (!projectId && !subscriptionTier) {
|
|
throw lastError || new Error('Failed to fetch project context after multiple attempts.');
|
|
}
|
|
|
|
return {
|
|
projectId,
|
|
subscriptionTier,
|
|
};
|
|
}
|
|
|
|
public static async fetchProjectId(
|
|
accessToken: string,
|
|
proxyUrl?: string,
|
|
): Promise<string | null> {
|
|
const context = await this.fetchProjectContext(accessToken, proxyUrl);
|
|
return context.projectId ?? null;
|
|
}
|
|
|
|
static async fetchAICredits(
|
|
accessToken: string,
|
|
proxyUrl?: string,
|
|
): Promise<{ credits: number; expiryDate: string } | null> {
|
|
try {
|
|
const fetchOptions = this.getFetchOptions(proxyUrl);
|
|
|
|
// Try loadCodeAssist first — fetchCredits returns 404 for many accounts
|
|
// and loadCodeAssist provides the same paid tier credit data.
|
|
const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
|
|
const loadAssistResponse = await fetch(URLS.DAILY_LOAD_PROJECT, {
|
|
method: 'POST',
|
|
headers: buildInternalApiHeaders(accessToken),
|
|
body: JSON.stringify({
|
|
metadata: {
|
|
ide_type: 'ANTIGRAVITY',
|
|
ide_version: discoveryVersion,
|
|
ide_name: 'geminiNexus',
|
|
},
|
|
}),
|
|
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
|
...fetchOptions,
|
|
});
|
|
|
|
if (loadAssistResponse.ok) {
|
|
const loadAssistData = (await loadAssistResponse.json()) as LoadProjectResponse;
|
|
const credits = extractAiCreditsFromProjectContext(loadAssistData);
|
|
if (credits) {
|
|
return credits;
|
|
}
|
|
}
|
|
|
|
// Fallback to the legacy fetchCredits endpoint if loadCodeAssist
|
|
// did not return usable credit data.
|
|
const response = await fetch(URLS.FETCH_CREDITS, {
|
|
method: 'POST',
|
|
headers: buildInternalApiHeaders(accessToken),
|
|
body: JSON.stringify({}),
|
|
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
|
...fetchOptions,
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = (await response.json()) as Partial<{
|
|
credits: number | string;
|
|
remainingCredits: number | string;
|
|
expiryDate: string;
|
|
expirationDate: string;
|
|
}>;
|
|
|
|
return toAiCredits(data);
|
|
}
|
|
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Core logic: Fetches detailed model quota information.
|
|
*/
|
|
static async fetchQuota(accessToken: string, proxyUrl?: string): Promise<QuotaData> {
|
|
const { projectId, subscriptionTier } = await this.fetchProjectContext(accessToken, proxyUrl);
|
|
|
|
const payload: Record<string, unknown> = projectId ? { project: projectId } : {};
|
|
let lastError: Error | null = null;
|
|
const fetchOptions = this.getFetchOptions(proxyUrl);
|
|
|
|
for (let endpointIndex = 0; endpointIndex < QUOTA_API_ENDPOINTS.length; endpointIndex++) {
|
|
const endpoint = QUOTA_API_ENDPOINTS[endpointIndex];
|
|
const hasNextEndpoint = endpointIndex + 1 < QUOTA_API_ENDPOINTS.length;
|
|
|
|
try {
|
|
const response = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: buildInternalApiHeaders(accessToken),
|
|
body: JSON.stringify(payload),
|
|
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
|
...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}`;
|
|
if (hasNextEndpoint && (status === 429 || status >= 500)) {
|
|
logger.warn(
|
|
`[GoogleAPIService] Quota API ${endpoint} returned ${status}, falling back to next endpoint`,
|
|
);
|
|
lastError = new Error(errorMsg);
|
|
|
|
await sleep(1000);
|
|
continue;
|
|
}
|
|
|
|
throw new Error(errorMsg);
|
|
}
|
|
|
|
const data = (await response.json()) as FetchModelsResponse;
|
|
const result: QuotaData = {
|
|
models: {},
|
|
subscription_tier: subscriptionTier,
|
|
is_forbidden: false,
|
|
};
|
|
|
|
for (const [modelName, modelInfoRaw] of Object.entries(data.models || {})) {
|
|
const modelQuota = toModelQuotaInfo(modelName, modelInfoRaw);
|
|
if (modelQuota) {
|
|
result.models[modelName] = modelQuota;
|
|
}
|
|
}
|
|
|
|
const modelForwardingRules = toModelForwardingRules(data.deprecatedModelIds);
|
|
if (modelForwardingRules) {
|
|
result.model_forwarding_rules = modelForwardingRules;
|
|
}
|
|
|
|
if (endpointIndex > 0) {
|
|
logger.info(
|
|
`[GoogleAPIService] Quota API fallback succeeded at endpoint #${endpointIndex + 1}`,
|
|
);
|
|
}
|
|
|
|
return result;
|
|
} catch (error: unknown) {
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
const isPermanentHttp4xx =
|
|
/^HTTP 4\d{2}\b/.test(errorMsg) && !errorMsg.startsWith('HTTP 429');
|
|
|
|
// Abort retries for auth errors
|
|
if (errorMsg === 'FORBIDDEN' || errorMsg === 'UNAUTHORIZED') {
|
|
throw error;
|
|
}
|
|
|
|
if (hasNextEndpoint && !isPermanentHttp4xx) {
|
|
logger.warn(
|
|
`[GoogleAPIService] Quota API request failed at ${endpoint}: ${errorMsg}. Falling back to next endpoint`,
|
|
);
|
|
await sleep(1000);
|
|
continue;
|
|
}
|
|
|
|
throw lastError;
|
|
}
|
|
}
|
|
|
|
throw lastError || new Error('Quota check failed');
|
|
}
|
|
}
|