/** * Model API for Cloud Code * * Handles model listing and quota retrieval from the Cloud Code API. */ import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_HEADERS, LOAD_CODE_ASSIST_ENDPOINTS, LOAD_CODE_ASSIST_HEADERS, CLIENT_METADATA, getModelFamily, MODEL_VALIDATION_CACHE_TTL_MS } from '../constants.js'; import { logger } from '../utils/logger.js'; import { throttledFetch } from '../utils/helpers.js'; // Model validation cache const modelCache = { validModels: new Set(), lastFetched: 0, fetchPromise: null // Prevents concurrent fetches }; /** * Check if a model is supported (Claude or Gemini) * @param {string} modelId - Model ID to check * @returns {boolean} True if model is supported */ function isSupportedModel(modelId) { const family = getModelFamily(modelId); return family === 'claude' || family === 'gemini'; } /** * List available models in Anthropic API format * Fetches models dynamically from the Cloud Code API * * @param {string} token - OAuth access token * @returns {Promise<{object: string, data: Array<{id: string, object: string, created: number, owned_by: string, description: string}>}>} List of available models */ export async function listModels(token) { const data = await fetchAvailableModels(token); if (!data || !data.models) { return { object: 'list', data: [] }; } const modelList = Object.entries(data.models) .filter(([modelId]) => isSupportedModel(modelId)) .map(([modelId, modelData]) => ({ id: modelId, object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'anthropic', description: modelData.displayName || modelId })); // Warm the model validation cache modelCache.validModels = new Set(modelList.map(m => m.id)); modelCache.lastFetched = Date.now(); return { object: 'list', data: modelList }; } /** * Fetch available models with quota info from Cloud Code API * Returns model quotas including remaining fraction and reset time * * @param {string} token - OAuth access token * @param {string} [projectId] - Optional project ID for accurate quota info * @returns {Promise} Raw response from fetchAvailableModels API */ export async function fetchAvailableModels(token, projectId = null) { const headers = { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', ...ANTIGRAVITY_HEADERS }; // Include project ID in body for accurate quota info (per Quotio implementation) const body = projectId ? { project: projectId } : {}; for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) { try { const url = `${endpoint}/v1internal:fetchAvailableModels`; const response = await throttledFetch(url, { method: 'POST', headers, body: JSON.stringify(body) }); if (!response.ok) { const errorText = await response.text(); logger.warn(`[CloudCode] fetchAvailableModels error at ${endpoint}: ${response.status}`); // Detect permanent ToS ban — no point trying other endpoints if (response.status === 403) { const lower = (errorText || '').toLowerCase(); if (lower.includes('has been disabled') && lower.includes('violation of terms of service')) { throw new Error(`ACCOUNT_BANNED: ${errorText}`); } } continue; } return await response.json(); } catch (error) { logger.warn(`[CloudCode] fetchAvailableModels failed at ${endpoint}:`, error.message); } } throw new Error('Failed to fetch available models from all endpoints'); } /** * Get model quotas for an account * Extracts quota info (remaining fraction and reset time) for each model * * @param {string} token - OAuth access token * @param {string} [projectId] - Optional project ID for accurate quota info * @returns {Promise} Map of modelId -> { remainingFraction, resetTime } */ export async function getModelQuotas(token, projectId = null) { const data = await fetchAvailableModels(token, projectId); if (!data || !data.models) return {}; const quotas = {}; for (const [modelId, modelData] of Object.entries(data.models)) { // Only include Claude and Gemini models if (!isSupportedModel(modelId)) continue; if (modelData.quotaInfo) { quotas[modelId] = { // When remainingFraction is missing but resetTime is present, quota is exhausted (0%) remainingFraction: modelData.quotaInfo.remainingFraction ?? (modelData.quotaInfo.resetTime ? 0 : null), resetTime: modelData.quotaInfo.resetTime ?? null }; } } return quotas; } /** * Parse tier ID string to determine subscription level * @param {string} tierId - The tier ID from the API * @returns {'free' | 'pro' | 'ultra' | 'unknown'} The subscription tier */ export function parseTierId(tierId) { if (!tierId) return 'unknown'; const lower = tierId.toLowerCase(); if (lower.includes('ultra')) { return 'ultra'; } if (lower === 'standard-tier') { // standard-tier = "Gemini Code Assist" (paid, project-based) return 'pro'; } if (lower.includes('pro') || lower.includes('premium')) { return 'pro'; } if (lower === 'free-tier' || lower.includes('free')) { return 'free'; } return 'unknown'; } /** * Get subscription tier for an account * Calls loadCodeAssist API to discover project ID and subscription tier * * @param {string} token - OAuth access token * @returns {Promise<{tier: string, projectId: string|null}>} Subscription tier (free/pro/ultra) and project ID */ export async function getSubscriptionTier(token) { const headers = { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', ...LOAD_CODE_ASSIST_HEADERS }; for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) { try { const url = `${endpoint}/v1internal:loadCodeAssist`; const response = await throttledFetch(url, { method: 'POST', headers, body: JSON.stringify({ metadata: CLIENT_METADATA, mode: 1 }) }); if (!response.ok) { const errorText = await response.text().catch(() => ''); logger.warn(`[CloudCode] loadCodeAssist error at ${endpoint}: ${response.status}`); // Detect permanent ToS ban — no point trying other endpoints if (response.status === 403) { const lower = (errorText || '').toLowerCase(); if (lower.includes('has been disabled') && lower.includes('violation of terms of service')) { throw new Error(`ACCOUNT_BANNED: ${errorText}`); } } continue; } const data = await response.json(); // Debug: Log all tier-related fields from the response logger.debug(`[CloudCode] loadCodeAssist tier data: paidTier=${JSON.stringify(data.paidTier)}, currentTier=${JSON.stringify(data.currentTier)}, allowedTiers=${JSON.stringify(data.allowedTiers?.map(t => ({ id: t?.id, isDefault: t?.isDefault })))}`); // Extract project ID let projectId = null; if (typeof data.cloudaicompanionProject === 'string') { projectId = data.cloudaicompanionProject; } else if (data.cloudaicompanionProject?.id) { projectId = data.cloudaicompanionProject.id; } // Extract subscription tier // Priority: paidTier > currentTier > allowedTiers // - paidTier.id: "g1-pro-tier", "g1-ultra-tier" (Google One subscription) // - currentTier.id: "standard-tier" (pro), "free-tier" (free) // - allowedTiers: fallback when currentTier is missing // Note: paidTier is sometimes missing from the response even for Pro accounts let tier = 'unknown'; let tierId = null; let tierSource = null; // 1. Check paidTier first (Google One AI subscription - most reliable) if (data.paidTier?.id) { tierId = data.paidTier.id; tier = parseTierId(tierId); tierSource = 'paidTier'; } // 2. Fall back to currentTier if paidTier didn't give us a tier if (tier === 'unknown' && data.currentTier?.id) { tierId = data.currentTier.id; tier = parseTierId(tierId); tierSource = 'currentTier'; } // 3. Fall back to allowedTiers (find the default or first non-free tier) if (tier === 'unknown' && Array.isArray(data.allowedTiers) && data.allowedTiers.length > 0) { // First look for the default tier let defaultTier = data.allowedTiers.find(t => t?.isDefault); if (!defaultTier) { defaultTier = data.allowedTiers[0]; } if (defaultTier?.id) { tierId = defaultTier.id; tier = parseTierId(tierId); tierSource = 'allowedTiers'; } } logger.debug(`[CloudCode] Subscription detected: ${tier} (tierId: ${tierId}, source: ${tierSource}), Project: ${projectId}`); return { tier, projectId }; } catch (error) { logger.warn(`[CloudCode] loadCodeAssist failed at ${endpoint}:`, error.message); } } // Fallback: return default values if all endpoints fail logger.warn('[CloudCode] Failed to detect subscription tier from all endpoints. Defaulting to free.'); return { tier: 'free', projectId: null }; } /** * Populate the model validation cache * @param {string} token - OAuth access token * @param {string} [projectId] - Optional project ID * @returns {Promise} */ async function populateModelCache(token, projectId = null) { const now = Date.now(); // Check if cache is fresh if (modelCache.validModels.size > 0 && (now - modelCache.lastFetched) < MODEL_VALIDATION_CACHE_TTL_MS) { return; } // If already fetching, wait for it if (modelCache.fetchPromise) { await modelCache.fetchPromise; return; } // Start fetch modelCache.fetchPromise = (async () => { try { const data = await fetchAvailableModels(token, projectId); if (data && data.models) { const validIds = Object.keys(data.models).filter(modelId => isSupportedModel(modelId)); modelCache.validModels = new Set(validIds); modelCache.lastFetched = Date.now(); logger.debug(`[CloudCode] Model cache populated with ${validIds.length} models`); } } catch (error) { logger.warn(`[CloudCode] Failed to populate model cache: ${error.message}`); // Don't throw - validation should degrade gracefully } finally { modelCache.fetchPromise = null; } })(); await modelCache.fetchPromise; } /** * Check if a model ID is valid (exists in the available models list) * Uses a cached model list with TTL-based refresh * @param {string} modelId - Model ID to validate * @param {string} token - OAuth access token for cache population * @param {string} [projectId] - Optional project ID * @returns {Promise} True if model is valid */ export async function isValidModel(modelId, token, projectId = null) { try { // Populate cache if needed await populateModelCache(token, projectId); // If cache is populated, validate against it if (modelCache.validModels.size > 0) { return modelCache.validModels.has(modelId); } // Cache empty (fetch failed) - fail open, let API validate return true; } catch (error) { logger.debug(`[CloudCode] Model validation error: ${error.message}`); // Fail open - let the API validate return true; } }