UNPKG

claude-switcher

Version:

Cross-platform CLI tool for switching between different Claude AI model configurations. Supports automatic backup, rollback, and multi-platform configuration management for Claude API integrations.

853 lines (852 loc) • 35.6 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.UIServer = void 0; const express_1 = __importDefault(require("express")); const path_1 = __importDefault(require("path")); const fs_1 = __importDefault(require("fs")); const chalk_1 = __importDefault(require("chalk")); function getPackageVersion() { try { const packageJsonPath = path_1.default.join(__dirname, '..', '..', 'package.json'); const packageJson = JSON.parse(fs_1.default.readFileSync(packageJsonPath, 'utf8')); return packageJson.version; } catch (error) { return '1.0.4'; } } const ErrorHandler_1 = require("../utils/ErrorHandler"); const OutputFormatter_1 = require("../utils/OutputFormatter"); const ConnectionTester_1 = require("./utils/ConnectionTester"); class UIServer { constructor(configManager, options) { this.server = null; this.io = null; this.fileWatcher = null; this.app = (0, express_1.default)(); this.configManager = configManager; this.options = options; this.setupMiddleware(); this.setupRoutes(); } setupMiddleware() { this.app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization'); if (req.method === 'OPTIONS') { res.sendStatus(200); } else { next(); } }); this.app.use(express_1.default.json({ limit: '10mb' })); this.app.use(express_1.default.urlencoded({ extended: true, limit: '10mb' })); if (process.env.NODE_ENV !== 'production') { this.app.use((req, _res, next) => { console.log(chalk_1.default.gray(`${new Date().toISOString()} ${req.method} ${req.path}`)); next(); }); } this.app.use(this.errorHandler.bind(this)); const staticPath = path_1.default.join(__dirname, '../../dist/ui-frontend'); this.app.use(express_1.default.static(staticPath)); } setupRoutes() { this.app.get('/api/health', this.handleHealthCheck.bind(this)); this.app.get('/api/configs', this.handleGetConfigs.bind(this)); this.app.get('/api/configs/:name', this.handleGetConfig.bind(this)); this.app.post('/api/configs', this.handleCreateConfig.bind(this)); this.app.put('/api/configs/:name', this.handleUpdateConfig.bind(this)); this.app.delete('/api/configs/:name', this.handleDeleteConfig.bind(this)); this.app.post('/api/configs/:name/activate', this.handleActivateConfig.bind(this)); this.app.post('/api/configs/:name/test', this.handleTestConfig.bind(this)); this.app.get('/api/backups', this.handleGetBackups.bind(this)); this.app.post('/api/backups', this.handleCreateBackup.bind(this)); this.app.post('/api/backups/:id/restore', this.handleRestoreBackup.bind(this)); this.app.delete('/api/backups/:id', this.handleDeleteBackup.bind(this)); this.app.get('/api/providers', this.handleGetProviders.bind(this)); this.app.post('/api/providers/:name/models', this.handleGetProviderModels.bind(this)); this.app.get('/api/providers/:name/docs', this.handleGetProviderDocs.bind(this)); this.app.get('/api/providers/cache/status', this.handleGetCacheStatus.bind(this)); this.app.delete('/api/providers/cache/:name?', this.handleClearCache.bind(this)); this.app.get('/api/system/status', this.handleGetSystemStatus.bind(this)); this.app.get('/api/system/verify', this.handleVerifySystem.bind(this)); this.app.post('/api/system/sync', this.handleSyncSystem.bind(this)); this.app.get('*', (_req, res) => { const indexPath = path_1.default.join(__dirname, '../../dist/ui-frontend/index.html'); res.sendFile(indexPath, (err) => { if (err) { res.status(404).send('UI not built. Please run the build process first.'); } }); }); } async handleHealthCheck(_req, res) { res.json({ status: 'ok', timestamp: new Date().toISOString(), version: getPackageVersion() }); } async handleGetConfigs(_req, res) { try { const configs = await this.configManager.getAvailableConfigs(); const activeConfig = await this.configManager.getCurrentConfig(); const enhancedConfigs = configs.map(config => ({ ...config, provider: this.extractProvider(config.fileName), description: `${config.displayName} configuration`, createdAt: new Date(), updatedAt: new Date(), tags: [this.extractProvider(config.fileName)], isValid: true })); res.json({ configs: enhancedConfigs, activeConfig: activeConfig ? configs.find(c => c.isActive)?.name : null, total: configs.length }); } catch (error) { this.sendError(res, 500, 'Failed to get configurations', error); } } async handleGetConfig(req, res) { try { const { name } = req.params; const existingConfigs = await this.configManager.getAvailableConfigs(); const configInfo = existingConfigs.find(c => c.name === name); if (!configInfo) { return this.sendError(res, 404, `Configuration '${name}' not found`); } const configPath = path_1.default.join(this.configManager.getClaudeDir(), `settings_${name}.json`); const configData = await fs_1.default.promises.readFile(configPath, 'utf-8'); const fullConfig = JSON.parse(configData); const enhancedConfig = { name: configInfo.name, fileName: configInfo.fileName, displayName: configInfo.displayName, model: configInfo.model, isActive: configInfo.isActive, filePath: configInfo.filePath, apiBaseUrl: fullConfig.env?.ANTHROPIC_BASE_URL || '', primaryModel: fullConfig.env?.ANTHROPIC_MODEL || '', fastModel: fullConfig.env?.ANTHROPIC_SMALL_FAST_MODEL || fullConfig.env?.ANTHROPIC_MODEL || '', provider: this.extractProvider(configInfo.fileName), description: `${configInfo.displayName} configuration`, createdAt: new Date(), updatedAt: new Date(), tags: [this.extractProvider(configInfo.fileName)], isValid: true, apiKey: fullConfig.env?.ANTHROPIC_AUTH_TOKEN ? this.maskApiKey(fullConfig.env.ANTHROPIC_AUTH_TOKEN) : '' }; res.json({ config: enhancedConfig }); } catch (error) { this.sendError(res, 500, 'Failed to get configuration', error); } } async handleCreateConfig(req, res) { try { const { name, config } = req.body; if (!name || !config) { return this.sendError(res, 400, 'Missing required fields: name and config'); } const validation = this.configManager.validateConfigIntegrity(config); const formatValidation = ConnectionTester_1.ConnectionTester.validateConfigFormat(config); if (!validation.isValid || !formatValidation.isValid) { const allErrors = [...validation.errors, ...formatValidation.errors]; return this.sendError(res, 400, 'Invalid configuration format', { errors: allErrors, warnings: formatValidation.warnings }); } const existingConfigs = await this.configManager.getAvailableConfigs(); if (existingConfigs.some(c => c.name === name)) { return this.sendError(res, 409, `Configuration '${name}' already exists`); } const configPath = path_1.default.join(this.configManager.getClaudeDir(), `settings_${name}.json`); await fs_1.default.promises.writeFile(configPath, JSON.stringify(config, null, 2)); const newConfigs = await this.configManager.getAvailableConfigs(); const createdConfig = newConfigs.find(c => c.name === name); if (createdConfig) { this.broadcastConfigChange(createdConfig); res.status(201).json({ message: 'Configuration created successfully', config: createdConfig }); } else { this.sendError(res, 500, 'Failed to create configuration'); } } catch (error) { this.sendError(res, 500, 'Failed to create configuration', error); } } async handleUpdateConfig(req, res) { try { const { name } = req.params; const { config } = req.body; if (!config) { return this.sendError(res, 400, 'Missing required field: config'); } const validation = this.configManager.validateConfigIntegrity(config); const formatValidation = ConnectionTester_1.ConnectionTester.validateConfigFormat(config); if (!validation.isValid || !formatValidation.isValid) { const allErrors = [...validation.errors, ...formatValidation.errors]; return this.sendError(res, 400, 'Invalid configuration format', { errors: allErrors, warnings: formatValidation.warnings }); } const existingConfigs = await this.configManager.getAvailableConfigs(); const existingConfig = existingConfigs.find(c => c.name === name); if (!existingConfig) { return this.sendError(res, 404, `Configuration '${name}' not found`); } if (existingConfig.isActive) { await this.configManager.createBackup(); } const configPath = path_1.default.join(this.configManager.getClaudeDir(), `settings_${name}.json`); await fs_1.default.promises.writeFile(configPath, JSON.stringify(config, null, 2)); const updatedConfigs = await this.configManager.getAvailableConfigs(); const updatedConfig = updatedConfigs.find(c => c.name === name); if (updatedConfig) { this.broadcastConfigChange(updatedConfig); res.json({ message: 'Configuration updated successfully', config: updatedConfig }); } else { this.sendError(res, 500, 'Failed to update configuration'); } } catch (error) { this.sendError(res, 500, 'Failed to update configuration', error); } } async handleDeleteConfig(req, res) { try { const { name } = req.params; const existingConfigs = await this.configManager.getAvailableConfigs(); const configToDelete = existingConfigs.find(c => c.name === name); if (!configToDelete) { return this.sendError(res, 404, `Configuration '${name}' not found`); } if (configToDelete.isActive) { return this.sendError(res, 400, 'Cannot delete active configuration. Please switch to another configuration first.'); } const configPath = path_1.default.join(this.configManager.getClaudeDir(), `settings_${name}.json`); await fs_1.default.promises.unlink(configPath); this.io?.emit('config:deleted', { name }); res.json({ message: 'Configuration deleted successfully', name }); } catch (error) { this.sendError(res, 500, 'Failed to delete configuration', error); } } async handleActivateConfig(req, res) { try { const { name } = req.params; const switchedConfig = await this.configManager.switchToConfig(name); this.io?.emit('config:activated', name); res.json({ message: 'Configuration activated successfully', config: switchedConfig }); } catch (error) { this.sendError(res, 500, 'Failed to activate configuration', error); } } async handleTestConfig(req, res) { try { const { name } = req.params; const { config: testConfig } = req.body; let configToTest; if (testConfig) { configToTest = testConfig; } else { const existingConfigs = await this.configManager.getAvailableConfigs(); const existingConfig = existingConfigs.find(c => c.name === name); if (!existingConfig) { return this.sendError(res, 404, `Configuration '${name}' not found`); } const configPath = path_1.default.join(this.configManager.getClaudeDir(), `settings_${name}.json`); const configData = await fs_1.default.promises.readFile(configPath, 'utf-8'); configToTest = JSON.parse(configData); } const testResult = await this.performConnectionTest(configToTest); res.json({ message: 'Connection test completed', result: testResult }); } catch (error) { this.sendError(res, 500, 'Failed to test configuration', error); } } async handleGetBackups(_req, res) { try { const backups = await this.configManager.getBackupList(); const backupInfo = await Promise.all(backups.map(async (backup) => { const backupPath = path_1.default.join(this.configManager.getClaudeDir(), backup); const stats = await fs_1.default.promises.stat(backupPath); return { id: backup, fileName: backup, originalConfig: this.extractOriginalConfigFromBackup(backup), createdAt: stats.birthtime, size: stats.size, description: `Backup created on ${stats.birthtime.toLocaleString()}` }; })); res.json({ backups: backupInfo, total: backups.length }); } catch (error) { this.sendError(res, 500, 'Failed to get backups', error); } } async handleCreateBackup(req, res) { try { const { description } = req.body; const backupPath = await this.configManager.createBackup(); const backupFileName = path_1.default.basename(backupPath); this.io?.emit('backup:created', { id: backupFileName, fileName: backupFileName, createdAt: new Date(), description: description || `Backup created on ${new Date().toLocaleString()}` }); res.status(201).json({ message: 'Backup created successfully', backup: { id: backupFileName, fileName: backupFileName, path: backupPath } }); } catch (error) { this.sendError(res, 500, 'Failed to create backup', error); } } async handleRestoreBackup(req, res) { try { const { id } = req.params; const restoredConfig = await this.configManager.restoreFromBackup(id); res.json({ message: 'Backup restored successfully', config: restoredConfig }); } catch (error) { this.sendError(res, 500, 'Failed to restore backup', error); } } async handleDeleteBackup(req, res) { try { const { id } = req.params; const backupPath = path_1.default.join(this.configManager.getClaudeDir(), id); await fs_1.default.promises.unlink(backupPath); res.json({ message: 'Backup deleted successfully', id }); } catch (error) { this.sendError(res, 500, 'Failed to delete backup', error); } } async handleGetProviders(_req, res) { try { const providers = this.getProviderTemplates(); res.json({ providers, total: providers.length }); } catch (error) { this.sendError(res, 500, 'Failed to get providers', error); } } async handleGetProviderModels(req, res) { try { const { name } = req.params; const { refresh = 'false' } = req.query; const { config } = req.body; const { ModelService } = await Promise.resolve().then(() => __importStar(require('./utils/ModelService'))); const forceRefresh = refresh === 'true'; const result = await ModelService.getModels(name, config, forceRefresh); res.json(result); } catch (error) { this.sendError(res, 500, 'Failed to get provider models', error); } } async handleGetProviderDocs(req, res) { try { const { name } = req.params; const docs = this.getProviderDocumentation(name); res.json({ provider: name, documentation: docs }); } catch (error) { this.sendError(res, 500, 'Failed to get provider documentation', error); } } async handleGetCacheStatus(_req, res) { try { const { ModelService } = await Promise.resolve().then(() => __importStar(require('./utils/ModelService'))); const cacheStatus = ModelService.getCacheStatus(); res.json({ cache: cacheStatus, timestamp: new Date().toISOString() }); } catch (error) { this.sendError(res, 500, 'Failed to get cache status', error); } } async handleClearCache(req, res) { try { const { name } = req.params; const { ModelService } = await Promise.resolve().then(() => __importStar(require('./utils/ModelService'))); ModelService.clearCache(name); res.json({ message: name ? `Cache cleared for ${name}` : 'All cache cleared', provider: name || 'all', timestamp: new Date().toISOString() }); } catch (error) { this.sendError(res, 500, 'Failed to clear cache', error); } } async handleGetSystemStatus(_req, res) { try { const activeConfig = await this.configManager.getCurrentConfig(); const configDir = this.configManager.getClaudeDir(); const settingsFile = this.configManager.getSettingsFile(); res.json({ activeConfig, configDirectory: configDir, settingsFile, timestamp: new Date().toISOString(), isAccessible: await this.configManager.isClaudeDirAccessible() }); } catch (error) { this.sendError(res, 500, 'Failed to get system status', error); } } async handleVerifySystem(_req, res) { try { const currentConfig = await this.configManager.getCurrentConfig(); if (!currentConfig) { return res.json({ isConsistent: false, message: 'No active configuration found', issues: ['No settings.json file found'] }); } const validation = this.configManager.validateConfigIntegrity(currentConfig); res.json({ isConsistent: validation.isValid, message: validation.isValid ? 'Environment is consistent' : 'Environment has issues', issues: validation.errors, config: currentConfig }); } catch (error) { this.sendError(res, 500, 'Failed to verify system', error); } } async handleSyncSystem(_req, res) { try { res.json({ message: 'System synchronized successfully', timestamp: new Date().toISOString() }); } catch (error) { this.sendError(res, 500, 'Failed to sync system', error); } } async start() { return new Promise((resolve, reject) => { try { this.server = this.app.listen(this.options.port, this.options.host, () => { const url = `http://${this.options.host}:${this.options.port}`; this.setupWebSocket(); this.setupFileWatcher(); console.log(OutputFormatter_1.OutputFormatter.createBox([ 'šŸš€ Claude Switcher UI Server Started', '', `šŸ“ Server URL: ${chalk_1.default.blue.underline(url)}`, `šŸ  Host: ${this.options.host}`, `šŸ”Œ Port: ${this.options.port}`, '', 'šŸ’” Commands:', ' • Press Ctrl+C to stop the server', ' • Visit the URL above to access the UI', '', 'šŸ“ Configuration Directory:', ` ${this.configManager.getClaudeDir()}`, '', 'šŸ”„ Real-time Features:', ' • WebSocket connections enabled', ' • File system monitoring active' ], { title: 'UI Server', style: 'rounded', color: chalk_1.default.green })); if (this.options.open) { this.openBrowser(url); } resolve(); }); this.server.on('error', (error) => { if (error.code === 'EADDRINUSE') { console.error(OutputFormatter_1.OutputFormatter.createBox([ 'āŒ Port Already in Use', '', `Port ${this.options.port} is already being used by another process.`, '', 'šŸ’” Solutions:', ` • Try a different port: cs ui --port ${this.options.port + 1}`, ' • Stop the other process using this port', ' • Use netstat or lsof to find the conflicting process' ], { title: 'Server Error', style: 'double', color: chalk_1.default.red })); } else { ErrorHandler_1.ErrorHandler.displayError(error, { operation: 'Starting UI server', severity: ErrorHandler_1.ErrorSeverity.HIGH }); } reject(error); }); this.setupGracefulShutdown(); } catch (error) { reject(error); } }); } async stop() { return new Promise((resolve) => { if (this.fileWatcher) { this.fileWatcher.close(); this.fileWatcher = null; } if (this.io) { this.io.close(); this.io = null; } if (this.server) { this.server.close(() => { console.log(chalk_1.default.yellow('\nšŸ‘‹ UI Server stopped gracefully')); resolve(); }); } else { resolve(); } }); } async openBrowser(url) { try { const open = await Promise.resolve().then(() => __importStar(require('open'))); await open.default(url); console.log(chalk_1.default.green(`🌐 Opening browser at ${url}`)); } catch (error) { console.log(chalk_1.default.yellow(`āš ļø Could not auto-open browser. Please visit: ${url}`)); console.log(chalk_1.default.gray(` Reason: ${error instanceof Error ? error.message : 'Unknown error'}`)); } } setupGracefulShutdown() { const shutdown = async (signal) => { console.log(chalk_1.default.yellow(`\n\nšŸ›‘ Received ${signal}, shutting down UI server...`)); await this.stop(); process.exit(0); }; process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGTERM', () => shutdown('SIGTERM')); } setupWebSocket() { if (!this.server) return; try { const { Server: SocketIOServer } = require('socket.io'); this.io = new SocketIOServer(this.server, { cors: { origin: "*", methods: ["GET", "POST"] } }); this.io?.on('connection', (socket) => { console.log(chalk_1.default.gray(`WebSocket client connected: ${socket.id}`)); socket.on('config:watch', () => { socket.join('config-watchers'); }); socket.on('config:unwatch', () => { socket.leave('config-watchers'); }); socket.on('system:subscribe', () => { socket.join('system-watchers'); }); socket.on('disconnect', () => { console.log(chalk_1.default.gray(`WebSocket client disconnected: ${socket.id}`)); }); }); console.log(chalk_1.default.green('āœ… WebSocket server initialized')); } catch { console.log(chalk_1.default.yellow('āš ļø WebSocket not available (socket.io not installed)')); console.log(chalk_1.default.gray(' Real-time features will be disabled')); } } setupFileWatcher() { try { const configDir = this.configManager.getClaudeDir(); this.fileWatcher = fs_1.default.watch(configDir, { recursive: false }, (eventType, filename) => { if (filename && filename.match(/^settings.*\.json$/)) { console.log(chalk_1.default.gray(`File changed: ${filename}`)); setTimeout(() => { this.handleFileChange(filename, eventType); }, 100); } }); console.log(chalk_1.default.green('āœ… File system watcher initialized')); } catch (error) { console.log(chalk_1.default.yellow('āš ļø File system watcher not available')); console.log(chalk_1.default.gray(` Reason: ${error instanceof Error ? error.message : 'Unknown error'}`)); } } async handleFileChange(filename, eventType) { try { if (eventType === 'change' || eventType === 'rename') { const configs = await this.configManager.getAvailableConfigs(); if (this.io) { this.io.to('config-watchers').emit('config:changed', { type: eventType, filename, configs }); } } } catch (error) { console.error('Error handling file change:', error); } } sendError(res, status, message, details) { const error = { error: message, message, details }; if (details instanceof Error) { error.message = details.message; error.details = details.stack; } return res.status(status).json(error); } errorHandler(error, _req, res, next) { console.error('API Error:', error); if (res.headersSent) { return next(error); } this.sendError(res, 500, 'Internal server error', error); } broadcastConfigChange(config) { if (this.io) { this.io.to('config-watchers').emit('config:changed', config); } } extractProvider(fileName) { const match = fileName.match(/^settings_(.+)\.json$/); if (match) { const name = match[1]; const providerMap = { 'deepseek': 'DeepSeek', 'qwen': 'Qwen', 'glm': 'GLM', 'kimi': 'Kimi', 'claude': 'Claude', 'openai': 'OpenAI', 'ollama': 'Ollama' }; return providerMap[name.toLowerCase()] || name; } return 'Unknown'; } extractOriginalConfigFromBackup(backupFileName) { const parts = backupFileName.split('.backup.'); return parts[0] || 'settings.json'; } async performConnectionTest(config) { try { const result = await ConnectionTester_1.ConnectionTester.testConnection(config, { timeout: 15000, retries: 2, validateModels: true }); return { success: result.success, latency: result.latency || undefined, error: result.error || undefined, modelInfo: result.modelInfo || undefined, timestamp: result.timestamp }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Connection test failed', timestamp: new Date() }; } } getProviderTemplates() { return [ { id: 'deepseek', name: 'DeepSeek', description: 'DeepSeek AI models', baseUrl: 'https://api.deepseek.com', authType: 'bearer', defaultModels: { primary: 'deepseek-chat', fast: 'deepseek-chat' }, documentationUrl: 'https://platform.deepseek.com/api-docs', signupUrl: 'https://platform.deepseek.com', icon: '🧠', category: 'AI Provider', isPopular: true }, { id: 'qwen', name: 'Qwen', description: 'Alibaba Qwen models', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', authType: 'bearer', defaultModels: { primary: 'qwen-turbo', fast: 'qwen-turbo' }, documentationUrl: 'https://help.aliyun.com/zh/dashscope', signupUrl: 'https://dashscope.console.aliyun.com', icon: '🌟', category: 'AI Provider', isPopular: true }, { id: 'openai', name: 'OpenAI', description: 'OpenAI GPT models', baseUrl: 'https://api.openai.com/v1', authType: 'bearer', defaultModels: { primary: 'gpt-4', fast: 'gpt-3.5-turbo' }, documentationUrl: 'https://platform.openai.com/docs', signupUrl: 'https://platform.openai.com', icon: 'šŸ¤–', category: 'AI Provider', isPopular: true } ]; } getProviderDocumentation(providerName) { const docMap = { 'deepseek': { apiDocs: 'https://platform.deepseek.com/api-docs', quickStart: 'https://platform.deepseek.com/docs/quick-start', pricing: 'https://platform.deepseek.com/pricing' }, 'qwen': { apiDocs: 'https://help.aliyun.com/zh/dashscope', quickStart: 'https://help.aliyun.com/zh/dashscope/developer-reference/quick-start', pricing: 'https://dashscope.console.aliyun.com/billing' }, 'openai': { apiDocs: 'https://platform.openai.com/docs', quickStart: 'https://platform.openai.com/docs/quickstart', pricing: 'https://openai.com/pricing' } }; return docMap[providerName.toLowerCase()] || {}; } maskApiKey(apiKey) { if (!apiKey || apiKey.length < 8) { return '***'; } const start = apiKey.substring(0, 3); const end = apiKey.substring(apiKey.length - 4); const middle = '*'.repeat(Math.min(apiKey.length - 7, 20)); return `${start}${middle}${end}`; } } exports.UIServer = UIServer;