feat: per-account quota threshold protection Resolves #135 - Adds configurable quota protection with three-tier threshold resolution (per-model → per-account → global) - New global Minimum Quota Level slider in Settings - Per-account threshold settings via Account Settings modal - Draggable per-account threshold markers on model quota bars - Backend: PATCH /api/accounts/:email endpoint, globalQuotaThreshold config - i18n: quota protection keys for all 5 languages
1051 lines
39 KiB
JavaScript
1051 lines
39 KiB
JavaScript
/**
|
|
* 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 } from '../constants.js';
|
|
import { readClaudeConfig, updateClaudeConfig, replaceClaudeConfig, getClaudeConfigPath, readPresets, savePreset, deletePreset } from '../utils/claude-config.js';
|
|
import { logger } from '../utils/logger.js';
|
|
import { getAuthorizationUrl, completeOAuthFlow, startCallbackServer } from '../auth/oauth.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) {
|
|
const providedPassword = req.headers['x-webui-password'] || req.query.password;
|
|
if (providedPassword !== password) {
|
|
return res.status(401).json({ status: 'error', error: 'Unauthorized: Password required' });
|
|
}
|
|
}
|
|
next();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
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', (req, res) => {
|
|
try {
|
|
const { debug, logLevel, maxRetries, retryBaseMs, retryMaxMs, persistTokenCache, defaultCooldownMs, maxWaitBeforeErrorMs, maxAccounts, globalQuotaThreshold, accountSelection, rateLimitDedupWindowMs, maxConsecutiveFailures, extendedCooldownMs, maxCapacityRetries } = req.body;
|
|
|
|
// Only allow updating specific fields (security)
|
|
const updates = {};
|
|
if (typeof debug === 'boolean') updates.debug = debug;
|
|
if (logLevel && ['info', 'warn', 'error', 'debug'].includes(logLevel)) {
|
|
updates.logLevel = logLevel;
|
|
}
|
|
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 persistTokenCache === 'boolean') {
|
|
updates.persistTokenCache = persistTokenCache;
|
|
}
|
|
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;
|
|
}
|
|
// Account selection strategy validation
|
|
if (accountSelection && typeof accountSelection === 'object') {
|
|
const validStrategies = ['sticky', 'round-robin', 'hybrid'];
|
|
if (accountSelection.strategy && validStrategies.includes(accountSelection.strategy)) {
|
|
updates.accountSelection = {
|
|
...(config.accountSelection || {}),
|
|
strategy: accountSelection.strategy
|
|
};
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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 });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// 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 /');
|
|
}
|