Spaces:
Running
Running
| /** | |
| * WebUI Module - Optional web interface for account management | |
| * | |
| * This module provides a web-based UI for: | |
| * - Dashboard with real-time model quota visualization | |
| * - Account management (add via OAuth, enable/disable, refresh, remove) | |
| * - Live server log streaming with filtering | |
| * - Claude CLI configuration editor | |
| * | |
| * Usage in server.js: | |
| * import { mountWebUI } from './webui/index.js'; | |
| * mountWebUI(app, __dirname, accountManager); | |
| */ | |
| import path from 'path'; | |
| import express from 'express'; | |
| import { getPublicConfig, saveConfig, config } from '../config.js'; | |
| import { DEFAULT_PORT, ACCOUNT_CONFIG_PATH, MAX_ACCOUNTS, DEFAULT_PRESETS, DEFAULT_SERVER_PRESETS } from '../constants.js'; | |
| import { readClaudeConfig, updateClaudeConfig, replaceClaudeConfig, getClaudeConfigPath, readPresets, savePreset, deletePreset } from '../utils/claude-config.js'; | |
| import { readServerPresets, saveServerPreset, updateServerPreset, deleteServerPreset } from '../utils/server-presets.js'; | |
| import { logger } from '../utils/logger.js'; | |
| import { hasAdminAccess } from '../middleware/api-key-auth.js'; | |
| import { loadAccounts, saveAccounts } from '../account-manager/storage.js'; | |
| import { getPackageVersion } from '../utils/helpers.js'; | |
| // Get package version | |
| const packageVersion = getPackageVersion(); | |
| // OAuth state storage (state -> { server, verifier, state, timestamp }) | |
| // Maps state ID to active OAuth flow data | |
| const pendingOAuthFlows = new Map(); | |
| /** | |
| * WebUI Helper Functions - Direct account manipulation | |
| * These functions work around AccountManager's limited API by directly | |
| * manipulating the accounts.json config file (non-invasive approach for PR) | |
| */ | |
| /** | |
| * Set account enabled/disabled state | |
| */ | |
| async function setAccountEnabled(email, enabled) { | |
| const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH); | |
| const account = accounts.find(a => a.email === email); | |
| if (!account) { | |
| throw new Error(`Account ${email} not found`); | |
| } | |
| account.enabled = enabled; | |
| await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex); | |
| logger.info(`[WebUI] Account ${email} ${enabled ? 'enabled' : 'disabled'}`); | |
| } | |
| /** | |
| * Remove account from config | |
| */ | |
| async function removeAccount(email) { | |
| const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH); | |
| const index = accounts.findIndex(a => a.email === email); | |
| if (index === -1) { | |
| throw new Error(`Account ${email} not found`); | |
| } | |
| accounts.splice(index, 1); | |
| // Adjust activeIndex if needed | |
| const newActiveIndex = activeIndex >= accounts.length ? Math.max(0, accounts.length - 1) : activeIndex; | |
| await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, newActiveIndex); | |
| logger.info(`[WebUI] Account ${email} removed`); | |
| } | |
| /** | |
| * Add new account to config | |
| * @throws {Error} If MAX_ACCOUNTS limit is reached (for new accounts only) | |
| */ | |
| async function addAccount(accountData) { | |
| const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH); | |
| // Check if account already exists | |
| const existingIndex = accounts.findIndex(a => a.email === accountData.email); | |
| if (existingIndex !== -1) { | |
| // Update existing account | |
| accounts[existingIndex] = { | |
| ...accounts[existingIndex], | |
| ...accountData, | |
| enabled: true, | |
| isInvalid: false, | |
| invalidReason: null, | |
| addedAt: accounts[existingIndex].addedAt || new Date().toISOString() | |
| }; | |
| logger.info(`[WebUI] Account ${accountData.email} updated`); | |
| } else { | |
| // Check MAX_ACCOUNTS limit before adding new account | |
| if (accounts.length >= MAX_ACCOUNTS) { | |
| throw new Error(`Maximum of ${MAX_ACCOUNTS} accounts reached. Update maxAccounts in config to increase the limit.`); | |
| } | |
| // Add new account | |
| accounts.push({ | |
| ...accountData, | |
| enabled: true, | |
| isInvalid: false, | |
| invalidReason: null, | |
| modelRateLimits: {}, | |
| lastUsed: null, | |
| addedAt: new Date().toISOString() | |
| }); | |
| logger.info(`[WebUI] Account ${accountData.email} added`); | |
| } | |
| await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex); | |
| } | |
| /** | |
| * Auth Middleware - Optional password protection for WebUI | |
| * Password can be set via WEBUI_PASSWORD env var or config.json | |
| */ | |
| function createAuthMiddleware() { | |
| return (req, res, next) => { | |
| const password = config.webuiPassword; | |
| if (!password) return next(); | |
| // Determine if this path should be protected | |
| const isApiRoute = req.path.startsWith('/api/'); | |
| const isAuthUrl = req.path === '/api/auth/url'; | |
| const isConfigGet = req.path === '/api/config' && req.method === 'GET'; | |
| const isProtected = (isApiRoute && !isAuthUrl && !isConfigGet) || req.path === '/account-limits' || req.path === '/health'; | |
| if (isProtected) { | |
| if (hasAdminAccess(req)) { | |
| return next(); | |
| } | |
| return res.status(401).json({ status: 'error', error: 'Unauthorized: Password required' }); | |
| } | |
| next(); | |
| }; | |
| } | |
| /** | |
| * Validate server config fields from user input. | |
| * Shared by POST /api/config and PATCH /api/server/presets/:name. | |
| * @param {Object} input - Raw config fields to validate | |
| * @returns {Object} Validated updates object (only valid fields included) | |
| */ | |
| function validateConfigFields(input) { | |
| const updates = {}; | |
| const { maxRetries, retryBaseMs, retryMaxMs, defaultCooldownMs, maxWaitBeforeErrorMs, maxAccounts, globalQuotaThreshold, accountSelection, rateLimitDedupWindowMs, maxConsecutiveFailures, extendedCooldownMs, maxCapacityRetries, switchAccountDelayMs, capacityBackoffTiersMs } = input; | |
| if (typeof maxRetries === 'number' && maxRetries >= 1 && maxRetries <= 20) { | |
| updates.maxRetries = maxRetries; | |
| } | |
| if (typeof retryBaseMs === 'number' && retryBaseMs >= 100 && retryBaseMs <= 10000) { | |
| updates.retryBaseMs = retryBaseMs; | |
| } | |
| if (typeof retryMaxMs === 'number' && retryMaxMs >= 1000 && retryMaxMs <= 120000) { | |
| updates.retryMaxMs = retryMaxMs; | |
| } | |
| if (typeof defaultCooldownMs === 'number' && defaultCooldownMs >= 1000 && defaultCooldownMs <= 300000) { | |
| updates.defaultCooldownMs = defaultCooldownMs; | |
| } | |
| if (typeof maxWaitBeforeErrorMs === 'number' && maxWaitBeforeErrorMs >= 0 && maxWaitBeforeErrorMs <= 600000) { | |
| updates.maxWaitBeforeErrorMs = maxWaitBeforeErrorMs; | |
| } | |
| if (typeof maxAccounts === 'number' && maxAccounts >= 1 && maxAccounts <= 100) { | |
| updates.maxAccounts = maxAccounts; | |
| } | |
| if (typeof globalQuotaThreshold === 'number' && globalQuotaThreshold >= 0 && globalQuotaThreshold < 1) { | |
| updates.globalQuotaThreshold = globalQuotaThreshold; | |
| } | |
| if (typeof rateLimitDedupWindowMs === 'number' && rateLimitDedupWindowMs >= 1000 && rateLimitDedupWindowMs <= 30000) { | |
| updates.rateLimitDedupWindowMs = rateLimitDedupWindowMs; | |
| } | |
| if (typeof maxConsecutiveFailures === 'number' && maxConsecutiveFailures >= 1 && maxConsecutiveFailures <= 10) { | |
| updates.maxConsecutiveFailures = maxConsecutiveFailures; | |
| } | |
| if (typeof extendedCooldownMs === 'number' && extendedCooldownMs >= 10000 && extendedCooldownMs <= 300000) { | |
| updates.extendedCooldownMs = extendedCooldownMs; | |
| } | |
| if (typeof maxCapacityRetries === 'number' && maxCapacityRetries >= 1 && maxCapacityRetries <= 10) { | |
| updates.maxCapacityRetries = maxCapacityRetries; | |
| } | |
| if (typeof switchAccountDelayMs === 'number' && switchAccountDelayMs >= 1000 && switchAccountDelayMs <= 60000) { | |
| updates.switchAccountDelayMs = switchAccountDelayMs; | |
| } | |
| if (Array.isArray(capacityBackoffTiersMs) && capacityBackoffTiersMs.length >= 1 && capacityBackoffTiersMs.length <= 10) { | |
| const allValid = capacityBackoffTiersMs.every(v => typeof v === 'number' && v >= 1000 && v <= 300000); | |
| if (allValid) { | |
| updates.capacityBackoffTiersMs = [...capacityBackoffTiersMs]; | |
| } | |
| } | |
| // Account selection strategy and tuning validation | |
| if (accountSelection && typeof accountSelection === 'object') { | |
| const validStrategies = ['sticky', 'round-robin', 'hybrid']; | |
| const acctUpdate = {}; | |
| if (accountSelection.strategy && validStrategies.includes(accountSelection.strategy)) { | |
| acctUpdate.strategy = accountSelection.strategy; | |
| } | |
| // Health score tuning | |
| if (accountSelection.healthScore && typeof accountSelection.healthScore === 'object') { | |
| const hs = accountSelection.healthScore; | |
| const hsUpdate = {}; | |
| if (typeof hs.initial === 'number' && hs.initial >= 0 && hs.initial <= 100) hsUpdate.initial = hs.initial; | |
| if (typeof hs.successReward === 'number' && hs.successReward >= 0 && hs.successReward <= 20) hsUpdate.successReward = hs.successReward; | |
| if (typeof hs.rateLimitPenalty === 'number' && hs.rateLimitPenalty >= -50 && hs.rateLimitPenalty <= 0) hsUpdate.rateLimitPenalty = hs.rateLimitPenalty; | |
| if (typeof hs.failurePenalty === 'number' && hs.failurePenalty >= -50 && hs.failurePenalty <= 0) hsUpdate.failurePenalty = hs.failurePenalty; | |
| if (typeof hs.recoveryPerHour === 'number' && hs.recoveryPerHour >= 0 && hs.recoveryPerHour <= 20) hsUpdate.recoveryPerHour = hs.recoveryPerHour; | |
| if (typeof hs.minUsable === 'number' && hs.minUsable >= 0 && hs.minUsable <= 100) hsUpdate.minUsable = hs.minUsable; | |
| if (typeof hs.maxScore === 'number' && hs.maxScore >= 1 && hs.maxScore <= 200) hsUpdate.maxScore = hs.maxScore; | |
| if (Object.keys(hsUpdate).length > 0) acctUpdate.healthScore = hsUpdate; | |
| } | |
| // Token bucket tuning | |
| if (accountSelection.tokenBucket && typeof accountSelection.tokenBucket === 'object') { | |
| const tb = accountSelection.tokenBucket; | |
| const tbUpdate = {}; | |
| if (typeof tb.maxTokens === 'number' && tb.maxTokens >= 5 && tb.maxTokens <= 200) tbUpdate.maxTokens = tb.maxTokens; | |
| if (typeof tb.tokensPerMinute === 'number' && tb.tokensPerMinute >= 1 && tb.tokensPerMinute <= 60) tbUpdate.tokensPerMinute = tb.tokensPerMinute; | |
| if (typeof tb.initialTokens === 'number' && tb.initialTokens >= 1 && tb.initialTokens <= 200) tbUpdate.initialTokens = tb.initialTokens; | |
| if (Object.keys(tbUpdate).length > 0) acctUpdate.tokenBucket = tbUpdate; | |
| } | |
| // Quota tuning | |
| if (accountSelection.quota && typeof accountSelection.quota === 'object') { | |
| const q = accountSelection.quota; | |
| const qUpdate = {}; | |
| if (typeof q.lowThreshold === 'number' && q.lowThreshold >= 0 && q.lowThreshold < 1) qUpdate.lowThreshold = q.lowThreshold; | |
| if (typeof q.criticalThreshold === 'number' && q.criticalThreshold >= 0 && q.criticalThreshold < 1) qUpdate.criticalThreshold = q.criticalThreshold; | |
| if (typeof q.staleMs === 'number' && q.staleMs >= 30000 && q.staleMs <= 3600000) qUpdate.staleMs = q.staleMs; | |
| if (Object.keys(qUpdate).length > 0) acctUpdate.quota = qUpdate; | |
| } | |
| // Weights tuning | |
| if (accountSelection.weights && typeof accountSelection.weights === 'object') { | |
| const w = accountSelection.weights; | |
| const wUpdate = {}; | |
| if (typeof w.health === 'number' && w.health >= 0 && w.health <= 20) wUpdate.health = w.health; | |
| if (typeof w.tokens === 'number' && w.tokens >= 0 && w.tokens <= 20) wUpdate.tokens = w.tokens; | |
| if (typeof w.quota === 'number' && w.quota >= 0 && w.quota <= 20) wUpdate.quota = w.quota; | |
| if (typeof w.lru === 'number' && w.lru >= 0 && w.lru <= 5) wUpdate.lru = w.lru; | |
| if (Object.keys(wUpdate).length > 0) acctUpdate.weights = wUpdate; | |
| } | |
| if (Object.keys(acctUpdate).length > 0) { | |
| updates.accountSelection = acctUpdate; | |
| } | |
| } | |
| return updates; | |
| } | |
| /** | |
| * Mount WebUI routes and middleware on Express app | |
| * @param {Express} app - Express application instance | |
| * @param {string} dirname - __dirname of the calling module (for static file path) | |
| * @param {AccountManager} accountManager - Account manager instance | |
| */ | |
| export function mountWebUI(app, dirname, accountManager) { | |
| // Apply auth middleware | |
| app.use(createAuthMiddleware()); | |
| // Serve static files from public directory | |
| app.use(express.static(path.join(dirname, '../public'))); | |
| // ========================================== | |
| // Account Management API | |
| // ========================================== | |
| /** | |
| * GET /api/accounts - List all accounts with status | |
| */ | |
| app.get('/api/accounts', async (req, res) => { | |
| try { | |
| const status = accountManager.getStatus(); | |
| res.json({ | |
| status: 'ok', | |
| accounts: status.accounts, | |
| summary: { | |
| total: status.total, | |
| available: status.available, | |
| rateLimited: status.rateLimited, | |
| invalid: status.invalid | |
| } | |
| }); | |
| } catch (error) { | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| /** | |
| * POST /api/accounts/:email/refresh - Refresh specific account token | |
| */ | |
| app.post('/api/accounts/:email/refresh', async (req, res) => { | |
| try { | |
| const { email } = req.params; | |
| accountManager.clearTokenCache(email); | |
| accountManager.clearProjectCache(email); | |
| // For verification errors (403 VALIDATION_REQUIRED), clear isInvalid on refresh. | |
| // The user has completed verification on Google's site and clicks Refresh to re-enable. | |
| // Auth errors (no verifyUrl) still require OAuth re-auth via FIX button. | |
| const account = accountManager.getAllAccounts().find(a => a.email === email); | |
| if (account && account.isInvalid && account.verifyUrl) { | |
| accountManager.clearInvalid(email); | |
| } | |
| res.json({ | |
| status: 'ok', | |
| message: `Token cache cleared for ${email}` | |
| }); | |
| } catch (error) { | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| /** | |
| * POST /api/accounts/:email/toggle - Enable/disable account | |
| */ | |
| app.post('/api/accounts/:email/toggle', async (req, res) => { | |
| try { | |
| const { email } = req.params; | |
| const { enabled } = req.body; | |
| if (typeof enabled !== 'boolean') { | |
| return res.status(400).json({ status: 'error', error: 'enabled must be a boolean' }); | |
| } | |
| await setAccountEnabled(email, enabled); | |
| // Reload AccountManager to pick up changes | |
| await accountManager.reload(); | |
| res.json({ | |
| status: 'ok', | |
| message: `Account ${email} ${enabled ? 'enabled' : 'disabled'}` | |
| }); | |
| } catch (error) { | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| /** | |
| * DELETE /api/accounts/:email - Remove account | |
| */ | |
| app.delete('/api/accounts/:email', async (req, res) => { | |
| try { | |
| const { email } = req.params; | |
| await removeAccount(email); | |
| // Reload AccountManager to pick up changes | |
| await accountManager.reload(); | |
| res.json({ | |
| status: 'ok', | |
| message: `Account ${email} removed` | |
| }); | |
| } catch (error) { | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| /** | |
| * PATCH /api/accounts/:email - Update account settings (thresholds) | |
| */ | |
| app.patch('/api/accounts/:email', async (req, res) => { | |
| try { | |
| const { email } = req.params; | |
| const { quotaThreshold, modelQuotaThresholds } = req.body; | |
| const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH); | |
| const account = accounts.find(a => a.email === email); | |
| if (!account) { | |
| return res.status(404).json({ status: 'error', error: `Account ${email} not found` }); | |
| } | |
| // Validate and update quotaThreshold (0-0.99 or null/undefined to clear) | |
| if (quotaThreshold !== undefined) { | |
| if (quotaThreshold === null) { | |
| delete account.quotaThreshold; | |
| } else if (typeof quotaThreshold === 'number' && quotaThreshold >= 0 && quotaThreshold < 1) { | |
| account.quotaThreshold = quotaThreshold; | |
| } else { | |
| return res.status(400).json({ status: 'error', error: 'quotaThreshold must be 0-0.99 or null' }); | |
| } | |
| } | |
| // Validate and update modelQuotaThresholds (full replacement, not merge) | |
| if (modelQuotaThresholds !== undefined) { | |
| if (modelQuotaThresholds === null || (typeof modelQuotaThresholds === 'object' && Object.keys(modelQuotaThresholds).length === 0)) { | |
| // Clear all model thresholds | |
| delete account.modelQuotaThresholds; | |
| } else if (typeof modelQuotaThresholds === 'object') { | |
| // Validate all thresholds first | |
| for (const [modelId, threshold] of Object.entries(modelQuotaThresholds)) { | |
| if (typeof threshold !== 'number' || threshold < 0 || threshold >= 1) { | |
| return res.status(400).json({ | |
| status: 'error', | |
| error: `Invalid threshold for model ${modelId}: must be 0-0.99` | |
| }); | |
| } | |
| } | |
| // Replace entire object (not merge) | |
| account.modelQuotaThresholds = { ...modelQuotaThresholds }; | |
| } else { | |
| return res.status(400).json({ status: 'error', error: 'modelQuotaThresholds must be an object or null' }); | |
| } | |
| } | |
| await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex); | |
| // Reload AccountManager to pick up changes | |
| await accountManager.reload(); | |
| logger.info(`[WebUI] Account ${email} thresholds updated`); | |
| res.json({ | |
| status: 'ok', | |
| message: `Account ${email} thresholds updated`, | |
| account: { | |
| email: account.email, | |
| quotaThreshold: account.quotaThreshold, | |
| modelQuotaThresholds: account.modelQuotaThresholds || {} | |
| } | |
| }); | |
| } catch (error) { | |
| logger.error('[WebUI] Error updating account thresholds:', error); | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| /** | |
| * POST /api/accounts/reload - Reload accounts from disk | |
| */ | |
| app.post('/api/accounts/reload', async (req, res) => { | |
| try { | |
| // Reload AccountManager from disk | |
| await accountManager.reload(); | |
| const status = accountManager.getStatus(); | |
| res.json({ | |
| status: 'ok', | |
| message: 'Accounts reloaded from disk', | |
| summary: status.summary | |
| }); | |
| } catch (error) { | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| /** | |
| * GET /api/accounts/export - Export accounts | |
| */ | |
| app.get('/api/accounts/export', async (req, res) => { | |
| try { | |
| const { accounts } = await loadAccounts(ACCOUNT_CONFIG_PATH); | |
| // Export only essential fields for portability | |
| const exportData = accounts | |
| .filter(acc => acc.source !== 'database') | |
| .map(acc => { | |
| const essential = { email: acc.email }; | |
| // Use snake_case for compatibility | |
| if (acc.refreshToken) { | |
| essential.refresh_token = acc.refreshToken; | |
| } | |
| if (acc.apiKey) { | |
| essential.api_key = acc.apiKey; | |
| } | |
| return essential; | |
| }); | |
| // Return plain array for simpler format | |
| res.json(exportData); | |
| } catch (error) { | |
| logger.error('[WebUI] Export accounts error:', error); | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| /** | |
| * POST /api/accounts/import - Batch import accounts | |
| */ | |
| app.post('/api/accounts/import', async (req, res) => { | |
| try { | |
| // Support both wrapped format { accounts: [...] } and plain array [...] | |
| let importAccounts = req.body; | |
| if (req.body.accounts && Array.isArray(req.body.accounts)) { | |
| importAccounts = req.body.accounts; | |
| } | |
| if (!Array.isArray(importAccounts) || importAccounts.length === 0) { | |
| return res.status(400).json({ | |
| status: 'error', | |
| error: 'accounts must be a non-empty array' | |
| }); | |
| } | |
| const results = { added: [], updated: [], failed: [] }; | |
| // Load existing accounts once before the loop | |
| const { accounts: existingAccounts } = await loadAccounts(ACCOUNT_CONFIG_PATH); | |
| const existingEmails = new Set(existingAccounts.map(a => a.email)); | |
| for (const acc of importAccounts) { | |
| try { | |
| // Validate required fields | |
| if (!acc.email) { | |
| results.failed.push({ email: acc.email || 'unknown', reason: 'Missing email' }); | |
| continue; | |
| } | |
| // Support both snake_case and camelCase | |
| const refreshToken = acc.refresh_token || acc.refreshToken; | |
| const apiKey = acc.api_key || acc.apiKey; | |
| // Must have at least one credential | |
| if (!refreshToken && !apiKey) { | |
| results.failed.push({ email: acc.email, reason: 'Missing refresh_token or api_key' }); | |
| continue; | |
| } | |
| // Check if account already exists | |
| const exists = existingEmails.has(acc.email); | |
| // Add account | |
| await addAccount({ | |
| email: acc.email, | |
| source: apiKey ? 'manual' : 'oauth', | |
| refreshToken: refreshToken, | |
| apiKey: apiKey | |
| }); | |
| if (exists) { | |
| results.updated.push(acc.email); | |
| } else { | |
| results.added.push(acc.email); | |
| } | |
| } catch (err) { | |
| results.failed.push({ email: acc.email, reason: err.message }); | |
| } | |
| } | |
| // Reload AccountManager | |
| await accountManager.reload(); | |
| logger.info(`[WebUI] Import complete: ${results.added.length} added, ${results.updated.length} updated, ${results.failed.length} failed`); | |
| res.json({ | |
| status: 'ok', | |
| results, | |
| message: `Imported ${results.added.length + results.updated.length} accounts` | |
| }); | |
| } catch (error) { | |
| logger.error('[WebUI] Import accounts error:', error); | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| // ========================================== | |
| // Configuration API | |
| // ========================================== | |
| /** | |
| * GET /api/config - Get server configuration | |
| */ | |
| app.get('/api/config', (req, res) => { | |
| try { | |
| const publicConfig = getPublicConfig(); | |
| res.json({ | |
| status: 'ok', | |
| config: publicConfig, | |
| version: packageVersion, | |
| note: 'Edit ~/.config/antigravity-proxy/config.json or use env vars to change these values' | |
| }); | |
| } catch (error) { | |
| logger.error('[WebUI] Error getting config:', error); | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| /** | |
| * POST /api/config - Update server configuration | |
| */ | |
| app.post('/api/config', async (req, res) => { | |
| try { | |
| const { debug, devMode, logLevel, persistTokenCache, requestThrottlingEnabled, requestDelayMs } = req.body; | |
| // Validate tunable config fields via shared helper | |
| const updates = validateConfigFields(req.body); | |
| // Handle fields not covered by the shared helper | |
| if (typeof devMode === 'boolean') { | |
| updates.devMode = devMode; | |
| updates.debug = devMode; | |
| logger.setDebug(devMode); | |
| } else if (typeof debug === 'boolean') { | |
| updates.debug = debug; | |
| updates.devMode = debug; | |
| logger.setDebug(debug); | |
| } | |
| if (logLevel && ['info', 'warn', 'error', 'debug'].includes(logLevel)) { | |
| updates.logLevel = logLevel; | |
| } | |
| if (typeof persistTokenCache === 'boolean') { | |
| updates.persistTokenCache = persistTokenCache; | |
| } | |
| if (typeof requestThrottlingEnabled === 'boolean') { | |
| updates.requestThrottlingEnabled = requestThrottlingEnabled; | |
| } | |
| if (typeof requestDelayMs === 'number' && requestDelayMs >= 100 && requestDelayMs <= 5000) { | |
| updates.requestDelayMs = requestDelayMs; | |
| } | |
| if (Object.keys(updates).length === 0) { | |
| return res.status(400).json({ | |
| status: 'error', | |
| error: 'No valid configuration updates provided' | |
| }); | |
| } | |
| const success = saveConfig(updates); | |
| if (success) { | |
| // Hot-reload strategy if it was changed (no server restart needed) | |
| if (updates.accountSelection?.strategy && accountManager) { | |
| await accountManager.reload(); | |
| logger.info(`[WebUI] Strategy hot-reloaded to: ${updates.accountSelection.strategy}`); | |
| } | |
| res.json({ | |
| status: 'ok', | |
| message: 'Configuration saved. Restart server to apply some changes.', | |
| updates: updates, | |
| config: getPublicConfig() | |
| }); | |
| } else { | |
| res.status(500).json({ | |
| status: 'error', | |
| error: 'Failed to save configuration file' | |
| }); | |
| } | |
| } catch (error) { | |
| logger.error('[WebUI] Error updating config:', error); | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| /** | |
| * POST /api/config/password - Change WebUI password | |
| */ | |
| app.post('/api/config/password', (req, res) => { | |
| try { | |
| const { oldPassword, newPassword } = req.body; | |
| // Validate input | |
| if (!newPassword || typeof newPassword !== 'string') { | |
| return res.status(400).json({ | |
| status: 'error', | |
| error: 'New password is required' | |
| }); | |
| } | |
| // If current password exists, verify old password | |
| if (config.webuiPassword && config.webuiPassword !== oldPassword) { | |
| return res.status(403).json({ | |
| status: 'error', | |
| error: 'Invalid current password' | |
| }); | |
| } | |
| // Save new password | |
| const success = saveConfig({ webuiPassword: newPassword }); | |
| if (success) { | |
| // Update in-memory config | |
| config.webuiPassword = newPassword; | |
| res.json({ | |
| status: 'ok', | |
| message: 'Password changed successfully' | |
| }); | |
| } else { | |
| throw new Error('Failed to save password to config file'); | |
| } | |
| } catch (error) { | |
| logger.error('[WebUI] Error changing password:', error); | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| /** | |
| * GET /api/settings - Get runtime settings | |
| */ | |
| app.get('/api/settings', async (req, res) => { | |
| try { | |
| const settings = accountManager.getSettings ? accountManager.getSettings() : {}; | |
| res.json({ | |
| status: 'ok', | |
| settings: { | |
| ...settings, | |
| port: process.env.PORT || DEFAULT_PORT | |
| } | |
| }); | |
| } catch (error) { | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| // ========================================== | |
| // Claude CLI Configuration API | |
| // ========================================== | |
| /** | |
| * GET /api/claude/config - Get Claude CLI configuration | |
| */ | |
| app.get('/api/claude/config', async (req, res) => { | |
| try { | |
| const claudeConfig = await readClaudeConfig(); | |
| res.json({ | |
| status: 'ok', | |
| config: claudeConfig, | |
| path: getClaudeConfigPath() | |
| }); | |
| } catch (error) { | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| /** | |
| * POST /api/claude/config - Update Claude CLI configuration | |
| */ | |
| app.post('/api/claude/config', async (req, res) => { | |
| try { | |
| const updates = req.body; | |
| if (!updates || typeof updates !== 'object') { | |
| return res.status(400).json({ status: 'error', error: 'Invalid config updates' }); | |
| } | |
| const newConfig = await updateClaudeConfig(updates); | |
| res.json({ | |
| status: 'ok', | |
| config: newConfig, | |
| message: 'Claude configuration updated' | |
| }); | |
| } catch (error) { | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| /** | |
| * POST /api/claude/config/restore - Restore Claude CLI to default (remove proxy settings) | |
| */ | |
| app.post('/api/claude/config/restore', async (req, res) => { | |
| try { | |
| const claudeConfig = await readClaudeConfig(); | |
| // Proxy-related environment variables to remove when restoring defaults | |
| const PROXY_ENV_VARS = [ | |
| 'ANTHROPIC_BASE_URL', | |
| 'ANTHROPIC_AUTH_TOKEN', | |
| 'ANTHROPIC_MODEL', | |
| 'CLAUDE_CODE_SUBAGENT_MODEL', | |
| 'ANTHROPIC_DEFAULT_OPUS_MODEL', | |
| 'ANTHROPIC_DEFAULT_SONNET_MODEL', | |
| 'ANTHROPIC_DEFAULT_HAIKU_MODEL', | |
| 'ENABLE_EXPERIMENTAL_MCP_CLI' | |
| ]; | |
| // Remove proxy-related environment variables to restore defaults | |
| if (claudeConfig.env) { | |
| for (const key of PROXY_ENV_VARS) { | |
| delete claudeConfig.env[key]; | |
| } | |
| // Remove env entirely if empty to truly restore defaults | |
| if (Object.keys(claudeConfig.env).length === 0) { | |
| delete claudeConfig.env; | |
| } | |
| } | |
| // Use replaceClaudeConfig to completely overwrite the config (not merge) | |
| const newConfig = await replaceClaudeConfig(claudeConfig); | |
| logger.info(`[WebUI] Restored Claude CLI config to defaults at ${getClaudeConfigPath()}`); | |
| res.json({ | |
| status: 'ok', | |
| config: newConfig, | |
| message: 'Claude CLI configuration restored to defaults' | |
| }); | |
| } catch (error) { | |
| logger.error('[WebUI] Error restoring Claude config:', error); | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| // ========================================== | |
| // Claude CLI Mode Toggle API (Proxy/Paid) | |
| // ========================================== | |
| /** | |
| * GET /api/claude/mode - Get current mode (proxy or paid) | |
| * Returns 'proxy' if ANTHROPIC_BASE_URL is set to localhost, 'paid' otherwise | |
| */ | |
| app.get('/api/claude/mode', async (req, res) => { | |
| try { | |
| const claudeConfig = await readClaudeConfig(); | |
| const baseUrl = claudeConfig.env?.ANTHROPIC_BASE_URL || ''; | |
| // Determine mode based on ANTHROPIC_BASE_URL | |
| const isProxy = baseUrl && ( | |
| baseUrl.includes('localhost') || | |
| baseUrl.includes('127.0.0.1') || | |
| baseUrl.includes('::1') || | |
| baseUrl.includes('0.0.0.0') | |
| ); | |
| res.json({ | |
| status: 'ok', | |
| mode: isProxy ? 'proxy' : 'paid' | |
| }); | |
| } catch (error) { | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| /** | |
| * POST /api/claude/mode - Switch between proxy and paid mode | |
| * Body: { mode: 'proxy' | 'paid' } | |
| * | |
| * When switching to 'paid' mode: | |
| * - Removes the entire 'env' object from settings.json | |
| * - Claude CLI uses its built-in defaults (official Anthropic API) | |
| * | |
| * When switching to 'proxy' mode: | |
| * - Sets 'env' to the first default preset config (from constants.js) | |
| */ | |
| app.post('/api/claude/mode', async (req, res) => { | |
| try { | |
| const { mode } = req.body; | |
| if (!mode || !['proxy', 'paid'].includes(mode)) { | |
| return res.status(400).json({ | |
| status: 'error', | |
| error: 'mode must be "proxy" or "paid"' | |
| }); | |
| } | |
| const claudeConfig = await readClaudeConfig(); | |
| if (mode === 'proxy') { | |
| // Switch to proxy mode - use first default preset config (e.g., "Claude Thinking") | |
| claudeConfig.env = { ...DEFAULT_PRESETS[0].config }; | |
| } else { | |
| // Switch to paid mode - remove env entirely | |
| delete claudeConfig.env; | |
| } | |
| // Save the updated config | |
| const newConfig = await replaceClaudeConfig(claudeConfig); | |
| logger.info(`[WebUI] Switched Claude CLI to ${mode} mode`); | |
| res.json({ | |
| status: 'ok', | |
| mode, | |
| config: newConfig, | |
| message: `Switched to ${mode === 'proxy' ? 'Proxy' : 'Paid (Anthropic API)'} mode. Restart Claude CLI to apply.` | |
| }); | |
| } catch (error) { | |
| logger.error('[WebUI] Error switching mode:', error); | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| // ========================================== | |
| // Claude CLI Presets API | |
| // ========================================== | |
| /** | |
| * GET /api/claude/presets - Get all saved presets | |
| */ | |
| app.get('/api/claude/presets', async (req, res) => { | |
| try { | |
| const presets = await readPresets(); | |
| res.json({ status: 'ok', presets }); | |
| } catch (error) { | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| /** | |
| * POST /api/claude/presets - Save a new preset | |
| */ | |
| app.post('/api/claude/presets', async (req, res) => { | |
| try { | |
| const { name, config: presetConfig } = req.body; | |
| if (!name || typeof name !== 'string' || !name.trim()) { | |
| return res.status(400).json({ status: 'error', error: 'Preset name is required' }); | |
| } | |
| if (!presetConfig || typeof presetConfig !== 'object') { | |
| return res.status(400).json({ status: 'error', error: 'Config object is required' }); | |
| } | |
| const presets = await savePreset(name.trim(), presetConfig); | |
| res.json({ status: 'ok', presets, message: `Preset "${name}" saved` }); | |
| } catch (error) { | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| /** | |
| * DELETE /api/claude/presets/:name - Delete a preset | |
| */ | |
| app.delete('/api/claude/presets/:name', async (req, res) => { | |
| try { | |
| const { name } = req.params; | |
| if (!name) { | |
| return res.status(400).json({ status: 'error', error: 'Preset name is required' }); | |
| } | |
| const presets = await deletePreset(name); | |
| res.json({ status: 'ok', presets, message: `Preset "${name}" deleted` }); | |
| } catch (error) { | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| // ========================================== | |
| // Server Configuration Presets API | |
| // ========================================== | |
| /** | |
| * GET /api/server/presets - List all server config presets | |
| */ | |
| app.get('/api/server/presets', async (req, res) => { | |
| try { | |
| const presets = await readServerPresets(); | |
| res.json({ status: 'ok', presets }); | |
| } catch (error) { | |
| logger.error('[WebUI] Error reading server presets:', error); | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| /** | |
| * POST /api/server/presets - Save a custom server config preset | |
| */ | |
| app.post('/api/server/presets', async (req, res) => { | |
| try { | |
| const { name, config: presetConfig, description } = req.body; | |
| if (!name || typeof name !== 'string' || !name.trim()) { | |
| return res.status(400).json({ status: 'error', error: 'Preset name is required' }); | |
| } | |
| if (name.trim().length > 50) { | |
| return res.status(400).json({ status: 'error', error: 'Preset name must be 50 characters or fewer' }); | |
| } | |
| if (!presetConfig || typeof presetConfig !== 'object' || Array.isArray(presetConfig)) { | |
| return res.status(400).json({ status: 'error', error: 'Config object is required' }); | |
| } | |
| const validatedConfig = validateConfigFields(presetConfig); | |
| if (Object.keys(validatedConfig).length === 0) { | |
| return res.status(400).json({ status: 'error', error: 'No valid config fields provided' }); | |
| } | |
| const presets = await saveServerPreset(name.trim(), validatedConfig, description); | |
| res.json({ status: 'ok', presets, message: `Server preset "${name}" saved` }); | |
| } catch (error) { | |
| const status = error.message.includes('built-in') ? 400 : 500; | |
| res.status(status).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| /** | |
| * PATCH /api/server/presets/:name - Update custom preset metadata and/or config | |
| */ | |
| app.patch('/api/server/presets/:name', async (req, res) => { | |
| try { | |
| const { name: currentName } = req.params; | |
| if (!currentName) { | |
| return res.status(400).json({ status: 'error', error: 'Preset name is required' }); | |
| } | |
| const { name: newName, description, config: configInput } = req.body; | |
| if (typeof newName === 'string' && !newName.trim()) { | |
| return res.status(400).json({ status: 'error', error: 'Preset name is required' }); | |
| } | |
| if (typeof newName === 'string' && newName.trim().length > 50) { | |
| return res.status(400).json({ status: 'error', error: 'Preset name must be 50 characters or fewer' }); | |
| } | |
| const updates = {}; | |
| if (newName !== undefined) updates.name = newName.trim(); | |
| if (description !== undefined) updates.description = description; | |
| // Validate and include config updates if provided | |
| if (configInput && typeof configInput === 'object') { | |
| const validatedConfig = validateConfigFields(configInput); | |
| if (Object.keys(validatedConfig).length > 0) { | |
| updates.config = validatedConfig; | |
| } | |
| } | |
| if (Object.keys(updates).length === 0) { | |
| return res.status(400).json({ status: 'error', error: 'No updates provided' }); | |
| } | |
| const presets = await updateServerPreset(currentName, updates); | |
| res.json({ status: 'ok', presets, message: `Server preset "${currentName}" updated` }); | |
| } catch (error) { | |
| const status = error.message.includes('built-in') || error.message.includes('not found') || error.message.includes('already exists') ? 400 : 500; | |
| res.status(status).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| /** | |
| * DELETE /api/server/presets/:name - Delete a custom server config preset | |
| */ | |
| app.delete('/api/server/presets/:name', async (req, res) => { | |
| try { | |
| const { name } = req.params; | |
| if (!name) { | |
| return res.status(400).json({ status: 'error', error: 'Preset name is required' }); | |
| } | |
| const presets = await deleteServerPreset(name); | |
| res.json({ status: 'ok', presets, message: `Server preset "${name}" deleted` }); | |
| } catch (error) { | |
| const status = error.message.includes('built-in') ? 400 : 500; | |
| res.status(status).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| /** | |
| * POST /api/models/config - Update model configuration (hidden/pinned/alias) | |
| */ | |
| app.post('/api/models/config', (req, res) => { | |
| try { | |
| const { modelId, config: newModelConfig } = req.body; | |
| if (!modelId || typeof newModelConfig !== 'object') { | |
| return res.status(400).json({ status: 'error', error: 'Invalid parameters' }); | |
| } | |
| // Load current config | |
| const currentMapping = config.modelMapping || {}; | |
| // Update specific model config | |
| currentMapping[modelId] = { | |
| ...currentMapping[modelId], | |
| ...newModelConfig | |
| }; | |
| // Save back to main config | |
| const success = saveConfig({ modelMapping: currentMapping }); | |
| if (success) { | |
| // Update in-memory config reference | |
| config.modelMapping = currentMapping; | |
| res.json({ status: 'ok', modelConfig: currentMapping[modelId] }); | |
| } else { | |
| throw new Error('Failed to save configuration'); | |
| } | |
| } catch (error) { | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| // ========================================== | |
| // Logs API | |
| // ========================================== | |
| /** | |
| * GET /api/logs - Get log history | |
| */ | |
| app.get('/api/logs', (req, res) => { | |
| res.json({ | |
| status: 'ok', | |
| logs: logger.getHistory ? logger.getHistory() : [] | |
| }); | |
| }); | |
| /** | |
| * GET /api/logs/stream - Stream logs via SSE | |
| */ | |
| app.get('/api/logs/stream', (req, res) => { | |
| res.setHeader('Content-Type', 'text/event-stream'); | |
| res.setHeader('Cache-Control', 'no-cache'); | |
| res.setHeader('Connection', 'keep-alive'); | |
| const sendLog = (log) => { | |
| res.write(`data: ${JSON.stringify(log)}\n\n`); | |
| }; | |
| // Send recent history if requested | |
| if (req.query.history === 'true' && logger.getHistory) { | |
| const history = logger.getHistory(); | |
| history.forEach(log => sendLog(log)); | |
| } | |
| // Subscribe to new logs | |
| if (logger.on) { | |
| logger.on('log', sendLog); | |
| } | |
| // Cleanup on disconnect | |
| req.on('close', () => { | |
| if (logger.off) { | |
| logger.off('log', sendLog); | |
| } | |
| }); | |
| }); | |
| // ========================================== | |
| // Strategy Health API (Developer Mode) | |
| // ========================================== | |
| /** | |
| * GET /api/strategy/health - Get strategy health data for the inspector panel | |
| * Only available when devMode is enabled | |
| */ | |
| app.get('/api/strategy/health', (req, res) => { | |
| try { | |
| if (!config.devMode) { | |
| return res.status(403).json({ | |
| status: 'error', | |
| error: 'Developer mode is not enabled' | |
| }); | |
| } | |
| const healthData = accountManager.getStrategyHealthData(); | |
| res.json({ | |
| status: 'ok', | |
| ...healthData | |
| }); | |
| } catch (error) { | |
| logger.error('[WebUI] Error fetching strategy health:', error); | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| // ========================================== | |
| // OAuth API | |
| // ========================================== | |
| /** | |
| * GET /api/auth/url - Get OAuth URL to start the flow | |
| * Uses CLI's OAuth flow (localhost:51121) instead of WebUI's port | |
| * to match Google OAuth Console's authorized redirect URIs | |
| */ | |
| app.get('/api/auth/url', async (req, res) => { | |
| try { | |
| // Clean up old flows (> 10 mins) | |
| const now = Date.now(); | |
| for (const [key, val] of pendingOAuthFlows.entries()) { | |
| if (now - val.timestamp > 10 * 60 * 1000) { | |
| pendingOAuthFlows.delete(key); | |
| } | |
| } | |
| // Generate OAuth URL using default redirect URI (localhost:51121) | |
| const { url, verifier, state } = getAuthorizationUrl(); | |
| // Start callback server on port 51121 (same as CLI) | |
| const { promise: serverPromise, abort: abortServer } = startCallbackServer(state, 120000); // 2 min timeout | |
| // Store the flow data | |
| pendingOAuthFlows.set(state, { | |
| serverPromise, | |
| abortServer, | |
| verifier, | |
| state, | |
| timestamp: Date.now() | |
| }); | |
| // Start async handler for the OAuth callback | |
| serverPromise | |
| .then(async (code) => { | |
| try { | |
| logger.info('[WebUI] Received OAuth callback, completing flow...'); | |
| const accountData = await completeOAuthFlow(code, verifier); | |
| // Add or update the account | |
| // Note: Don't set projectId here - it will be discovered and stored | |
| // in the refresh token via getProjectForAccount() on first use | |
| await addAccount({ | |
| email: accountData.email, | |
| refreshToken: accountData.refreshToken, | |
| source: 'oauth' | |
| }); | |
| // Reload AccountManager to pick up the new account | |
| await accountManager.reload(); | |
| logger.success(`[WebUI] Account ${accountData.email} added successfully`); | |
| } catch (err) { | |
| logger.error('[WebUI] OAuth flow completion error:', err); | |
| } finally { | |
| pendingOAuthFlows.delete(state); | |
| } | |
| }) | |
| .catch((err) => { | |
| // Only log if not aborted (manual completion causes this) | |
| if (!err.message?.includes('aborted')) { | |
| logger.error('[WebUI] OAuth callback server error:', err); | |
| } | |
| pendingOAuthFlows.delete(state); | |
| }); | |
| res.json({ status: 'ok', url, state }); | |
| } catch (error) { | |
| logger.error('[WebUI] Error generating auth URL:', error); | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| /** | |
| * POST /api/auth/complete - Complete OAuth with manually submitted callback URL/code | |
| * Used when auto-callback cannot reach the local server | |
| */ | |
| app.post('/api/auth/complete', async (req, res) => { | |
| try { | |
| const { callbackInput, state } = req.body; | |
| if (!callbackInput || !state) { | |
| return res.status(400).json({ | |
| status: 'error', | |
| error: 'Missing callbackInput or state' | |
| }); | |
| } | |
| // Find the pending flow | |
| const flowData = pendingOAuthFlows.get(state); | |
| if (!flowData) { | |
| return res.status(400).json({ | |
| status: 'error', | |
| error: 'OAuth flow not found. The account may have been already added via auto-callback. Please refresh the account list.' | |
| }); | |
| } | |
| const { verifier, abortServer } = flowData; | |
| // Extract code from input (URL or raw code) | |
| const { extractCodeFromInput, completeOAuthFlow } = await import('../auth/oauth.js'); | |
| const { code } = extractCodeFromInput(callbackInput); | |
| // Complete the OAuth flow | |
| const accountData = await completeOAuthFlow(code, verifier); | |
| // Add or update the account | |
| await addAccount({ | |
| email: accountData.email, | |
| refreshToken: accountData.refreshToken, | |
| projectId: accountData.projectId, | |
| source: 'oauth' | |
| }); | |
| // Reload AccountManager to pick up the new account | |
| await accountManager.reload(); | |
| // Abort the callback server since manual completion succeeded | |
| if (abortServer) { | |
| abortServer(); | |
| } | |
| // Clean up | |
| pendingOAuthFlows.delete(state); | |
| logger.success(`[WebUI] Account ${accountData.email} added via manual callback`); | |
| res.json({ | |
| status: 'ok', | |
| email: accountData.email, | |
| message: `Account ${accountData.email} added successfully` | |
| }); | |
| } catch (error) { | |
| logger.error('[WebUI] Manual OAuth completion error:', error); | |
| res.status(500).json({ status: 'error', error: error.message }); | |
| } | |
| }); | |
| /** | |
| * Note: /oauth/callback route removed | |
| * OAuth callbacks are now handled by the temporary server on port 51121 | |
| * (same as CLI) to match Google OAuth Console's authorized redirect URIs | |
| */ | |
| logger.info('[WebUI] Mounted at /'); | |
| } | |