mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 00:17:47 +00:00
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:
+117
-13
@@ -44,6 +44,14 @@ export interface QuotaResult {
|
|||||||
const ANTIGRAVITY_API_BASE = 'https://cloudcode-pa.googleapis.com';
|
const ANTIGRAVITY_API_BASE = 'https://cloudcode-pa.googleapis.com';
|
||||||
const ANTIGRAVITY_API_VERSION = 'v1internal';
|
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 */
|
/** API client headers */
|
||||||
const ANTIGRAVITY_HEADERS = {
|
const ANTIGRAVITY_HEADERS = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -66,11 +74,21 @@ interface AntigravityAuthFile {
|
|||||||
/** Auth data returned from file */
|
/** Auth data returned from file */
|
||||||
interface AuthData {
|
interface AuthData {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
refreshToken: string | null;
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
isExpired: boolean;
|
isExpired: boolean;
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Token refresh response */
|
||||||
|
interface TokenRefreshResponse {
|
||||||
|
access_token?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
token_type?: string;
|
||||||
|
error?: string;
|
||||||
|
error_description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** loadCodeAssist response */
|
/** loadCodeAssist response */
|
||||||
interface LoadCodeAssistResponse {
|
interface LoadCodeAssistResponse {
|
||||||
cloudaicompanionProject?: string | { id?: string };
|
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)
|
* 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;
|
if (!data.access_token) return null;
|
||||||
return {
|
return {
|
||||||
accessToken: data.access_token,
|
accessToken: data.access_token,
|
||||||
|
refreshToken: data.refresh_token || null,
|
||||||
projectId: data.project_id || null,
|
projectId: data.project_id || null,
|
||||||
isExpired: isTokenExpired(data.expired),
|
isExpired: isTokenExpired(data.expired),
|
||||||
expiresAt: data.expired || null,
|
expiresAt: data.expired || null,
|
||||||
@@ -168,6 +237,7 @@ function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData |
|
|||||||
if (data.email === accountId && data.access_token) {
|
if (data.email === accountId && data.access_token) {
|
||||||
return {
|
return {
|
||||||
accessToken: data.access_token,
|
accessToken: data.access_token,
|
||||||
|
refreshToken: data.refresh_token || null,
|
||||||
projectId: data.project_id || null,
|
projectId: data.project_id || null,
|
||||||
isExpired: isTokenExpired(data.expired),
|
isExpired: isTokenExpired(data.expired),
|
||||||
expiresAt: data.expired || null,
|
expiresAt: data.expired || null,
|
||||||
@@ -377,26 +447,60 @@ export async function fetchAccountQuota(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: We don't check isExpired here because:
|
// Determine which access token to use
|
||||||
// 1. CLIProxyAPIPlus refreshes tokens at runtime but doesn't persist to disk
|
// File-based token is often stale (CLIProxyAPIPlus refreshes at runtime but doesn't persist)
|
||||||
// 2. File-based expiration is always stale and misleading
|
// If we have refresh_token, proactively refresh to get a fresh access_token
|
||||||
// 3. If token is truly invalid, the API call below will fail with 401
|
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
|
// Get project ID - prefer stored value, fallback to API call
|
||||||
let projectId = authData.projectId;
|
let projectId = authData.projectId;
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
const projectResult = await getProjectId(authData.accessToken);
|
const projectResult = await getProjectId(accessToken);
|
||||||
if (!projectResult.projectId) {
|
if (!projectResult.projectId) {
|
||||||
return {
|
// If project ID fetch fails, it might be token issue - try refresh if we haven't
|
||||||
success: false,
|
if (authData.refreshToken && accessToken === authData.accessToken) {
|
||||||
models: [],
|
const refreshResult = await refreshAccessToken(authData.refreshToken);
|
||||||
lastUpdated: Date.now(),
|
if (refreshResult.accessToken) {
|
||||||
error: projectResult.error || 'Failed to retrieve project ID',
|
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
|
// 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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user