feat(cloud-oauth): support selectable google oauth clients
This commit is contained in:
1 parent
385036d577
commit
bcdf2a6c24
8 files changed
+1370
-125
No files matched your search
@@ -1,33 +1,207 @@
|
||||
import { z } from 'zod';
|
||||
import { ConfigManager } from '../ipc/config/manager';
|
||||
import { AuthServer } from '../ipc/cloud/authServer';
|
||||
import { ProxyAgent } from 'undici';
|
||||
import { EnvHttpProxyAgent, ProxyAgent } from 'undici';
|
||||
import { logger } from '../utils/logger';
|
||||
import {
|
||||
buildUserAgent,
|
||||
FALLBACK_VERSION,
|
||||
resolveLocalInstalledVersion,
|
||||
} from '@/server/modules/proxy/request-user-agent';
|
||||
import { isNumber, isString } 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 = 'antigravity_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',
|
||||
QUOTA: 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
|
||||
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;
|
||||
|
||||
// Internal API masquerading
|
||||
const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64';
|
||||
|
||||
// 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: 'Antigravity Enterprise',
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
is_builtin: true,
|
||||
},
|
||||
];
|
||||
|
||||
const rawExtraClients = process.env[OAUTH_CLIENTS_ENV];
|
||||
if (isString(rawExtraClients) && 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 (clientKey && 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.
|
||||
*/
|
||||
@@ -45,6 +219,15 @@ export interface TokenResponse {
|
||||
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 {
|
||||
@@ -52,19 +235,19 @@ export interface UserInfo {
|
||||
email: string;
|
||||
verified_email: boolean;
|
||||
name: string;
|
||||
given_name: string;
|
||||
family_name: string;
|
||||
picture: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
picture?: string;
|
||||
}
|
||||
|
||||
export const UserInfoSchema = z.object({
|
||||
id: z.string(),
|
||||
email: z.string(),
|
||||
verified_email: z.boolean(),
|
||||
name: z.string(),
|
||||
given_name: z.string(),
|
||||
family_name: z.string(),
|
||||
picture: 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 {
|
||||
@@ -118,6 +301,13 @@ interface TierRaw {
|
||||
quotaTier?: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
availableCredits?: AvailableCreditRaw[];
|
||||
}
|
||||
|
||||
interface AvailableCreditRaw {
|
||||
creditType?: string;
|
||||
creditAmount?: string | number;
|
||||
minimumCreditAmountForUsage?: string | number;
|
||||
}
|
||||
|
||||
interface LoadProjectResponse {
|
||||
@@ -228,11 +418,129 @@ function toModelForwardingRules(
|
||||
return Object.keys(forwardingRules).length > 0 ? forwardingRules : undefined;
|
||||
}
|
||||
|
||||
function parseCreditAmount(value: string | number | undefined): number {
|
||||
if (isNumber(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
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 (typeof creditsValue === 'undefined') {
|
||||
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 =
|
||||
typeof currentToken.project_id !== 'string' || currentToken.project_id.trim() === '';
|
||||
|
||||
if (
|
||||
typeof currentToken.oauth_client_key !== 'string' &&
|
||||
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),
|
||||
};
|
||||
@@ -243,6 +551,9 @@ export class GoogleAPIService {
|
||||
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),
|
||||
};
|
||||
@@ -251,23 +562,62 @@ export class GoogleAPIService {
|
||||
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(): string {
|
||||
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: CLIENT_ID,
|
||||
client_id: oauthClient.client_id,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: scopes,
|
||||
@@ -282,78 +632,121 @@ export class GoogleAPIService {
|
||||
/**
|
||||
* Exchanges an authorization code for tokens.
|
||||
*/
|
||||
static async exchangeCode(code: string, proxyUrl?: string): Promise<TokenResponse> {
|
||||
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 params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
});
|
||||
const attemptErrors: string[] = [];
|
||||
|
||||
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) {
|
||||
if (err.name === 'AbortError') {
|
||||
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;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Token exchange failed: ${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}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<TokenResponse>;
|
||||
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): Promise<TokenResponse> {
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: '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 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) {
|
||||
if (err.name === 'AbortError') {
|
||||
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;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Token refresh failed: ${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}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as TokenResponse;
|
||||
|
||||
return data;
|
||||
throw new Error(`Token refresh failed for all OAuth clients: ${attemptErrors.join(' | ')}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -382,7 +775,13 @@ export class GoogleAPIService {
|
||||
|
||||
const data = await response.json();
|
||||
try {
|
||||
return UserInfoSchema.parse(data);
|
||||
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');
|
||||
@@ -451,20 +850,55 @@ export class GoogleAPIService {
|
||||
proxyUrl?: string,
|
||||
): Promise<{ credits: number; expiryDate: string } | null> {
|
||||
try {
|
||||
const response = await fetch('https://cloudcode-pa.googleapis.com/v1internal:fetchCredits', {
|
||||
const fetchOptions = this.getFetchOptions(proxyUrl);
|
||||
const response = await fetch(URLS.FETCH_CREDITS, {
|
||||
method: 'POST',
|
||||
headers: buildInternalApiHeaders(accessToken),
|
||||
body: JSON.stringify({}),
|
||||
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||
...this.getFetchOptions(proxyUrl),
|
||||
...fetchOptions,
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return {
|
||||
credits: data?.credits ?? data?.remainingCredits ?? 0,
|
||||
expiryDate: data?.expiryDate ?? data?.expirationDate ?? '',
|
||||
};
|
||||
} catch {
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as Partial<{
|
||||
credits: number | string;
|
||||
remainingCredits: number | string;
|
||||
expiryDate: string;
|
||||
expirationDate: string;
|
||||
}>;
|
||||
|
||||
return toAiCredits(data);
|
||||
}
|
||||
|
||||
if (response.status !== 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// `fetchCredits` has started returning 404 for some accounts. Reuse the
|
||||
// internal `loadCodeAssist` payload, which also exposes paid tier credits.
|
||||
logger.warn('[GoogleAPIService] fetchCredits returned 404, falling back to loadCodeAssist');
|
||||
const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
|
||||
const fallbackResponse = await fetch(URLS.DAILY_LOAD_PROJECT, {
|
||||
method: 'POST',
|
||||
headers: buildInternalApiHeaders(accessToken),
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ide_type: 'ANTIGRAVITY',
|
||||
ide_version: discoveryVersion,
|
||||
ide_name: 'antigravity',
|
||||
},
|
||||
}),
|
||||
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||
...fetchOptions,
|
||||
});
|
||||
|
||||
if (!fallbackResponse.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackData = (await fallbackResponse.json()) as LoadProjectResponse;
|
||||
return extractAiCreditsFromProjectContext(fallbackData);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -475,18 +909,16 @@ export class GoogleAPIService {
|
||||
static async fetchQuota(accessToken: string, proxyUrl?: string): Promise<QuotaData> {
|
||||
const { projectId, subscriptionTier } = await this.fetchProjectContext(accessToken, proxyUrl);
|
||||
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (projectId) {
|
||||
payload['project'] = projectId;
|
||||
}
|
||||
|
||||
const maxRetries = 3;
|
||||
const payload: Record<string, unknown> = projectId ? { project: projectId } : {};
|
||||
let lastError: Error | null = null;
|
||||
const fetchOptions = this.getFetchOptions(proxyUrl);
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
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(URLS.QUOTA, {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: buildInternalApiHeaders(accessToken),
|
||||
body: JSON.stringify(payload),
|
||||
@@ -506,17 +938,17 @@ export class GoogleAPIService {
|
||||
}
|
||||
|
||||
const errorMsg = `HTTP ${status} - ${text}`;
|
||||
logger.warn(
|
||||
`[GoogleAPIService] API Error: ${errorMsg} (Attempt ${attempt}/${maxRetries})`,
|
||||
);
|
||||
if (hasNextEndpoint && (status === 429 || status >= 500)) {
|
||||
logger.warn(
|
||||
`[GoogleAPIService] Quota API ${endpoint} returned ${status}, falling back to next endpoint`,
|
||||
);
|
||||
lastError = new Error(errorMsg);
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
const backoffMs = 1000 * Math.pow(2, attempt - 1);
|
||||
await sleep(backoffMs);
|
||||
await sleep(1000);
|
||||
continue;
|
||||
} else {
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as FetchModelsResponse;
|
||||
@@ -538,26 +970,33 @@ export class GoogleAPIService {
|
||||
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);
|
||||
if (error instanceof Error) {
|
||||
logger.warn(
|
||||
`[GoogleAPIService] Request failed: ${error.message} (Attempt ${attempt}/${maxRetries})`,
|
||||
);
|
||||
}
|
||||
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 (attempt < maxRetries) {
|
||||
const backoffMs = 1000 * Math.pow(2, attempt - 1);
|
||||
await sleep(backoffMs);
|
||||
if (hasNextEndpoint && !isPermanentHttp4xx) {
|
||||
logger.warn(
|
||||
`[GoogleAPIService] Quota API request failed at ${endpoint}: ${errorMsg}. Falling back to next endpoint`,
|
||||
);
|
||||
await sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user