bardd's picture
Fix gemini-3.1-pro-high by routing to pro-low with thinkingLevel high.
4badc3b
Raw
History Blame Contribute Delete
14.5 kB
/**
* Credentials Management
*
* Handles OAuth token handling and project discovery.
*/
import {
ANTIGRAVITY_DB_PATH,
TOKEN_REFRESH_INTERVAL_MS,
LOAD_CODE_ASSIST_ENDPOINTS,
LOAD_CODE_ASSIST_HEADERS,
CLIENT_METADATA,
DEFAULT_PROJECT_ID
} from '../constants.js';
import { refreshAccessToken, parseRefreshParts, formatRefreshParts } from '../auth/oauth.js';
import { getAuthStatus } from '../auth/database.js';
import { logger } from '../utils/logger.js';
import { isNetworkError, throttledFetch } from '../utils/helpers.js';
import { onboardUser, getDefaultTierId } from './onboarding.js';
import { parseTierId } from '../cloudcode/model-api.js';
// Track accounts currently fetching subscription to avoid duplicate calls
const subscriptionFetchInProgress = new Set();
/**
* Fetch subscription tier and save it (blocking)
* Used when we have a cached project but missing subscription data
*
* @param {string} token - OAuth access token
* @param {Object} account - Account object
* @param {Function} [onSave] - Callback to save account changes
*/
async function fetchAndSaveSubscription(token, account, onSave) {
// Avoid duplicate fetches for the same account
if (subscriptionFetchInProgress.has(account.email)) {
return;
}
subscriptionFetchInProgress.add(account.email);
try {
// Call discoverProject just to get subscription info
const { subscription } = await discoverProject(token, account.projectId);
if (subscription && subscription.tier !== 'unknown') {
account.subscription = subscription;
if (onSave) {
await onSave();
}
logger.info(`[AccountManager] Updated subscription tier for ${account.email}: ${subscription.tier}`);
}
} catch (e) {
logger.debug(`[AccountManager] Subscription fetch failed for ${account.email}: ${e.message}`);
} finally {
subscriptionFetchInProgress.delete(account.email);
}
}
/**
* Get OAuth token for an account
*
* @param {Object} account - Account object with email and credentials
* @param {Map} tokenCache - Token cache map
* @param {Function} onInvalid - Callback when account is invalid (email, reason)
* @param {Function} onSave - Callback to save changes
* @returns {Promise<string>} OAuth access token
* @throws {Error} If token refresh fails
*/
export async function getTokenForAccount(account, tokenCache, onInvalid, onSave) {
// Check cache first
const cached = tokenCache.get(account.email);
if (cached && (Date.now() - cached.extractedAt) < TOKEN_REFRESH_INTERVAL_MS) {
return cached.token;
}
// Get fresh token based on source
let token;
if (account.source === 'oauth' && account.refreshToken) {
// OAuth account - use refresh token to get new access token
try {
const tokens = await refreshAccessToken(account.refreshToken);
token = tokens.accessToken;
// Clear invalid flag on success
if (account.isInvalid) {
account.isInvalid = false;
account.invalidReason = null;
if (onSave) await onSave();
}
logger.success(`[AccountManager] Refreshed OAuth token for: ${account.email}`);
} catch (error) {
// Check if it's a transient network error
if (isNetworkError(error)) {
logger.warn(`[AccountManager] Failed to refresh token for ${account.email} due to network error: ${error.message}`);
// Do NOT mark as invalid, just throw so caller knows it failed
throw new Error(`AUTH_NETWORK_ERROR: ${error.message}`);
}
logger.error(`[AccountManager] Failed to refresh token for ${account.email}:`, error.message);
// Mark account as invalid (credentials need re-auth)
if (onInvalid) onInvalid(account.email, error.message);
throw new Error(`AUTH_INVALID: ${account.email}: ${error.message}`);
}
} else if (account.source === 'manual' && account.apiKey) {
token = account.apiKey;
} else {
// Extract from database
const dbPath = account.dbPath || ANTIGRAVITY_DB_PATH;
const authData = getAuthStatus(dbPath);
token = authData.apiKey;
}
// Cache the token
tokenCache.set(account.email, {
token,
extractedAt: Date.now()
});
return token;
}
/**
* Get project ID for an account
* Aligned with opencode-antigravity-auth: parses refresh token for stored project IDs
*
* @param {Object} account - Account object
* @param {string} token - OAuth access token
* @param {Map} projectCache - Project cache map
* @param {Function} [onSave] - Callback to save account changes
* @returns {Promise<string>} Project ID
*/
export async function getProjectForAccount(account, token, projectCache, onSave = null) {
// Check cache first
const cached = projectCache.get(account.email);
if (cached) {
return cached;
}
// Parse refresh token to get stored project IDs (aligned with opencode-antigravity-auth)
const parts = account.refreshToken ? parseRefreshParts(account.refreshToken) : { refreshToken: null, projectId: undefined, managedProjectId: undefined };
// If we have a managedProjectId in the refresh token, use it
if (parts.managedProjectId) {
projectCache.set(account.email, parts.managedProjectId);
// If subscription is missing/unknown, fetch it now (blocking)
if (!account.subscription || account.subscription.tier === 'unknown') {
await fetchAndSaveSubscription(token, account, onSave);
}
return parts.managedProjectId;
}
// Legacy: check account.projectId for backward compatibility
if (account.projectId) {
projectCache.set(account.email, account.projectId);
// If subscription is missing/unknown, fetch it now (blocking)
if (!account.subscription || account.subscription.tier === 'unknown') {
await fetchAndSaveSubscription(token, account, onSave);
}
return account.projectId;
}
// Discover managed project, passing projectId for metadata.duetProject
// Reference: opencode-antigravity-auth - discoverProject handles fallback internally
const { project, subscription } = await discoverProject(token, parts.projectId);
// Store managedProjectId back in refresh token (if we got a real project)
if (project && project !== DEFAULT_PROJECT_ID) {
let needsSave = false;
if (account.refreshToken) {
// OAuth accounts: encode in refresh token
account.refreshToken = formatRefreshParts({
refreshToken: parts.refreshToken,
projectId: parts.projectId,
managedProjectId: project,
});
needsSave = true;
} else if (account.source === 'database' || account.source === 'manual') {
// Database/manual accounts: store in projectId field
account.projectId = project;
needsSave = true;
}
// Save subscription tier if discovered
if (subscription) {
account.subscription = subscription;
needsSave = true;
}
// Trigger save to persist the updated project and subscription
if (needsSave && onSave) {
try {
await onSave();
} catch (e) {
logger.warn(`[AccountManager] Failed to save updated project: ${e.message}`);
}
}
} else if (subscription) {
// Even if no project discovered, save subscription if we got it
account.subscription = subscription;
if (onSave) {
try {
await onSave();
} catch (e) {
logger.warn(`[AccountManager] Failed to save subscription: ${e.message}`);
}
}
}
projectCache.set(account.email, project);
return project;
}
/**
* Discover project ID via Cloud Code API
*
* @param {string} token - OAuth access token
* @param {string} [projectId] - Optional project ID from refresh token (for metadata.duetProject)
* @returns {Promise<{project: string, subscription: {tier: string, projectId: string|null, detectedAt: string}|null}>} Project and subscription info
*/
export async function discoverProject(token, projectId = undefined) {
let lastError = null;
let gotSuccessfulResponse = false;
let loadCodeAssistData = null;
const metadata = { ...CLIENT_METADATA };
if (projectId) {
metadata.duetProject = projectId;
}
for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) {
try {
const response = await throttledFetch(`${endpoint}/v1internal:loadCodeAssist`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...LOAD_CODE_ASSIST_HEADERS
},
body: JSON.stringify({ metadata, mode: 1 })
});
if (!response.ok) {
const errorText = await response.text();
lastError = `${response.status} - ${errorText}`;
logger.debug(`[AccountManager] loadCodeAssist failed at ${endpoint}: ${lastError}`);
continue;
}
const data = await response.json();
gotSuccessfulResponse = true;
loadCodeAssistData = data;
logger.debug(`[AccountManager] loadCodeAssist response from ${endpoint}:`, JSON.stringify(data));
// Extract subscription tier from response
const subscription = extractSubscriptionFromResponse(data);
if (typeof data.cloudaicompanionProject === 'string') {
logger.success(`[AccountManager] Discovered project: ${data.cloudaicompanionProject}`);
return { project: data.cloudaicompanionProject, subscription };
}
if (data.cloudaicompanionProject?.id) {
logger.success(`[AccountManager] Discovered project: ${data.cloudaicompanionProject.id}`);
return { project: data.cloudaicompanionProject.id, subscription };
}
// No project found - log tier data and try to onboard the user
logger.info(`[AccountManager] No project in loadCodeAssist response, attempting onboardUser...`);
logger.debug(`[AccountManager] Tier data for onboarding: paidTier=${JSON.stringify(data.paidTier)}, currentTier=${JSON.stringify(data.currentTier)}, allowedTiers=${JSON.stringify(data.allowedTiers?.map(t => ({ id: t?.id, isDefault: t?.isDefault })))}`);
break;
} catch (error) {
lastError = error.message;
logger.debug(`[AccountManager] loadCodeAssist error at ${endpoint}:`, error.message);
}
}
// If we got a successful response but no project, try onboarding
if (gotSuccessfulResponse && loadCodeAssistData) {
// Only use allowedTiers for onboarding (matching opencode-antigravity-auth and oauth.js)
// Note: paidTier (g1-pro-tier, g1-ultra-tier) is NOT valid for onboardUser API
// The paidTier is used for subscription detection only, not for onboarding
const tierId = getDefaultTierId(loadCodeAssistData.allowedTiers) || 'free-tier';
logger.info(`[AccountManager] Onboarding user with tier: ${tierId}`);
// Pass projectId for metadata.duetProject (without fallback, matching reference)
// Reference: opencode-antigravity-auth passes parts.projectId (not fallback) to onboardManagedProject
const onboardedProject = await onboardUser(
token,
tierId,
projectId // Original projectId without fallback
);
if (onboardedProject) {
logger.success(`[AccountManager] Successfully onboarded, project: ${onboardedProject}`);
const subscription = extractSubscriptionFromResponse(loadCodeAssistData);
return { project: onboardedProject, subscription };
}
logger.warn(`[AccountManager] Onboarding failed - account may not work correctly`);
}
// Only warn if all endpoints failed with errors (not just missing project)
if (!gotSuccessfulResponse) {
logger.warn(`[AccountManager] loadCodeAssist failed for all endpoints: ${lastError}`);
}
// Fallback: use projectId if available, otherwise use default
// Reference: opencode-antigravity-auth/src/plugin/project.ts
if (projectId) {
return { project: projectId, subscription: null };
}
return { project: DEFAULT_PROJECT_ID, subscription: null };
}
/**
* Extract subscription tier from loadCodeAssist response
*
* @param {Object} data - loadCodeAssist response data
* @returns {{tier: string, projectId: string|null, detectedAt: string}|null} Subscription info
*/
function extractSubscriptionFromResponse(data) {
if (!data) return null;
// Priority: paidTier > currentTier (consistent with model-api.js)
let tier = 'free';
let cloudProject = null;
if (data.paidTier?.id) {
tier = parseTierId(data.paidTier.id);
} else if (data.currentTier?.id) {
tier = parseTierId(data.currentTier.id);
}
// Get project ID
if (typeof data.cloudaicompanionProject === 'string') {
cloudProject = data.cloudaicompanionProject;
} else if (data.cloudaicompanionProject?.id) {
cloudProject = data.cloudaicompanionProject.id;
}
return {
tier,
projectId: cloudProject,
detectedAt: new Date().toISOString()
};
}
/**
* Clear project cache for an account
*
* @param {Map} projectCache - Project cache map
* @param {string|null} email - Email to clear cache for, or null to clear all
*/
export function clearProjectCache(projectCache, email = null) {
if (email) {
projectCache.delete(email);
} else {
projectCache.clear();
}
}
/**
* Clear token cache for an account
*
* @param {Map} tokenCache - Token cache map
* @param {string|null} email - Email to clear cache for, or null to clear all
*/
export function clearTokenCache(tokenCache, email = null) {
if (email) {
tokenCache.delete(email);
} else {
tokenCache.clear();
}
}