/** * Multi-account authentication pool for Codeium/Windsurf. * * Features: * - Multiple accounts with round-robin load balancing * - Account health tracking (error count, auto-disable) * - Dynamic add/remove via API * - Token-based registration via api.codeium.com */ import { randomUUID } from 'crypto'; import { readFileSync, writeFileSync, existsSync } from 'fs'; import { config, log } from './config.js'; import { getEffectiveProxy } from './dashboard/proxy-config.js'; import { getTierModels, getModelKeysByEnum, MODELS } from './models.js'; import { join } from 'path'; const ACCOUNTS_FILE = join(process.cwd(), 'accounts.json'); // ─── Account pool ────────────────────────────────────────── const accounts = []; let _roundRobinIndex = 0; // Per-tier requests-per-minute limits. Used for both filter-by-cap and // weighted selection (accounts with more headroom are preferred). const TIER_RPM = { pro: 60, free: 10, unknown: 20, expired: 0 }; const RPM_WINDOW_MS = 60 * 1000; function rpmLimitFor(account) { return TIER_RPM[account.tier || 'unknown'] ?? 20; } function pruneRpmHistory(account, now) { if (!account._rpmHistory) account._rpmHistory = []; const cutoff = now - RPM_WINDOW_MS; while (account._rpmHistory.length && account._rpmHistory[0] < cutoff) { account._rpmHistory.shift(); } return account._rpmHistory.length; } function saveAccounts() { try { const data = accounts.map(a => ({ id: a.id, email: a.email, apiKey: a.apiKey, apiServerUrl: a.apiServerUrl, method: a.method, status: a.status, addedAt: a.addedAt, tier: a.tier, capabilities: a.capabilities, lastProbed: a.lastProbed, credits: a.credits || null, blockedModels: a.blockedModels || [], refreshToken: a.refreshToken || '', // From GetUserStatus — the authoritative tier/entitlement snapshot. userStatus: a.userStatus || null, userStatusLastFetched: a.userStatusLastFetched || 0, })); writeFileSync(ACCOUNTS_FILE, JSON.stringify(data, null, 2)); } catch (e) { log.error('Failed to save accounts:', e.message); } } function loadAccounts() { try { if (!existsSync(ACCOUNTS_FILE)) return; const data = JSON.parse(readFileSync(ACCOUNTS_FILE, 'utf-8')); for (const a of data) { if (accounts.find(x => x.apiKey === a.apiKey)) continue; accounts.push({ id: a.id || randomUUID().slice(0, 8), email: a.email, apiKey: a.apiKey, apiServerUrl: a.apiServerUrl || '', method: a.method || 'api_key', status: a.status || 'active', lastUsed: 0, errorCount: 0, refreshToken: a.refreshToken || '', expiresAt: 0, refreshTimer: null, addedAt: a.addedAt || Date.now(), tier: a.tier || 'unknown', capabilities: a.capabilities || {}, lastProbed: a.lastProbed || 0, credits: a.credits || null, blockedModels: Array.isArray(a.blockedModels) ? a.blockedModels : [], userStatus: a.userStatus || null, userStatusLastFetched: a.userStatusLastFetched || 0, }); } if (data.length > 0) log.info(`Loaded ${data.length} account(s) from disk`); } catch (e) { log.error('Failed to load accounts:', e.message); } } // ─── Dynamic model catalog from cloud ───────────────────── async function fetchAndMergeModelCatalog() { // Use the first active account to fetch the catalog. const acct = accounts.find(a => a.status === 'active' && a.apiKey); if (!acct) { log.debug('No active account for model catalog fetch'); return; } try { const { getCascadeModelConfigs } = await import('./windsurf-api.js'); const { mergeCloudModels } = await import('./models.js'); const proxy = getEffectiveProxy(acct.id) || null; const { configs } = await getCascadeModelConfigs(acct.apiKey, proxy); const added = mergeCloudModels(configs); log.info(`Model catalog: ${configs.length} cloud models, ${added} new entries merged`); } catch (e) { log.warn(`Model catalog fetch failed: ${e.message}`); } } async function registerWithCodeium(idToken) { const { WindsurfClient } = await import('./client.js'); const client = new WindsurfClient('', 0, ''); const result = await client.registerUser(idToken); return result; // { apiKey, name, apiServerUrl } } // ─── Account management ─────────────────────────────────── /** * Add account via API key. */ export function addAccountByKey(apiKey, label = '') { const existing = accounts.find(a => a.apiKey === apiKey); if (existing) return existing; const account = { id: randomUUID().slice(0, 8), email: label || `key-${apiKey.slice(0, 8)}`, apiKey, apiServerUrl: '', method: 'api_key', status: 'active', lastUsed: 0, errorCount: 0, refreshToken: '', expiresAt: 0, refreshTimer: null, addedAt: Date.now(), tier: 'unknown', capabilities: {}, lastProbed: 0, blockedModels: [], }; account.credits = null; accounts.push(account); saveAccounts(); log.info(`Account added: ${account.id} (${account.email}) [api_key]`); return account; } /** * Add account via auth token. */ export async function addAccountByToken(token, label = '') { const reg = await registerWithCodeium(token); const existing = accounts.find(a => a.apiKey === reg.apiKey); if (existing) return existing; const account = { id: randomUUID().slice(0, 8), email: label || reg.name || `token-${reg.apiKey.slice(0, 8)}`, apiKey: reg.apiKey, apiServerUrl: reg.apiServerUrl || '', method: 'token', status: 'active', lastUsed: 0, errorCount: 0, refreshToken: '', expiresAt: 0, refreshTimer: null, addedAt: Date.now(), tier: 'unknown', capabilities: {}, lastProbed: 0, blockedModels: [], credits: null, }; accounts.push(account); saveAccounts(); log.info(`Account added: ${account.id} (${account.email}) [token] server=${account.apiServerUrl}`); return account; } /** * Add account via email/password is not supported for direct Firebase login. * Use token-based auth instead: get a token from windsurf.com/show-auth-token */ export async function addAccountByEmail(email, password) { throw new Error('Direct email/password login is not supported. Use token-based auth: get token from windsurf.com, then POST /auth/login {"token":"..."}'); } /** * Per-account blocklist: hide specific models from this account so the * selector won't route matching requests here. Useful when one key has * burned its claude quota but still serves gpt just fine. */ export function setAccountBlockedModels(id, blockedModels) { const account = accounts.find(a => a.id === id); if (!account) return false; account.blockedModels = Array.isArray(blockedModels) ? blockedModels.slice() : []; saveAccounts(); log.info(`Account ${id} blockedModels updated: ${account.blockedModels.length} blocked`); return true; } /** * Resolve whether `modelKey` is callable on this account: * tier entitlement ∩ (models.js catalog) − account.blockedModels */ export function isModelAllowedForAccount(account, modelKey) { const tierModels = getTierModels(account.tier || 'unknown'); if (!tierModels.includes(modelKey)) return false; const blocked = account.blockedModels || []; if (blocked.includes(modelKey)) return false; return true; } /** List of model keys this account is currently allowed to call. */ export function getAvailableModelsForAccount(account) { const tierModels = getTierModels(account.tier || 'unknown'); const blocked = new Set(account.blockedModels || []); return tierModels.filter(m => !blocked.has(m)); } /** * Set account status (active, disabled, error). */ export function setAccountStatus(id, status) { const account = accounts.find(a => a.id === id); if (!account) return false; account.status = status; if (status === 'active') account.errorCount = 0; saveAccounts(); log.info(`Account ${id} status set to ${status}`); return true; } /** * Reset error count for an account. */ export function resetAccountErrors(id) { const account = accounts.find(a => a.id === id); if (!account) return false; account.errorCount = 0; account.status = 'active'; saveAccounts(); log.info(`Account ${id} errors reset`); return true; } /** * Update account label. */ export function updateAccountLabel(id, label) { const account = accounts.find(a => a.id === id); if (!account) return false; account.email = label; saveAccounts(); return true; } /** * Persist tokens (apiKey / refreshToken / idToken) onto an account. * Fields with undefined are left unchanged. Always flushes to disk so the * rotation survives a restart even if the caller never saves explicitly. */ /** * Manually force an account's tier. Used when automatic probing mis- * classifies an account — e.g. 14-day Pro trials whose planName doesn't * match our regex, or accounts whose initial probe was blocked by an * upstream bug and now carry a stale "free" tag even though the real * subscription is Pro. */ export function setAccountTier(id, tier) { if (!['pro', 'free', 'unknown', 'expired'].includes(tier)) return false; const account = accounts.find(a => a.id === id); if (!account) return false; account.tier = tier; account.tierManual = true; saveAccounts(); log.info(`Account ${id} tier manually set to ${tier}`); return true; } export function setAccountTokens(id, { apiKey, refreshToken, idToken } = {}) { const account = accounts.find(a => a.id === id); if (!account) return false; if (apiKey != null) account.apiKey = apiKey; if (refreshToken != null) account.refreshToken = refreshToken; if (idToken != null) account.idToken = idToken; saveAccounts(); return true; } /** * Remove an account by ID. */ export function removeAccount(id) { const idx = accounts.findIndex(a => a.id === id); if (idx === -1) return false; const account = accounts[idx]; accounts.splice(idx, 1); saveAccounts(); // Drop any Cascade conversations owned by this key so future requests // don't try to resume on an account that no longer exists. import('./conversation-pool.js').then(m => m.invalidateFor({ apiKey: account.apiKey })).catch(() => {}); log.info(`Account removed: ${id} (${account.email})`); return true; } // ─── Account selection (tier-weighted RPM) ───────────────── /** * Pick the next available account based on per-tier RPM headroom. * * Strategy: * 1. Keep only active, non-excluded, non-rate-limited accounts. * 2. Drop accounts whose 60s request count already equals their tier cap. * 3. Pick the account with the highest remaining-ratio (most idle). * 4. Record the selection timestamp on that account's sliding window. * * Returns null when every account is temporarily full — callers should * wait a moment and retry (see handlers/chat.js queue loop). */ export function getApiKey(excludeKeys = [], modelKey = null) { const now = Date.now(); const candidates = []; for (const a of accounts) { if (a.status !== 'active') continue; if (excludeKeys.includes(a.apiKey)) continue; if (isRateLimitedForModel(a, modelKey, now)) continue; const limit = rpmLimitFor(a); if (limit <= 0) continue; // expired tier const used = pruneRpmHistory(a, now); if (used >= limit) continue; // Tier entitlement + per-account blocklist filter if (modelKey && !isModelAllowedForAccount(a, modelKey)) continue; candidates.push({ account: a, used, limit }); } if (candidates.length === 0) return null; // Pick the account with the highest remaining ratio. Ties broken by // least-recently-used so a burst spreads across accounts evenly. candidates.sort((x, y) => { const rx = (x.limit - x.used) / x.limit; const ry = (y.limit - y.used) / y.limit; if (ry !== rx) return ry - rx; return (x.account.lastUsed || 0) - (y.account.lastUsed || 0); }); const { account } = candidates[0]; account._rpmHistory.push(now); account.lastUsed = now; return { id: account.id, email: account.email, apiKey: account.apiKey, apiServerUrl: account.apiServerUrl || '', proxy: getEffectiveProxy(account.id) || null, }; } /** * Try to re-check-out a specific account by apiKey, applying the same * rate-limit / status guards as getApiKey(). Used by the conversation pool * when a pool hit requires routing back to the exact account that owns the * upstream cascade_id — if that account is momentarily unavailable we fall * back to a fresh cascade on a different account instead of queuing. */ export function acquireAccountByKey(apiKey, modelKey = null) { const now = Date.now(); const a = accounts.find(x => x.apiKey === apiKey); if (!a) return null; if (a.status !== 'active') return null; if (isRateLimitedForModel(a, modelKey, now)) return null; const limit = rpmLimitFor(a); if (limit <= 0) return null; const used = pruneRpmHistory(a, now); if (used >= limit) return null; if (modelKey && !isModelAllowedForAccount(a, modelKey)) return null; a._rpmHistory.push(now); a.lastUsed = now; return { id: a.id, email: a.email, apiKey: a.apiKey, apiServerUrl: a.apiServerUrl || '', proxy: getEffectiveProxy(a.id) || null, }; } /** * Snapshot of per-account RPM usage, for dashboard display. */ export function getRpmStats() { const now = Date.now(); const out = {}; for (const a of accounts) { const limit = rpmLimitFor(a); const used = pruneRpmHistory(a, now); out[a.id] = { used, limit, tier: a.tier || 'unknown' }; } return out; } /** * Ensure an LS instance exists for an account's proxy. * Used on startup and after adding new accounts so chat requests don't race * the first-time LS spawn. */ export async function ensureLsForAccount(accountId) { const { ensureLs } = await import('./langserver.js'); const account = accounts.find(a => a.id === accountId); const proxy = getEffectiveProxy(accountId) || null; try { const ls = await ensureLs(proxy); // Pre-warm the Cascade workspace init so the first real request on this // LS doesn't pay the 3-roundtrip setup cost. Fire-and-forget — chat // requests still await the same Promise if it hasn't finished yet. if (ls && account?.apiKey) { const { WindsurfClient } = await import('./client.js'); const client = new WindsurfClient(account.apiKey, ls.port, ls.csrfToken); client.warmupCascade().catch(e => log.warn(`Cascade warmup failed: ${e.message}`)); } } catch (e) { log.error(`Failed to start LS for account ${accountId}: ${e.message}`); } } /** * Mark an account as rate-limited for a duration (default 5 min). * When `modelKey` is provided, only that model is blocked on this account — * other models remain routable. When omitted, the entire account is blocked * (legacy behaviour, used by generic 429 responses). */ export function markRateLimited(apiKey, durationMs = 5 * 60 * 1000, modelKey = null) { const account = accounts.find(a => a.apiKey === apiKey); if (!account) return; const until = Date.now() + durationMs; if (modelKey) { if (!account._modelRateLimits) account._modelRateLimits = {}; account._modelRateLimits[modelKey] = until; log.warn(`Account ${account.id} (${account.email}) rate-limited on ${modelKey} for ${Math.round(durationMs / 60000)} min`); } else { account.rateLimitedUntil = until; log.warn(`Account ${account.id} (${account.email}) rate-limited (all models) for ${Math.round(durationMs / 60000)} min`); } } /** * Check if an account is rate-limited for a specific model. */ function isRateLimitedForModel(account, modelKey, now) { // Global rate limit if (account.rateLimitedUntil && account.rateLimitedUntil > now) return true; // Per-model rate limit if (modelKey && account._modelRateLimits) { const until = account._modelRateLimits[modelKey]; if (until && until > now) return true; // Clean up expired entries if (until && until <= now) delete account._modelRateLimits[modelKey]; } return false; } /** * Report an error for an API key (increment error count, auto-disable). */ export function reportError(apiKey) { const account = accounts.find(a => a.apiKey === apiKey); if (!account) return; account.errorCount++; if (account.errorCount >= 3) { account.status = 'error'; log.warn(`Account ${account.id} (${account.email}) disabled after ${account.errorCount} errors`); } } /** * Reset error count for an API key (call on success). */ export function reportSuccess(apiKey) { const account = accounts.find(a => a.apiKey === apiKey); if (!account) return; if (account.errorCount > 0) { account.errorCount = 0; account.status = 'active'; } account.internalErrorStreak = 0; } /** * Report an upstream "internal error occurred (error ID: ...)" from Windsurf. * These are account-specific backend errors — a given key will keep hitting * them until we stop using it. Quarantine the key for 5 minutes after 2 * consecutive hits so we stop burning user-visible retries on a dead key. */ export function reportInternalError(apiKey) { const account = accounts.find(a => a.apiKey === apiKey); if (!account) return; account.internalErrorStreak = (account.internalErrorStreak || 0) + 1; if (account.internalErrorStreak >= 2) { account.rateLimitedUntil = Date.now() + 5 * 60 * 1000; log.warn(`Account ${account.id} (${account.email}) quarantined 5min after ${account.internalErrorStreak} consecutive upstream internal errors`); } } // ─── Status ──────────────────────────────────────────────── /** * Check if every eligible account is currently rate-limited for a given model. * Returns { allLimited, retryAfterMs } — callers can use retryAfterMs to set * a Retry-After header for 429 responses. */ export function isAllRateLimited(modelKey) { const now = Date.now(); let soonestExpiry = Infinity; let anyEligible = false; for (const a of accounts) { if (a.status !== 'active') continue; if (modelKey && !isModelAllowedForAccount(a, modelKey)) continue; anyEligible = true; if (!isRateLimitedForModel(a, modelKey, now)) return { allLimited: false }; // Track the soonest expiry across both global and per-model limits if (a.rateLimitedUntil && a.rateLimitedUntil > now) { soonestExpiry = Math.min(soonestExpiry, a.rateLimitedUntil); } if (modelKey && a._modelRateLimits?.[modelKey] > now) { soonestExpiry = Math.min(soonestExpiry, a._modelRateLimits[modelKey]); } } if (!anyEligible) return { allLimited: false }; const retryAfterMs = soonestExpiry === Infinity ? 60000 : Math.max(1000, soonestExpiry - now); return { allLimited: true, retryAfterMs }; } export function isAuthenticated() { return accounts.some(a => a.status === 'active'); } export function getAccountList() { const now = Date.now(); return accounts.map(a => { const rpmLimit = rpmLimitFor(a); const rpmUsed = pruneRpmHistory(a, now); return { id: a.id, email: a.email, method: a.method, status: a.status, errorCount: a.errorCount, lastUsed: a.lastUsed ? new Date(a.lastUsed).toISOString() : null, addedAt: new Date(a.addedAt).toISOString(), keyPrefix: a.apiKey.slice(0, 8) + '...', apiKey: a.apiKey, tier: a.tier || 'unknown', capabilities: a.capabilities || {}, lastProbed: a.lastProbed || 0, rateLimitedUntil: a.rateLimitedUntil || 0, rateLimited: !!(a.rateLimitedUntil && a.rateLimitedUntil > now), modelRateLimits: a._modelRateLimits ? Object.fromEntries( Object.entries(a._modelRateLimits).filter(([, v]) => v > now) ) : {}, rpmUsed, rpmLimit, credits: a.credits || null, blockedModels: a.blockedModels || [], availableModels: getAvailableModelsForAccount(a), tierModels: getTierModels(a.tier || 'unknown'), userStatus: a.userStatus || null, userStatusLastFetched: a.userStatusLastFetched || 0, }; }); } /** * Fetch live credit balance + plan info from server.codeium.com and stash it * on the account. Used by manual refresh and by the 15-minute background loop. * Errors are returned in-band so the dashboard can show them without throwing. */ export async function refreshCredits(id) { const account = accounts.find(a => a.id === id); if (!account) return { ok: false, error: 'Account not found' }; try { const { getUserStatus } = await import('./windsurf-api.js'); const proxy = getEffectiveProxy(account.id) || null; const status = await getUserStatus(account.apiKey, proxy); // Drop the huge raw payload before persisting — keep it only in memory for // downstream callers (e.g. model catalog cache) to inspect once. const { raw, ...persist } = status; account.credits = persist; // Tier hint: if the plan info is explicit, prefer it over capability probing. // Trial / individual accounts also count as pro — Windsurf returns // "INDIVIDUAL" / "TRIAL" / similar for paid-tier trials (issue #8 follow-up: // motto1's 14-day Pro trial was misclassified as free because planName // wasn't "Pro"). const pn = status.planName || ''; if (/pro|teams|enterprise|trial|individual|premium|paid/i.test(pn)) { if (account.tier !== 'pro') account.tier = 'pro'; } else if (/free/i.test(pn)) { if (account.tier === 'unknown') account.tier = 'free'; } saveAccounts(); // Surface the raw response once so the caller can decide whether to mine // the bundled model catalog from it. return { ok: true, credits: persist, raw }; } catch (e) { const msg = e.message || String(e); log.warn(`refreshCredits ${id} failed: ${msg}`); // Stash the error on the account so the dashboard can show "last refresh // failed" without losing the previously successful snapshot. if (account.credits) account.credits.lastError = msg; else account.credits = { lastError: msg, fetchedAt: Date.now() }; return { ok: false, error: msg }; } } export async function refreshAllCredits() { const results = []; for (const a of accounts) { if (a.status !== 'active') continue; const r = await refreshCredits(a.id); results.push({ id: a.id, email: a.email, ok: r.ok, error: r.error }); } return results; } /** * Update the capability of an account for a specific model. * reason: 'success' | 'model_error' | 'rate_limit' | 'transport_error' */ export function updateCapability(apiKey, modelKey, ok, reason = '') { const account = accounts.find(a => a.apiKey === apiKey); if (!account) return; if (!account.capabilities) account.capabilities = {}; // Don't overwrite a confirmed failure with a transient error if (reason === 'transport_error') return; // rate_limit is temporary — don't mark as permanently failed if (!ok && reason === 'rate_limit') return; account.capabilities[modelKey] = { ok, lastCheck: Date.now(), reason, }; account.tier = inferTier(account.capabilities); saveAccounts(); } /** * Infer subscription tier from which canary models work. Fallback only — * probeAccount prefers GetUserStatus which returns the authoritative tier. */ function inferTier(caps) { const works = (m) => caps[m]?.ok === true; if (works('claude-opus-4.6') || works('claude-sonnet-4.6')) return 'pro'; if (works('gemini-2.5-flash') || works('gpt-4o-mini')) return 'free'; const checked = Object.keys(caps); if (checked.length > 0 && checked.every(m => caps[m].ok === false)) return 'expired'; return 'unknown'; } /** * Fetch authoritative user status from the LS → account fields. * Returns the parsed UserStatus object on success, null on failure. */ export async function fetchUserStatus(id) { const account = accounts.find(a => a.id === id); if (!account) return null; const { WindsurfClient } = await import('./client.js'); const { ensureLs, getLsFor } = await import('./langserver.js'); const proxy = getEffectiveProxy(account.id) || null; await ensureLs(proxy); const ls = getLsFor(proxy); if (!ls) { log.warn(`No LS for GetUserStatus on ${account.id}`); return null; } const client = new WindsurfClient(account.apiKey, ls.port, ls.csrfToken); let status; try { status = await client.getUserStatus(); } catch (err) { log.warn(`GetUserStatus ${account.id} (${account.email}) failed: ${err.message}`); return null; } // Apply to account — authoritative tier + entitlement snapshot. const prevTier = account.tier; account.tier = status.tierName; account.userStatus = { teamsTier: status.teamsTier, pro: status.pro, planName: status.planName, email: status.email, displayName: status.displayName, teamId: status.teamId, isTeams: status.isTeams, isEnterprise: status.isEnterprise, hasPaidFeatures: status.hasPaidFeatures, trialEndMs: status.trialEndMs, promptCreditsUsed: status.userUsedPromptCredits, flowCreditsUsed: status.userUsedFlowCredits, monthlyPromptCredits: status.monthlyPromptCredits, monthlyFlowCredits: status.monthlyFlowCredits, maxPremiumChatMessages: status.maxPremiumChatMessages, allowedModels: status.allowedModels, }; account.userStatusLastFetched = Date.now(); if (status.email && !account.email.includes('@')) account.email = status.email; // Mark every cascade-allowed enum as capable; every catalog enum NOT in the // allowlist as not-entitled. Pure-UID models (no enum) are left to the // canary probe since the server returns allowlists by enum only. if (status.allowedModels.length > 0) { if (!account.capabilities) account.capabilities = {}; const allowedEnums = new Set(status.allowedModels.map(m => m.modelEnum).filter(e => e > 0)); for (const [key, info] of Object.entries(MODELS)) { if (!info.enumValue || info.enumValue <= 0) continue; if (allowedEnums.has(info.enumValue)) { account.capabilities[key] = { ok: true, lastCheck: Date.now(), reason: 'user_status' }; } else { const prev = account.capabilities[key]; if (!prev || prev.reason !== 'success') { // Respect a previously-validated success (can happen if allowlist is // cascade-only while the model was reached via legacy endpoint). account.capabilities[key] = { ok: false, lastCheck: Date.now(), reason: 'not_entitled' }; } } } } if (prevTier !== account.tier) { log.info(`Tier change ${account.id} (${account.email}): ${prevTier} → ${account.tier} (plan="${status.planName}", ${status.allowedModels.length} allowed models)`); } else { log.info(`UserStatus ${account.id} (${account.email}): tier=${account.tier} plan="${status.planName}" allowed=${status.allowedModels.length}`); } saveAccounts(); return status; } // Expanded canary set — one representative per routing path / provider family. // Order matters: free-tier models first so tier can be inferred early even if // later requests rate-limit. modelUid-only entries cover the 4.6 series since // GetUserStatus's allowlist is enum-keyed. const PROBE_CANARIES = [ 'gpt-4o-mini', 'gemini-2.5-flash', 'claude-sonnet-4.6', 'claude-opus-4.6', 'gemini-3.0-flash', 'claude-4.5-sonnet', ]; /** * Probe an account's tier and model capabilities. * * Strategy (2026-04-21): * 1. GetUserStatus — authoritative tier + enum-keyed allowlist with credit * multipliers + trial end time + credit usage. One RPC, no quota burn. * 2. Canary probe — fills in capabilities for modelUid-only models (claude * 4.6 series etc.) which don't appear in the enum allowlist, and serves * as a fallback if GetUserStatus fails on this LS/account combo. */ export async function probeAccount(id) { const account = accounts.find(a => a.id === id); if (!account) return null; // ── Step 1: authoritative tier via GetUserStatus ── const status = await fetchUserStatus(id); const { WindsurfClient } = await import('./client.js'); const { getModelInfo } = await import('./models.js'); const { ensureLs, getLsFor } = await import('./langserver.js'); const proxy = getEffectiveProxy(account.id) || null; await ensureLs(proxy); const ls = getLsFor(proxy); if (!ls) { log.error(`No LS available for account ${account.id}`); return null; } const port = ls.port; const csrf = ls.csrfToken; // ── Step 2: canary probe, skipping models already classified by GetUserStatus ── // When allowlist is available we only need to probe UID-only models (no enum, // so server can't include them in allowlist) to get their actual status. const needsProbe = PROBE_CANARIES.filter(key => { const info = getModelInfo(key); if (!info) return false; // If GetUserStatus already gave us a definitive answer, skip. if (status && info.enumValue > 0) { const cap = account.capabilities?.[key]; if (cap && cap.reason === 'user_status') return false; if (cap && cap.reason === 'not_entitled') return false; } return true; }); if (needsProbe.length > 0) { log.info(`Probing account ${account.id} (${account.email}) across ${needsProbe.length} canary models (GetUserStatus ${status ? 'OK' : 'unavailable'})`); for (const modelKey of needsProbe) { const info = getModelInfo(modelKey); if (!info) continue; const useCascade = !!info.modelUid; const client = new WindsurfClient(account.apiKey, port, csrf); try { if (useCascade) { await client.cascadeChat([{ role: 'user', content: 'hi' }], info.enumValue, info.modelUid); } else { await client.rawGetChatMessage([{ role: 'user', content: 'hi' }], info.enumValue, info.modelUid); } updateCapability(account.apiKey, modelKey, true, 'success'); log.info(` ${modelKey}: OK`); } catch (err) { const isRateLimit = /rate limit|rate_limit|too many requests|quota/i.test(err.message); if (isRateLimit) { log.info(` ${modelKey}: RATE_LIMITED (skipped)`); } else { updateCapability(account.apiKey, modelKey, false, 'model_error'); log.info(` ${modelKey}: FAIL (${err.message.slice(0, 80)})`); } } } } // If GetUserStatus succeeded, its tier decision wins over the inferred one // (updateCapability rewrites tier via inferTier, so restore it afterwards). if (status) account.tier = status.tierName; account.lastProbed = Date.now(); saveAccounts(); log.info(`Probe complete for ${account.id}: tier=${account.tier}${status ? ` plan="${status.planName}"` : ''}`); return { tier: account.tier, capabilities: account.capabilities }; } export function getAccountCount() { return { total: accounts.length, active: accounts.filter(a => a.status === 'active').length, error: accounts.filter(a => a.status === 'error').length, }; } // ─── Incoming request API key validation ─────────────────── export function validateApiKey(key) { if (!config.apiKey) return true; return key === config.apiKey; } // ─── Firebase token refresh ────────────────────────────────── /** * Refresh Firebase tokens for all accounts that have a stored refreshToken. * Re-registers with Codeium to get a fresh API key and updates the account. */ async function refreshAllFirebaseTokens() { const { refreshFirebaseToken, reRegisterWithCodeium } = await import('./dashboard/windsurf-login.js'); for (const a of accounts) { if (a.status !== 'active' || !a.refreshToken) continue; try { const proxy = getEffectiveProxy(a.id) || null; const { idToken, refreshToken: newRefresh } = await refreshFirebaseToken(a.refreshToken, proxy); a.refreshToken = newRefresh; // Re-register to get a fresh API key (may be the same key) const { apiKey } = await reRegisterWithCodeium(idToken, proxy); if (apiKey && apiKey !== a.apiKey) { log.info(`Firebase refresh: ${a.email} got new API key`); a.apiKey = apiKey; } saveAccounts(); } catch (e) { log.warn(`Firebase refresh ${a.email} failed: ${e.message}`); } } } // ─── Init from .env ──────────────────────────────────────── export async function initAuth() { // Load persisted accounts first loadAccounts(); const promises = []; // Load API keys from env (comma-separated) if (config.codeiumApiKey) { for (const key of config.codeiumApiKey.split(',').map(k => k.trim()).filter(Boolean)) { addAccountByKey(key); } } // Load auth tokens from env (comma-separated) if (config.codeiumAuthToken) { for (const token of config.codeiumAuthToken.split(',').map(t => t.trim()).filter(Boolean)) { promises.push( addAccountByToken(token).catch(err => log.error(`Token auth failed: ${err.message}`)) ); } } // Note: email/password login removed (Firebase API key not valid for direct login) // Use token-based auth instead if (promises.length > 0) await Promise.allSettled(promises); // Periodic re-probe so tier/capability info doesn't drift as quotas reset. const REPROBE_INTERVAL = 6 * 60 * 60 * 1000; setInterval(async () => { for (const a of accounts) { if (a.status !== 'active') continue; try { await probeAccount(a.id); } catch (e) { log.warn(`Scheduled probe ${a.id} failed: ${e.message}`); } } }, REPROBE_INTERVAL).unref?.(); // Periodic credit refresh (every 15 min). First run is fire-and-forget so // startup isn't blocked by cloud round-trips. const CREDIT_INTERVAL = 15 * 60 * 1000; refreshAllCredits().catch(e => log.warn(`Initial credit refresh: ${e.message}`)); setInterval(() => { refreshAllCredits().catch(e => log.warn(`Scheduled credit refresh: ${e.message}`)); }, CREDIT_INTERVAL).unref?.(); // Fetch live model catalog from cloud and merge into hardcoded catalog. // Fire-and-forget — the hardcoded catalog is sufficient until this completes. fetchAndMergeModelCatalog().catch(e => log.warn(`Model catalog fetch: ${e.message}`)); // Periodic Firebase token refresh (every 50 min). Firebase ID tokens expire // after 60 min; refreshing at 50 keeps a comfortable margin. const hasRefreshTokens = accounts.some(a => !!a.refreshToken); if (hasRefreshTokens) { const TOKEN_REFRESH_INTERVAL = 50 * 60 * 1000; refreshAllFirebaseTokens().catch(e => log.warn(`Initial token refresh: ${e.message}`)); setInterval(() => { refreshAllFirebaseTokens().catch(e => log.warn(`Scheduled token refresh: ${e.message}`)); }, TOKEN_REFRESH_INTERVAL).unref?.(); } // Warm up an LS instance for each account's configured proxy so the first // chat request doesn't pay the spawn cost. const { ensureLs } = await import('./langserver.js'); const uniqueProxies = new Map(); for (const a of accounts) { const p = getEffectiveProxy(a.id); const k = p ? `${p.host}:${p.port}` : 'default'; if (!uniqueProxies.has(k)) uniqueProxies.set(k, p || null); } for (const p of uniqueProxies.values()) { try { await ensureLs(p); } catch (e) { log.warn(`LS warmup failed: ${e.message}`); } } const counts = getAccountCount(); if (counts.total > 0) { log.info(`Auth pool: ${counts.active} active, ${counts.error} error, ${counts.total} total`); } else { log.warn('No accounts configured. Add via POST /auth/login'); } }