feat(quota): add OAuth token refresh for independent quota fetching

CCS can now fetch quota independently of CLIProxyAPI by refreshing
access tokens using the refresh_token stored in auth files.

- Add refreshAccessToken() using Google OAuth token endpoint
- Use public Antigravity OAuth credentials (from CLIProxyAPIPlus)
- Proactively refresh token before API calls to avoid stale tokens
- Fallback retry on auth errors

This fixes quota showing "Access token expired" for accounts whose
file-based access_token is stale (CLIProxyAPI refreshes at runtime
but doesn't persist to disk).
This commit is contained in:
kaitranntt
2025-12-29 13:25:14 -05:00
parent 739270aac4
commit 4be8e927a0
+117 -13
View File
@@ -44,6 +44,14 @@ export interface QuotaResult {
const ANTIGRAVITY_API_BASE = 'https://cloudcode-pa.googleapis.com';
const ANTIGRAVITY_API_VERSION = 'v1internal';
/** Google OAuth token endpoint */
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
/** Antigravity OAuth credentials (from CLIProxyAPIPlus - public in open-source code) */
const ANTIGRAVITY_CLIENT_ID =
'1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
const ANTIGRAVITY_CLIENT_SECRET = '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';
/** API client headers */
const ANTIGRAVITY_HEADERS = {
'Content-Type': 'application/json',
@@ -66,11 +74,21 @@ interface AntigravityAuthFile {
/** Auth data returned from file */
interface AuthData {
accessToken: string;
refreshToken: string | null;
projectId: string | null;
isExpired: boolean;
expiresAt: string | null;
}
/** Token refresh response */
interface TokenRefreshResponse {
access_token?: string;
expires_in?: number;
token_type?: string;
error?: string;
error_description?: string;
}
/** loadCodeAssist response */
interface LoadCodeAssistResponse {
cloudaicompanionProject?: string | { id?: string };
@@ -122,6 +140,56 @@ function isTokenExpired(expiredStr?: string): boolean {
}
}
/**
* Refresh access token using refresh_token via Google OAuth
* This allows CCS to get fresh tokens independently of CLIProxyAPI
*/
async function refreshAccessToken(
refreshToken: string
): Promise<{ accessToken: string | null; error?: string }> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(GOOGLE_TOKEN_URL, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: ANTIGRAVITY_CLIENT_ID,
client_secret: ANTIGRAVITY_CLIENT_SECRET,
}).toString(),
});
clearTimeout(timeoutId);
const data = (await response.json()) as TokenRefreshResponse;
if (!response.ok || data.error) {
return {
accessToken: null,
error: data.error_description || data.error || `OAuth error: ${response.status}`,
};
}
if (!data.access_token) {
return { accessToken: null, error: 'No access_token in response' };
}
return { accessToken: data.access_token };
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof Error && err.name === 'AbortError') {
return { accessToken: null, error: 'Token refresh timeout' };
}
return { accessToken: null, error: err instanceof Error ? err.message : 'Unknown error' };
}
}
/**
* Read auth data from auth file (access token, project_id, expiry status)
*/
@@ -147,6 +215,7 @@ function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData |
if (!data.access_token) return null;
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || null,
projectId: data.project_id || null,
isExpired: isTokenExpired(data.expired),
expiresAt: data.expired || null,
@@ -168,6 +237,7 @@ function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData |
if (data.email === accountId && data.access_token) {
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || null,
projectId: data.project_id || null,
isExpired: isTokenExpired(data.expired),
expiresAt: data.expired || null,
@@ -377,26 +447,60 @@ export async function fetchAccountQuota(
};
}
// Note: We don't check isExpired here because:
// 1. CLIProxyAPIPlus refreshes tokens at runtime but doesn't persist to disk
// 2. File-based expiration is always stale and misleading
// 3. If token is truly invalid, the API call below will fail with 401
// Determine which access token to use
// File-based token is often stale (CLIProxyAPIPlus refreshes at runtime but doesn't persist)
// If we have refresh_token, proactively refresh to get a fresh access_token
let accessToken = authData.accessToken;
if (authData.refreshToken) {
// Always refresh to ensure we have a valid token
// This is necessary because CLIProxyAPIPlus doesn't persist refreshed tokens
const refreshResult = await refreshAccessToken(authData.refreshToken);
if (refreshResult.accessToken) {
accessToken = refreshResult.accessToken;
}
// If refresh fails, fall back to existing token (might still work)
}
// Get project ID - prefer stored value, fallback to API call
let projectId = authData.projectId;
if (!projectId) {
const projectResult = await getProjectId(authData.accessToken);
const projectResult = await getProjectId(accessToken);
if (!projectResult.projectId) {
return {
success: false,
models: [],
lastUpdated: Date.now(),
error: projectResult.error || 'Failed to retrieve project ID',
};
// If project ID fetch fails, it might be token issue - try refresh if we haven't
if (authData.refreshToken && accessToken === authData.accessToken) {
const refreshResult = await refreshAccessToken(authData.refreshToken);
if (refreshResult.accessToken) {
accessToken = refreshResult.accessToken;
const retryResult = await getProjectId(accessToken);
if (retryResult.projectId) {
projectId = retryResult.projectId;
}
}
}
if (!projectId) {
return {
success: false,
models: [],
lastUpdated: Date.now(),
error: projectResult.error || 'Failed to retrieve project ID',
};
}
} else {
projectId = projectResult.projectId;
}
projectId = projectResult.projectId;
}
// Fetch models with quota
return fetchAvailableModels(authData.accessToken, projectId);
const result = await fetchAvailableModels(accessToken, projectId);
// If quota fetch fails with auth error and we haven't refreshed yet, try refresh
if (!result.success && result.error?.includes('expired') && authData.refreshToken) {
const refreshResult = await refreshAccessToken(authData.refreshToken);
if (refreshResult.accessToken) {
return fetchAvailableModels(refreshResult.accessToken, projectId);
}
}
return result;
}