// API Settings View // Manages LiteLLM API providers, custom endpoints, and cache settings // ========== State Management ========== let apiSettingsData = null; const providerModels = {}; let currentModal = null; // New state for split layout let selectedProviderId = null; let providerSearchQuery = ''; let activeModelTab = 'llm'; let expandedModelGroups = new Set(); let activeSidebarTab = 'providers'; // 'providers' | 'endpoints' | 'cache' | 'embedding-pool' // Embedding Pool state let embeddingPoolConfig = null; let embeddingPoolAvailableModels = []; let embeddingPoolDiscoveredProviders = []; // Cache for ccw-litellm status (frontend cache with TTL) let ccwLitellmStatusCache = null; let ccwLitellmStatusCacheTime = 0; const CCW_LITELLM_STATUS_CACHE_TTL = 60000; // 60 seconds // Track if this is the first render (force refresh on first load) let isFirstApiSettingsRender = true; // ========== Data Loading ========== /** * Load API configuration * @param {boolean} forceRefresh - Force refresh from server, bypass cache */ async function loadApiSettings(forceRefresh = false) { // If not forcing refresh and data already exists, return cached data if (!forceRefresh && apiSettingsData && apiSettingsData.providers) { console.log('[API Settings] Using cached API settings data'); return apiSettingsData; } try { console.log('[API Settings] Fetching API settings from server...'); const response = await fetch('/api/litellm-api/config'); if (!response.ok) throw new Error('Failed to load API settings'); apiSettingsData = await response.json(); return apiSettingsData; } catch (err) { console.error('Failed to load API settings:', err); showRefreshToast(t('common.error') + ': ' + err.message, 'error'); return null; } } /** * Load available models for a provider type */ async function loadProviderModels(providerType) { try { const response = await fetch('/api/litellm-api/models/' + providerType); if (!response.ok) throw new Error('Failed to load models'); const data = await response.json(); providerModels[providerType] = data.models || []; return data.models; } catch (err) { console.error('Failed to load provider models:', err); return []; } } /** * Load cache statistics */ async function loadCacheStats() { try { const response = await fetch('/api/litellm-api/cache/stats'); if (!response.ok) throw new Error('Failed to load cache stats'); return await response.json(); } catch (err) { console.error('Failed to load cache stats:', err); return { enabled: false, totalSize: 0, maxSize: 104857600, entries: 0 }; } } /** * Load embedding pool configuration and available models */ async function loadEmbeddingPoolConfig() { try { const response = await fetch('/api/litellm-api/embedding-pool'); if (!response.ok) throw new Error('Failed to load embedding pool config'); const data = await response.json(); embeddingPoolConfig = data.poolConfig; embeddingPoolAvailableModels = data.availableModels || []; // If pool is enabled and has a target model, discover providers if (embeddingPoolConfig && embeddingPoolConfig.enabled && embeddingPoolConfig.targetModel) { await discoverProvidersForTargetModel(embeddingPoolConfig.targetModel); } return data; } catch (err) { console.error('Failed to load embedding pool config:', err); showRefreshToast(t('common.error') + ': ' + err.message, 'error'); return null; } } /** * Discover providers for a specific target model */ async function discoverProvidersForTargetModel(targetModel) { try { const response = await fetch('/api/litellm-api/embedding-pool/discover/' + encodeURIComponent(targetModel)); if (!response.ok) throw new Error('Failed to discover providers'); const data = await response.json(); embeddingPoolDiscoveredProviders = data.discovered || []; return data; } catch (err) { console.error('Failed to discover providers:', err); embeddingPoolDiscoveredProviders = []; return null; } } /** * Save embedding pool configuration */ async function saveEmbeddingPoolConfig() { try { const enabled = document.getElementById('embedding-pool-enabled')?.checked || false; const targetModel = document.getElementById('embedding-pool-target-model')?.value || ''; const strategy = document.getElementById('embedding-pool-strategy')?.value || 'round_robin'; const defaultCooldown = parseInt(document.getElementById('embedding-pool-cooldown')?.value || '60'); const defaultMaxConcurrentPerKey = parseInt(document.getElementById('embedding-pool-concurrent')?.value || '4'); const poolConfig = enabled ? { enabled: true, targetModel: targetModel, strategy: strategy, autoDiscover: true, excludedProviderIds: embeddingPoolConfig?.excludedProviderIds || [], defaultCooldown: defaultCooldown, defaultMaxConcurrentPerKey: defaultMaxConcurrentPerKey } : null; const response = await fetch('/api/litellm-api/embedding-pool', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(poolConfig) }); if (!response.ok) throw new Error('Failed to save embedding pool config'); const result = await response.json(); embeddingPoolConfig = result.poolConfig; const syncCount = result.syncResult?.syncedEndpoints?.length || 0; showRefreshToast(t('apiSettings.poolSaved') + (syncCount > 0 ? ' (' + syncCount + ' endpoints synced)' : ''), 'success'); // Invalidate API settings cache since endpoints may have been synced apiSettingsData = null; // Reload the embedding pool section await renderEmbeddingPoolMainPanel(); } catch (err) { console.error('Failed to save embedding pool config:', err); showRefreshToast(t('common.error') + ': ' + err.message, 'error'); } } /** * Toggle provider exclusion in embedding pool */ async function toggleProviderExclusion(providerId) { if (!embeddingPoolConfig) return; const excludedIds = embeddingPoolConfig.excludedProviderIds || []; const index = excludedIds.indexOf(providerId); if (index > -1) { excludedIds.splice(index, 1); } else { excludedIds.push(providerId); } embeddingPoolConfig.excludedProviderIds = excludedIds; // Re-render the discovered providers section renderDiscoveredProviders(); } // ========== Provider Management ========== /** * Show add provider modal */ async function showAddProviderModal() { const modalHtml = '
' + '
' + '
' + '

' + t('apiSettings.addProvider') + '

' + '' + '
' + '
' + '
' + '
' + '' + '' + '' + t('apiSettings.apiFormatHint') + '' + '
' + '
' + '' + '' + '
' + '
' + '' + '
' + '' + '' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '
' + '' + '
' + // Advanced Settings Collapsible Panel '
' + '' + ' ' + t('apiSettings.advancedSettings') + '' + '' + '
' + '' + '
' + '
' + '
' + '
'; document.body.insertAdjacentHTML('beforeend', modalHtml); document.getElementById('providerForm').addEventListener('submit', async function(e) { e.preventDefault(); await saveProvider(); }); if (window.lucide) lucide.createIcons(); } /** * Show edit provider modal */ async function showEditProviderModal(providerId) { if (!apiSettingsData) return; const provider = apiSettingsData.providers?.find(function(p) { return p.id === providerId; }); if (!provider) return; await showAddProviderModal(); // Update modal title document.querySelector('#providerModal .generic-modal-title').textContent = t('apiSettings.editProvider'); // Populate form document.getElementById('provider-type').value = provider.type; document.getElementById('provider-name').value = provider.name; document.getElementById('provider-apikey').value = provider.apiKey; if (provider.apiBase) { document.getElementById('provider-apibase').value = provider.apiBase; } document.getElementById('provider-enabled').checked = provider.enabled !== false; // Populate advanced settings if they exist if (provider.advancedSettings) { var settings = provider.advancedSettings; if (settings.timeout) { document.getElementById('provider-timeout').value = settings.timeout; } if (settings.maxRetries !== undefined) { document.getElementById('provider-max-retries').value = settings.maxRetries; } if (settings.organization) { document.getElementById('provider-organization').value = settings.organization; } if (settings.apiVersion) { document.getElementById('provider-api-version').value = settings.apiVersion; } if (settings.rpm) { document.getElementById('provider-rpm').value = settings.rpm; } if (settings.tpm) { document.getElementById('provider-tpm').value = settings.tpm; } if (settings.proxy) { document.getElementById('provider-proxy').value = settings.proxy; } if (settings.customHeaders) { document.getElementById('provider-custom-headers').value = JSON.stringify(settings.customHeaders, null, 2); } // Expand advanced settings if any values exist if (Object.keys(settings).length > 0) { toggleAdvancedSettings(); } } // Update provider-specific field visibility updateProviderSpecificFields(); // Store provider ID for update document.getElementById('providerForm').dataset.providerId = providerId; } /** * Save provider (create or update) */ async function saveProvider() { const form = document.getElementById('providerForm'); const providerId = form.dataset.providerId; const useEnvVar = document.getElementById('use-env-var').checked; const apiKey = useEnvVar ? '${' + document.getElementById('env-var-name').value + '}' : document.getElementById('provider-apikey').value; // Collect advanced settings var advancedSettings = {}; var timeout = document.getElementById('provider-timeout').value; if (timeout) advancedSettings.timeout = parseInt(timeout); var maxRetries = document.getElementById('provider-max-retries').value; if (maxRetries) advancedSettings.maxRetries = parseInt(maxRetries); var organization = document.getElementById('provider-organization').value; if (organization) advancedSettings.organization = organization; var apiVersion = document.getElementById('provider-api-version').value; if (apiVersion) advancedSettings.apiVersion = apiVersion; var rpm = document.getElementById('provider-rpm').value; if (rpm) advancedSettings.rpm = parseInt(rpm); var tpm = document.getElementById('provider-tpm').value; if (tpm) advancedSettings.tpm = parseInt(tpm); var proxy = document.getElementById('provider-proxy').value; if (proxy) advancedSettings.proxy = proxy; var customHeadersJson = document.getElementById('provider-custom-headers').value; if (customHeadersJson) { try { advancedSettings.customHeaders = JSON.parse(customHeadersJson); } catch (e) { showRefreshToast(t('apiSettings.invalidJsonHeaders'), 'error'); return; } } const providerData = { type: document.getElementById('provider-type').value, name: document.getElementById('provider-name').value, apiKey: apiKey, apiBase: document.getElementById('provider-apibase').value || undefined, enabled: document.getElementById('provider-enabled').checked, advancedSettings: Object.keys(advancedSettings).length > 0 ? advancedSettings : undefined }; try { const url = providerId ? '/api/litellm-api/providers/' + providerId : '/api/litellm-api/providers'; const method = providerId ? 'PUT' : 'POST'; const response = await fetch(url, { method: method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(providerData) }); if (!response.ok) throw new Error('Failed to save provider'); const result = await response.json(); showRefreshToast(t('apiSettings.providerSaved'), 'success'); closeProviderModal(); // Force refresh data after saving apiSettingsData = null; await renderApiSettings(); } catch (err) { console.error('Failed to save provider:', err); showRefreshToast(t('common.error') + ': ' + err.message, 'error'); } } /** * Delete provider */ async function deleteProvider(providerId) { if (!confirm(t('apiSettings.confirmDeleteProvider'))) return; try { const response = await fetch('/api/litellm-api/providers/' + providerId, { method: 'DELETE' }); if (!response.ok) throw new Error('Failed to delete provider'); showRefreshToast(t('apiSettings.providerDeleted'), 'success'); // Force refresh data after deleting apiSettingsData = null; await renderApiSettings(); } catch (err) { console.error('Failed to delete provider:', err); showRefreshToast(t('common.error') + ': ' + err.message, 'error'); } } /** * Test provider connection * @param {string} [providerIdParam] - Optional provider ID. If not provided, uses form context or selectedProviderId */ async function testProviderConnection(providerIdParam) { var providerId = providerIdParam; // Try to get providerId from different sources if (!providerId) { var form = document.getElementById('providerForm'); if (form && form.dataset.providerId) { providerId = form.dataset.providerId; } else if (selectedProviderId) { providerId = selectedProviderId; } } if (!providerId) { showRefreshToast(t('apiSettings.saveProviderFirst'), 'warning'); return; } try { const response = await fetch('/api/litellm-api/providers/' + providerId + '/test', { method: 'POST' }); if (!response.ok) throw new Error('Failed to test provider'); const result = await response.json(); if (result.success) { showRefreshToast(t('apiSettings.connectionSuccess'), 'success'); } else { showRefreshToast(t('apiSettings.connectionFailed') + ': ' + (result.error || 'Unknown error'), 'error'); } } catch (err) { console.error('Failed to test provider:', err); showRefreshToast(t('common.error') + ': ' + err.message, 'error'); } } /** * Close provider modal */ function closeProviderModal() { const modal = document.getElementById('providerModal'); if (modal) modal.remove(); } /** * Toggle API key visibility */ function toggleApiKeyVisibility(inputId) { const input = document.getElementById(inputId); const icon = event.target.closest('button').querySelector('i'); if (input.type === 'password') { input.type = 'text'; icon.setAttribute('data-lucide', 'eye-off'); } else { input.type = 'password'; icon.setAttribute('data-lucide', 'eye'); } if (window.lucide) lucide.createIcons(); } /** * Toggle environment variable input */ function toggleEnvVarInput() { const useEnvVar = document.getElementById('use-env-var').checked; const apiKeyInput = document.getElementById('provider-apikey'); const envVarInput = document.getElementById('env-var-name'); if (useEnvVar) { apiKeyInput.style.display = 'none'; apiKeyInput.required = false; envVarInput.style.display = 'block'; envVarInput.required = true; } else { apiKeyInput.style.display = 'block'; apiKeyInput.required = true; envVarInput.style.display = 'none'; envVarInput.required = false; } } /** * Toggle advanced settings visibility */ function toggleAdvancedSettings() { var content = document.getElementById('advanced-settings-content'); var legend = document.querySelector('.advanced-settings-legend'); var isCollapsed = content.classList.contains('collapsed'); content.classList.toggle('collapsed'); legend.classList.toggle('expanded'); // Update icon var icon = legend.querySelector('.advanced-toggle-icon'); if (icon) { icon.setAttribute('data-lucide', isCollapsed ? 'chevron-down' : 'chevron-right'); if (window.lucide) lucide.createIcons(); } } /** * Update provider-specific fields visibility based on provider type */ function updateProviderSpecificFields() { var providerType = document.getElementById('provider-type').value; // Hide all provider-specific fields first var specificFields = document.querySelectorAll('.provider-specific'); specificFields.forEach(function(el) { el.style.display = 'none'; }); // Show OpenAI-specific fields if (providerType === 'openai') { var openaiFields = document.querySelectorAll('.openai-only'); openaiFields.forEach(function(el) { el.style.display = 'block'; }); } // Show Azure-specific fields if (providerType === 'azure') { var azureFields = document.querySelectorAll('.azure-only'); azureFields.forEach(function(el) { el.style.display = 'block'; }); } } // ========== Endpoint Management ========== /** * Show add endpoint modal */ async function showAddEndpointModal() { if (!apiSettingsData || !apiSettingsData.providers || apiSettingsData.providers.length === 0) { showRefreshToast(t('apiSettings.addProviderFirst'), 'warning'); return; } const providerOptions = apiSettingsData.providers .filter(function(p) { return p.enabled !== false; }) .map(function(p) { return ''; }) .join(''); const modalHtml = '
' + '
' + '
' + '

' + t('apiSettings.addEndpoint') + '

' + '' + '
' + '
' + '
' + '
' + '' + '' + '' + t('apiSettings.endpointIdHint') + '' + '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '
' + '' + t('apiSettings.cacheStrategy') + '' + '' + '' + '
' + '' + '
' + '
' + '
' + '
'; document.body.insertAdjacentHTML('beforeend', modalHtml); document.getElementById('endpointForm').addEventListener('submit', async function(e) { e.preventDefault(); await saveEndpoint(); }); // Load models for first provider await loadModelsForProvider(); if (window.lucide) lucide.createIcons(); } /** * Show edit endpoint modal */ async function showEditEndpointModal(endpointId) { if (!apiSettingsData) return; const endpoint = apiSettingsData.endpoints?.find(function(e) { return e.id === endpointId; }); if (!endpoint) return; await showAddEndpointModal(); // Update modal title document.querySelector('#endpointModal .generic-modal-title').textContent = t('apiSettings.editEndpoint'); // Populate form document.getElementById('endpoint-id').value = endpoint.id; document.getElementById('endpoint-id').disabled = true; document.getElementById('endpoint-name').value = endpoint.name; document.getElementById('endpoint-provider').value = endpoint.providerId; await loadModelsForProvider(); document.getElementById('endpoint-model').value = endpoint.model; if (endpoint.cacheStrategy) { document.getElementById('cache-enabled').checked = endpoint.cacheStrategy.enabled; if (endpoint.cacheStrategy.enabled) { toggleCacheSettings(); document.getElementById('cache-ttl').value = endpoint.cacheStrategy.ttlMinutes || 60; document.getElementById('cache-maxsize').value = endpoint.cacheStrategy.maxSizeKB || 512; document.getElementById('cache-patterns').value = endpoint.cacheStrategy.autoCachePatterns?.join(', ') || ''; } } // Store endpoint ID for update document.getElementById('endpointForm').dataset.endpointId = endpointId; } /** * Save endpoint (create or update) */ async function saveEndpoint() { const form = document.getElementById('endpointForm'); const endpointId = form.dataset.endpointId || document.getElementById('endpoint-id').value; const cacheEnabled = document.getElementById('cache-enabled').checked; const cacheStrategy = cacheEnabled ? { enabled: true, ttlMinutes: parseInt(document.getElementById('cache-ttl').value) || 60, maxSizeKB: parseInt(document.getElementById('cache-maxsize').value) || 512, autoCachePatterns: document.getElementById('cache-patterns').value .split(',') .map(function(p) { return p.trim(); }) .filter(function(p) { return p; }) } : { enabled: false }; const endpointData = { id: endpointId, name: document.getElementById('endpoint-name').value, providerId: document.getElementById('endpoint-provider').value, model: document.getElementById('endpoint-model').value, cacheStrategy: cacheStrategy }; try { const url = form.dataset.endpointId ? '/api/litellm-api/endpoints/' + form.dataset.endpointId : '/api/litellm-api/endpoints'; const method = form.dataset.endpointId ? 'PUT' : 'POST'; const response = await fetch(url, { method: method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(endpointData) }); if (!response.ok) throw new Error('Failed to save endpoint'); const result = await response.json(); showRefreshToast(t('apiSettings.endpointSaved'), 'success'); closeEndpointModal(); // Force refresh data after saving apiSettingsData = null; await renderApiSettings(); } catch (err) { console.error('Failed to save endpoint:', err); showRefreshToast(t('common.error') + ': ' + err.message, 'error'); } } /** * Delete endpoint */ async function deleteEndpoint(endpointId) { if (!confirm(t('apiSettings.confirmDeleteEndpoint'))) return; try { const response = await fetch('/api/litellm-api/endpoints/' + endpointId, { method: 'DELETE' }); if (!response.ok) throw new Error('Failed to delete endpoint'); showRefreshToast(t('apiSettings.endpointDeleted'), 'success'); // Force refresh data after deleting apiSettingsData = null; await renderApiSettings(); } catch (err) { console.error('Failed to delete endpoint:', err); showRefreshToast(t('common.error') + ': ' + err.message, 'error'); } } /** * Close endpoint modal */ function closeEndpointModal() { const modal = document.getElementById('endpointModal'); if (modal) modal.remove(); } /** * Load models for selected provider */ async function loadModelsForProvider() { const providerSelect = document.getElementById('endpoint-provider'); const modelSelect = document.getElementById('endpoint-model'); if (!providerSelect || !modelSelect) return; const providerId = providerSelect.value; const provider = apiSettingsData.providers.find(function(p) { return p.id === providerId; }); if (!provider) return; // Use LLM models configured for this provider (not static presets) const models = provider.llmModels || []; if (models.length === 0) { modelSelect.innerHTML = ''; return; } modelSelect.innerHTML = '' + models.filter(function(m) { return m.enabled; }).map(function(m) { const contextInfo = m.capabilities && m.capabilities.contextWindow ? ' (' + Math.round(m.capabilities.contextWindow / 1000) + 'K)' : ''; return ''; }).join(''); } /** * Toggle cache settings visibility */ function toggleCacheSettings() { const enabled = document.getElementById('cache-enabled').checked; const settings = document.getElementById('cache-settings'); settings.style.display = enabled ? 'block' : 'none'; } // ========== Cache Management ========== /** * Clear cache */ async function clearCache() { if (!confirm(t('apiSettings.confirmClearCache'))) return; try { const response = await fetch('/api/litellm-api/cache/clear', { method: 'POST' }); if (!response.ok) throw new Error('Failed to clear cache'); const result = await response.json(); showRefreshToast(t('apiSettings.cacheCleared') + ' (' + result.removed + ' entries)', 'success'); // Cache stats might have changed, but apiSettingsData doesn't need refresh await renderApiSettings(); } catch (err) { console.error('Failed to clear cache:', err); showRefreshToast(t('common.error') + ': ' + err.message, 'error'); } } /** * Toggle global cache */ async function toggleGlobalCache() { const enabled = document.getElementById('global-cache-enabled').checked; try { const response = await fetch('/api/litellm-api/config/cache', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: enabled }) }); if (!response.ok) throw new Error('Failed to update cache settings'); showRefreshToast(t('apiSettings.cacheSettingsUpdated'), 'success'); } catch (err) { console.error('Failed to update cache settings:', err); showRefreshToast(t('common.error') + ': ' + err.message, 'error'); // Revert checkbox document.getElementById('global-cache-enabled').checked = !enabled; } } // ========== Rendering ========== /** * Render API Settings page - Split Layout */ async function renderApiSettings() { var container = document.getElementById('mainContent'); if (!container) return; // Hide stats grid and search var statsGrid = document.getElementById('statsGrid'); var searchInput = document.getElementById('searchInput'); if (statsGrid) statsGrid.style.display = 'none'; if (searchInput) searchInput.parentElement.style.display = 'none'; // Load data (use cache by default, forceRefresh=false) await loadApiSettings(false); if (!apiSettingsData) { container.innerHTML = '
' + '
' + t('apiSettings.failedToLoad') + '
' + '
'; return; } // Build sidebar tabs HTML var sidebarTabsHtml = ''; // Build sidebar content based on active tab var sidebarContentHtml = ''; var addButtonHtml = ''; if (activeSidebarTab === 'providers') { sidebarContentHtml = '' + '
'; addButtonHtml = ''; } else if (activeSidebarTab === 'endpoints') { sidebarContentHtml = '
'; addButtonHtml = ''; } else if (activeSidebarTab === 'embedding-pool') { sidebarContentHtml = '
' + '

' + t('apiSettings.embeddingPoolDesc') + '

' + '
'; } else if (activeSidebarTab === 'cache') { sidebarContentHtml = '
' + '

' + t('apiSettings.cacheTabHint') + '

' + '
'; } // Build split layout container.innerHTML = // CCW-LiteLLM Status Container '
' + '
' + // Left Sidebar '' + // Right Main Panel '
' + '
' + // Cache Panel Overlay '
'; // Render content based on active tab if (activeSidebarTab === 'providers') { renderProviderList(); // Auto-select first provider if exists if (!selectedProviderId && apiSettingsData.providers && apiSettingsData.providers.length > 0) { selectProvider(apiSettingsData.providers[0].id); } else if (selectedProviderId) { renderProviderDetail(selectedProviderId); } else { renderProviderEmptyState(); } } else if (activeSidebarTab === 'endpoints') { renderEndpointsList(); renderEndpointsMainPanel(); } else if (activeSidebarTab === 'embedding-pool') { renderEmbeddingPoolMainPanel(); } else if (activeSidebarTab === 'cache') { renderCacheMainPanel(); } // Check and render ccw-litellm status // Force refresh on first load, use cache on subsequent renders const forceStatusRefresh = isFirstApiSettingsRender; if (isFirstApiSettingsRender) { isFirstApiSettingsRender = false; } checkCcwLitellmStatus(forceStatusRefresh).then(renderCcwLitellmStatusCard); if (window.lucide) lucide.createIcons(); } /** * Render provider list in sidebar */ function renderProviderList() { var container = document.getElementById('provider-list'); if (!container) return; var providers = apiSettingsData.providers || []; var query = providerSearchQuery.toLowerCase(); // Filter providers if (query) { providers = providers.filter(function(p) { return p.name.toLowerCase().includes(query) || p.type.toLowerCase().includes(query); }); } if (providers.length === 0) { container.innerHTML = '
' + '

' + (query ? t('apiSettings.noProvidersFound') : t('apiSettings.noProviders')) + '

' + '
'; return; } var html = ''; providers.forEach(function(provider) { var isSelected = provider.id === selectedProviderId; var iconClass = getProviderIconClass(provider.type); var iconLetter = provider.type.charAt(0).toUpperCase(); html += '
' + '
' + iconLetter + '
' + '
' + '' + escapeHtml(provider.name) + '' + '' + provider.type + '' + '
' + '' + (provider.enabled ? 'ON' : 'OFF') + '' + '
'; }); container.innerHTML = html; } /** * Filter providers by search query */ function filterProviders(query) { providerSearchQuery = query; renderProviderList(); } /** * Switch sidebar tab */ function switchSidebarTab(tab) { activeSidebarTab = tab; renderApiSettings(); } /** * Select a provider */ function selectProvider(providerId) { selectedProviderId = providerId; renderProviderList(); renderProviderDetail(providerId); } /** * Render provider detail panel */ function renderProviderDetail(providerId) { var container = document.getElementById('provider-detail-panel'); if (!container) return; var provider = apiSettingsData.providers.find(function(p) { return p.id === providerId; }); if (!provider) { renderProviderEmptyState(); return; } var maskedKey = provider.apiKey ? '••••••••••••••••' + provider.apiKey.slice(-4) : '••••••••'; var currentApiBase = provider.apiBase || getDefaultApiBase(provider.type); // Show full endpoint URL preview based on active model tab var endpointPath = activeModelTab === 'embedding' ? '/embeddings' : '/chat/completions'; var apiBasePreview = currentApiBase + endpointPath; var html = '
' + '
' + '
' + provider.type.charAt(0).toUpperCase() + '
' + '

' + escapeHtml(provider.name) + '

' + '' + '' + '
' + '
' + '' + '
' + '
' + '
' + // API Key field '
' + '
' + '' + t('apiSettings.apiKey') + '' + '
' + '' + '
' + '
' + '
' + '' + '' + '' + '
' + '
' + // API Base URL field - editable '
' + '
' + '' + t('apiSettings.apiBaseUrl') + '' + '
' + '
' + '' + '' + '
' + '' + t('apiSettings.preview') + ': ' + escapeHtml(apiBasePreview) + '' + '
' + // Model Section '
' + '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '
' + '
' + '
' + // Multi-key and sync buttons '
' + '' + '' + '
' + '
'; container.innerHTML = html; renderModelTree(provider); if (window.lucide) lucide.createIcons(); } /** * Render provider empty state */ function renderProviderEmptyState() { var container = document.getElementById('provider-detail-panel'); if (!container) return; container.innerHTML = '
' + '' + '

' + t('apiSettings.selectProvider') + '

' + '

' + t('apiSettings.selectProviderHint') + '

' + '
'; if (window.lucide) lucide.createIcons(); } /** * Render model tree */ function renderModelTree(provider) { var container = document.getElementById('model-tree'); if (!container) return; var models = activeModelTab === 'llm' ? (provider.llmModels || []) : (provider.embeddingModels || []); if (models.length === 0) { container.innerHTML = '
' + '' + '

' + t('apiSettings.noModels') + '

' + '
'; if (window.lucide) lucide.createIcons(); return; } // Group models by series var groups = groupModelsBySeries(models); var html = ''; groups.forEach(function(group) { var isExpanded = expandedModelGroups.has(group.series); html += '
' + '
' + '' + '' + escapeHtml(group.series) + '' + '' + group.models.length + '' + '
' + '
'; group.models.forEach(function(model) { var badge = model.capabilities && model.capabilities.contextWindow ? formatContextWindow(model.capabilities.contextWindow) : ''; // Badge for embedding models shows dimension instead of context window var embeddingBadge = model.capabilities && model.capabilities.embeddingDimension ? model.capabilities.embeddingDimension + 'd' : ''; var displayBadge = activeModelTab === 'llm' ? badge : embeddingBadge; html += '
' + '' + '' + escapeHtml(model.name) + '' + (displayBadge ? '' + displayBadge + '' : '') + '
' + '' + '' + '
' + '
'; }); html += '
'; }); container.innerHTML = html; if (window.lucide) lucide.createIcons(); } /** * Group models by series */ function groupModelsBySeries(models) { var seriesMap = {}; models.forEach(function(model) { var series = model.series || 'Other'; if (!seriesMap[series]) { seriesMap[series] = []; } seriesMap[series].push(model); }); return Object.keys(seriesMap).map(function(series) { return { series: series, models: seriesMap[series] }; }).sort(function(a, b) { return a.series.localeCompare(b.series); }); } /** * Toggle model group expand/collapse */ function toggleModelGroup(series) { if (expandedModelGroups.has(series)) { expandedModelGroups.delete(series); } else { expandedModelGroups.add(series); } var provider = apiSettingsData.providers.find(function(p) { return p.id === selectedProviderId; }); if (provider) { renderModelTree(provider); } } /** * Switch model tab (LLM / Embedding) */ function switchModelTab(tab) { activeModelTab = tab; expandedModelGroups.clear(); var provider = apiSettingsData.providers.find(function(p) { return p.id === selectedProviderId; }); if (provider) { renderProviderDetail(selectedProviderId); } } /** * Format context window for display */ function formatContextWindow(tokens) { if (tokens >= 1000000) return Math.round(tokens / 1000000) + 'M'; if (tokens >= 1000) return Math.round(tokens / 1000) + 'K'; return tokens.toString(); } /** * Get default API base URL for provider type */ function getDefaultApiBase(type) { var defaults = { 'openai': 'https://api.openai.com/v1', 'anthropic': 'https://api.anthropic.com/v1' }; return defaults[type] || 'https://api.example.com/v1'; } /** * Toggle provider enabled status */ async function toggleProviderEnabled(providerId, enabled) { try { var response = await fetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: enabled }) }); if (!response.ok) throw new Error('Failed to update provider'); // Update local data (for instant UI feedback) var provider = apiSettingsData.providers.find(function(p) { return p.id === providerId; }); if (provider) provider.enabled = enabled; renderProviderList(); showRefreshToast(t('apiSettings.providerUpdated'), 'success'); // Invalidate cache for next render setTimeout(function() { apiSettingsData = null; }, 100); } catch (err) { console.error('Failed to toggle provider:', err); showRefreshToast(t('common.error') + ': ' + err.message, 'error'); } } /** * Show cache panel */ async function showCachePanel() { var overlay = document.getElementById('cache-panel-overlay'); if (!overlay) return; var cacheStats = await loadCacheStats(); var usedMB = (cacheStats.totalSize / 1048576).toFixed(1); var maxMB = (cacheStats.maxSize / 1048576).toFixed(0); var usagePercent = cacheStats.maxSize > 0 ? Math.round((cacheStats.totalSize / cacheStats.maxSize) * 100) : 0; overlay.innerHTML = '
' + '
' + '
' + '

' + t('apiSettings.cacheSettings') + '

' + '
' + '' + '
' + '
' + '' + '
' + '
' + '
' + '
' + '
' + '' + usedMB + ' MB ' + t('apiSettings.used') + '' + '' + maxMB + ' MB ' + t('apiSettings.total') + '' + '
' + '
' + '
' + '
' + '' + usagePercent + '%' + '' + t('apiSettings.cacheUsage') + '' + '
' + '
' + '' + cacheStats.entries + '' + '' + t('apiSettings.cacheEntries') + '' + '
' + '
' + '' + '
' + '
'; overlay.classList.add('active'); if (window.lucide) lucide.createIcons(); } /** * Close cache panel */ function closeCachePanel() { var overlay = document.getElementById('cache-panel-overlay'); if (overlay) { overlay.classList.remove('active'); } } /** * Close cache panel when clicking overlay */ function closeCachePanelOverlay(event) { if (event.target.id === 'cache-panel-overlay') { closeCachePanel(); } } /** * Escape HTML special characters */ function escapeHtml(str) { if (!str) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // ========== Model Management ========== /** * Show add model modal */ function showAddModelModal(providerId, modelType) { // Default to active tab if no modelType provided if (!modelType) { modelType = activeModelTab; } // Get provider to know which presets to show const provider = apiSettingsData.providers.find(function(p) { return p.id === providerId; }); if (!provider) return; const isLlm = modelType === 'llm'; const title = isLlm ? t('apiSettings.addLlmModel') : t('apiSettings.addEmbeddingModel'); // Get model presets based on provider type const presets = isLlm ? getLlmPresetsForType(provider.type) : getEmbeddingPresetsForType(provider.type); // Group presets by series const groupedPresets = groupPresetsBySeries(presets); const modalHtml = '
' + '
' + '
' + '

' + title + '

' + '' + '
' + '
' + '
' + // Preset Selection '
' + '' + '' + '
' + // Model ID '
' + '' + '' + '
' + // Display Name '
' + '' + '' + '
' + // Series '
' + '' + '' + '
' + // Capabilities based on model type (isLlm ? '
' + '' + '' + '
' + '
' + '' + '' + '' + '' + '
' : '
' + '' + '' + '
' + '
' + '' + '' + '
' ) + // Description '
' + '' + '' + '
' + '' + '
' + '
' + '
' + '
'; document.body.insertAdjacentHTML('beforeend', modalHtml); if (window.lucide) lucide.createIcons(); } /** * Close add model modal */ function closeAddModelModal() { const modal = document.getElementById('add-model-modal'); if (modal) modal.remove(); } /** * Get LLM presets for provider type */ function getLlmPresetsForType(providerType) { const presets = { openai: [ { id: 'gpt-4o', name: 'GPT-4o', series: 'GPT-4', contextWindow: 128000 }, { id: 'gpt-4o-mini', name: 'GPT-4o Mini', series: 'GPT-4', contextWindow: 128000 }, { id: 'gpt-4-turbo', name: 'GPT-4 Turbo', series: 'GPT-4', contextWindow: 128000 }, { id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo', series: 'GPT-3.5', contextWindow: 16385 }, { id: 'o1', name: 'O1', series: 'O1', contextWindow: 200000 }, { id: 'o1-mini', name: 'O1 Mini', series: 'O1', contextWindow: 128000 }, { id: 'deepseek-chat', name: 'DeepSeek Chat', series: 'DeepSeek', contextWindow: 64000 }, { id: 'deepseek-coder', name: 'DeepSeek Coder', series: 'DeepSeek', contextWindow: 64000 } ], anthropic: [ { id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', series: 'Claude 4', contextWindow: 200000 }, { id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet', series: 'Claude 3.5', contextWindow: 200000 }, { id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku', series: 'Claude 3.5', contextWindow: 200000 }, { id: 'claude-3-opus-20240229', name: 'Claude 3 Opus', series: 'Claude 3', contextWindow: 200000 } ], custom: [ { id: 'custom-model', name: 'Custom Model', series: 'Custom', contextWindow: 128000 } ] }; return presets[providerType] || presets.custom; } /** * Get Embedding presets for provider type */ function getEmbeddingPresetsForType(providerType) { const presets = { openai: [ { id: 'text-embedding-3-small', name: 'Text Embedding 3 Small', series: 'Embedding V3', dimensions: 1536, maxTokens: 8191 }, { id: 'text-embedding-3-large', name: 'Text Embedding 3 Large', series: 'Embedding V3', dimensions: 3072, maxTokens: 8191 }, { id: 'text-embedding-ada-002', name: 'Ada 002', series: 'Embedding V2', dimensions: 1536, maxTokens: 8191 } ], anthropic: [], // Anthropic doesn't have embedding models custom: [ { id: 'custom-embedding', name: 'Custom Embedding', series: 'Custom', dimensions: 1536, maxTokens: 8192 } ] }; return presets[providerType] || presets.custom; } /** * Group presets by series */ function groupPresetsBySeries(presets) { const grouped = {}; presets.forEach(function(preset) { if (!grouped[preset.series]) { grouped[preset.series] = []; } grouped[preset.series].push(preset); }); return grouped; } /** * Fill model form from preset */ function fillModelFromPreset(presetId, modelType) { if (!presetId) { // Clear fields for custom model document.getElementById('model-id').value = ''; document.getElementById('model-name').value = ''; document.getElementById('model-series').value = ''; return; } const provider = apiSettingsData.providers.find(function(p) { return p.id === selectedProviderId; }); if (!provider) return; const isLlm = modelType === 'llm'; const presets = isLlm ? getLlmPresetsForType(provider.type) : getEmbeddingPresetsForType(provider.type); const preset = presets.find(function(p) { return p.id === presetId; }); if (preset) { document.getElementById('model-id').value = preset.id; document.getElementById('model-name').value = preset.name; document.getElementById('model-series').value = preset.series; if (isLlm && preset.contextWindow) { document.getElementById('model-context-window').value = preset.contextWindow; } if (!isLlm && preset.dimensions) { document.getElementById('model-dimensions').value = preset.dimensions; if (preset.maxTokens) { document.getElementById('model-max-tokens').value = preset.maxTokens; } } } } /** * Save new model */ function saveNewModel(event, providerId, modelType) { event.preventDefault(); const isLlm = modelType === 'llm'; const now = new Date().toISOString(); const newModel = { id: document.getElementById('model-id').value.trim(), name: document.getElementById('model-name').value.trim(), type: modelType, series: document.getElementById('model-series').value.trim(), enabled: true, description: document.getElementById('model-description').value.trim() || undefined, createdAt: now, updatedAt: now }; // Add capabilities based on model type if (isLlm) { newModel.capabilities = { contextWindow: parseInt(document.getElementById('model-context-window').value) || 128000, streaming: document.getElementById('cap-streaming').checked, functionCalling: document.getElementById('cap-function-calling').checked, vision: document.getElementById('cap-vision').checked }; } else { newModel.capabilities = { embeddingDimension: parseInt(document.getElementById('model-dimensions').value) || 1536, contextWindow: parseInt(document.getElementById('model-max-tokens').value) || 8192 }; } // Save to provider fetch('/api/litellm-api/providers/' + providerId) .then(function(res) { return res.json(); }) .then(function(provider) { const modelsKey = isLlm ? 'llmModels' : 'embeddingModels'; const models = provider[modelsKey] || []; // Check for duplicate ID if (models.some(function(m) { return m.id === newModel.id; })) { showRefreshToast(t('apiSettings.modelIdExists'), 'error'); return Promise.reject('Duplicate ID'); } models.push(newModel); return fetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ [modelsKey]: models }) }); }) .then(function() { closeAddModelModal(); return loadApiSettings(); }) .then(function() { if (selectedProviderId === providerId) { selectProvider(providerId); } showRefreshToast(t('common.saveSuccess'), 'success'); }) .catch(function(err) { if (err !== 'Duplicate ID') { console.error('Failed to save model:', err); showRefreshToast(t('common.saveFailed'), 'error'); } }); } function showManageModelsModal(providerId) { // For now, show a helpful message showRefreshToast(t('apiSettings.useModelTreeToManage'), 'info'); } function showModelSettingsModal(providerId, modelId, modelType) { var provider = apiSettingsData.providers.find(function(p) { return p.id === providerId; }); if (!provider) return; var isLlm = modelType === 'llm'; var models = isLlm ? (provider.llmModels || []) : (provider.embeddingModels || []); var model = models.find(function(m) { return m.id === modelId; }); if (!model) return; var capabilities = model.capabilities || {}; var endpointSettings = model.endpointSettings || {}; // Calculate endpoint preview URL var providerBase = provider.apiBase || getDefaultApiBase(provider.type); var modelBaseUrl = endpointSettings.baseUrl || providerBase; var endpointPath = isLlm ? '/chat/completions' : '/embeddings'; var endpointPreview = modelBaseUrl + endpointPath; var modalHtml = ''; document.body.insertAdjacentHTML('beforeend', modalHtml); if (window.lucide) lucide.createIcons(); } /** * Update model endpoint preview when base URL changes */ function updateModelEndpointPreview(endpointPath, defaultBase) { var baseUrlInput = document.getElementById('model-settings-baseurl'); var previewElement = document.getElementById('model-endpoint-preview'); if (!baseUrlInput || !previewElement) return; var baseUrl = baseUrlInput.value.trim() || defaultBase; // Remove trailing slash if present if (baseUrl.endsWith('/')) { baseUrl = baseUrl.slice(0, -1); } previewElement.textContent = baseUrl + '/' + endpointPath; } /** * Copy model endpoint URL to clipboard */ function copyModelEndpoint() { var previewElement = document.getElementById('model-endpoint-preview'); if (previewElement) { navigator.clipboard.writeText(previewElement.textContent); showRefreshToast(t('common.copied'), 'success'); } } function closeModelSettingsModal() { var modal = document.getElementById('model-settings-modal'); if (modal) modal.remove(); } function saveModelSettings(event, providerId, modelId, modelType) { event.preventDefault(); var isLlm = modelType === 'llm'; var modelsKey = isLlm ? 'llmModels' : 'embeddingModels'; fetch('/api/litellm-api/providers/' + providerId) .then(function(res) { return res.json(); }) .then(function(provider) { var models = provider[modelsKey] || []; var modelIndex = models.findIndex(function(m) { return m.id === modelId; }); if (modelIndex === -1) { throw new Error('Model not found'); } // Update model fields models[modelIndex].name = document.getElementById('model-settings-name').value.trim(); models[modelIndex].series = document.getElementById('model-settings-series').value.trim(); models[modelIndex].description = document.getElementById('model-settings-description').value.trim() || undefined; models[modelIndex].updatedAt = new Date().toISOString(); // Update capabilities if (isLlm) { models[modelIndex].capabilities = { contextWindow: parseInt(document.getElementById('model-settings-context').value) || 128000, streaming: document.getElementById('model-settings-streaming').checked, functionCalling: document.getElementById('model-settings-function-calling').checked, vision: document.getElementById('model-settings-vision').checked }; } else { models[modelIndex].capabilities = { embeddingDimension: parseInt(document.getElementById('model-settings-dimensions').value) || 1536, contextWindow: parseInt(document.getElementById('model-settings-max-tokens').value) || 8192 }; } // Update endpoint settings var baseUrlOverride = document.getElementById('model-settings-baseurl').value.trim(); // Remove trailing slash if present if (baseUrlOverride && baseUrlOverride.endsWith('/')) { baseUrlOverride = baseUrlOverride.slice(0, -1); } models[modelIndex].endpointSettings = { baseUrl: baseUrlOverride || undefined, timeout: parseInt(document.getElementById('model-settings-timeout').value) || 300, maxRetries: parseInt(document.getElementById('model-settings-retries').value) || 3 }; var updateData = {}; updateData[modelsKey] = models; return fetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updateData) }); }) .then(function() { closeModelSettingsModal(); return loadApiSettings(); }) .then(function() { if (selectedProviderId === providerId) { selectProvider(providerId); } showRefreshToast(t('common.saveSuccess'), 'success'); }) .catch(function(err) { console.error('Failed to save model settings:', err); showRefreshToast(t('common.saveFailed'), 'error'); }); } function deleteModel(providerId, modelId, modelType) { if (!confirm(t('common.confirmDelete'))) return; var isLlm = modelType === 'llm'; var modelsKey = isLlm ? 'llmModels' : 'embeddingModels'; fetch('/api/litellm-api/providers/' + providerId) .then(function(res) { return res.json(); }) .then(function(provider) { var models = provider[modelsKey] || []; var updatedModels = models.filter(function(m) { return m.id !== modelId; }); var updateData = {}; updateData[modelsKey] = updatedModels; return fetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updateData) }); }) .then(function() { return loadApiSettings(); }) .then(function() { if (selectedProviderId === providerId) { selectProvider(providerId); } showRefreshToast(t('common.deleteSuccess'), 'success'); }) .catch(function(err) { console.error('Failed to delete model:', err); showRefreshToast(t('common.deleteFailed'), 'error'); }); } function copyProviderApiKey(providerId) { var provider = apiSettingsData.providers.find(function(p) { return p.id === providerId; }); if (provider && provider.apiKey) { navigator.clipboard.writeText(provider.apiKey); showRefreshToast(t('common.copied'), 'success'); } } /** * Save provider API base URL */ async function saveProviderApiBase(providerId) { var input = document.getElementById('provider-detail-apibase'); if (!input) return; var newApiBase = input.value.trim(); // Remove trailing slash if present if (newApiBase.endsWith('/')) { newApiBase = newApiBase.slice(0, -1); } try { var response = await fetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiBase: newApiBase || undefined }) }); if (!response.ok) throw new Error('Failed to update API base'); // Update local data (for instant UI feedback) var provider = apiSettingsData.providers.find(function(p) { return p.id === providerId; }); if (provider) { provider.apiBase = newApiBase || undefined; } // Update preview updateApiBasePreview(newApiBase); showRefreshToast(t('apiSettings.apiBaseUpdated'), 'success'); // Invalidate cache for next render (but keep current data for immediate UI) // This ensures next tab switch or page refresh gets fresh data setTimeout(function() { apiSettingsData = null; }, 100); } catch (err) { console.error('Failed to save API base:', err); showRefreshToast(t('common.error') + ': ' + err.message, 'error'); } } /** * Update API base preview text showing full endpoint URL */ function updateApiBasePreview(apiBase) { var preview = document.getElementById('api-base-preview'); if (!preview) return; var base = apiBase || getDefaultApiBase('openai'); // Remove trailing slash if present if (base.endsWith('/')) { base = base.slice(0, -1); } var endpointPath = activeModelTab === 'embedding' ? '/embeddings' : '/chat/completions'; preview.textContent = t('apiSettings.preview') + ': ' + base + endpointPath; } /** * Delete provider with confirmation */ async function deleteProviderWithConfirm(providerId) { if (!confirm(t('apiSettings.confirmDeleteProvider'))) return; try { var response = await fetch('/api/litellm-api/providers/' + providerId, { method: 'DELETE' }); if (!response.ok) throw new Error('Failed to delete provider'); // Remove from local data apiSettingsData.providers = apiSettingsData.providers.filter(function(p) { return p.id !== providerId; }); // Clear selection if deleted provider was selected if (selectedProviderId === providerId) { selectedProviderId = null; if (apiSettingsData.providers.length > 0) { selectProvider(apiSettingsData.providers[0].id); } else { renderProviderEmptyState(); } } renderProviderList(); showRefreshToast(t('apiSettings.providerDeleted'), 'success'); } catch (err) { console.error('Failed to delete provider:', err); showRefreshToast(t('common.error') + ': ' + err.message, 'error'); } } /** * Sync config to CodexLens (generate YAML config for ccw_litellm) */ async function syncConfigToCodexLens() { try { var response = await fetch('/api/litellm-api/config/sync', { method: 'POST' }); if (!response.ok) throw new Error('Failed to sync config'); var result = await response.json(); showRefreshToast(t('apiSettings.configSynced') + ' (' + result.yamlPath + ')', 'success'); } catch (err) { console.error('Failed to sync config:', err); showRefreshToast(t('common.error') + ': ' + err.message, 'error'); } } /** * Get provider icon class based on type */ function getProviderIconClass(type) { var iconMap = { 'openai': 'provider-icon-openai', 'anthropic': 'provider-icon-anthropic' }; return iconMap[type] || 'provider-icon-custom'; } /** * Get provider icon name based on type */ function getProviderIcon(type) { const iconMap = { 'openai': 'sparkles', 'anthropic': 'brain', 'google': 'cloud', 'azure': 'cloud-cog', 'ollama': 'server', 'mistral': 'wind', 'deepseek': 'search' }; return iconMap[type] || 'settings'; } /** * Render providers list */ function renderProvidersList() { const container = document.getElementById('providers-list'); if (!container) return; const providers = apiSettingsData.providers || []; if (providers.length === 0) { container.innerHTML = '
' + '
' + '' + '
' + '

' + t('apiSettings.noProviders') + '

' + '

' + t('apiSettings.noProvidersHint') + '

' + '
'; if (window.lucide) lucide.createIcons(); return; } container.innerHTML = providers.map(function(provider) { const statusClass = provider.enabled === false ? 'disabled' : 'enabled'; const statusText = provider.enabled === false ? t('apiSettings.disabled') : t('apiSettings.enabled'); const iconClass = getProviderIconClass(provider.type); const iconName = getProviderIcon(provider.type); return '
' + '
' + '
' + '
' + '' + '
' + '
' + '

' + provider.name + '

' + '' + provider.type + '' + '
' + '
' + '
' + '' + '' + '
' + '
' + '
' + '
' + '
' + '' + t('apiSettings.apiKey') + '' + '' + maskApiKey(provider.apiKey) + '' + '
' + '
' + '' + t('common.status') + '' + '' + statusText + '' + '
' + (provider.apiBase ? '
' + '' + t('apiSettings.apiBaseUrl') + '' + '' + provider.apiBase + '' + '
' : '') + '
' + '
' + '
'; }).join(''); if (window.lucide) lucide.createIcons(); } /** * Render endpoints list */ function renderEndpointsList() { const container = document.getElementById('endpoints-list'); if (!container) return; const endpoints = apiSettingsData.endpoints || []; if (endpoints.length === 0) { container.innerHTML = '
' + '
' + '' + '
' + '

' + t('apiSettings.noEndpoints') + '

' + '

' + t('apiSettings.noEndpointsHint') + '

' + '
'; if (window.lucide) lucide.createIcons(); return; } container.innerHTML = endpoints.map(function(endpoint) { const provider = apiSettingsData.providers.find(function(p) { return p.id === endpoint.providerId; }); const providerName = provider ? provider.name : endpoint.providerId; const providerType = provider ? provider.type : 'custom'; const iconClass = getProviderIconClass(providerType); const iconName = getProviderIcon(providerType); const cacheEnabled = endpoint.cacheStrategy?.enabled; const cacheStatus = cacheEnabled ? endpoint.cacheStrategy.ttlMinutes + ' min' : t('apiSettings.off'); return '
' + '
' + '
' + '
' + '' + '
' + '
' + '

' + endpoint.name + '

' + '' + endpoint.id + '' + '
' + '
' + '
' + '' + '' + '
' + '
' + '
' + '
' + '
' + '' + t('apiSettings.provider') + '' + '' + providerName + '' + '
' + '
' + '' + t('apiSettings.model') + '' + '' + endpoint.model + '' + '
' + '
' + '' + t('apiSettings.cache') + '' + '' + (cacheEnabled ? '' : '') + cacheStatus + '' + '
' + '
' + '
' + '' + 'ccw cli -p "..." --model ' + endpoint.id + '' + '
' + '
' + '
'; }).join(''); if (window.lucide) lucide.createIcons(); } /** * Render endpoints main panel */ function renderEndpointsMainPanel() { var container = document.getElementById('provider-detail-panel'); if (!container) return; var endpoints = apiSettingsData.endpoints || []; var html = '
' + '
' + '

' + t('apiSettings.endpoints') + '

' + '

' + t('apiSettings.endpointsDescription') + '

' + '
' + '
' + '
' + '
' + endpoints.length + '
' + '
' + t('apiSettings.totalEndpoints') + '
' + '
' + '
' + '
' + endpoints.filter(function(e) { return e.cacheStrategy?.enabled; }).length + '
' + '
' + t('apiSettings.cachedEndpoints') + '
' + '
' + '
' + '
'; container.innerHTML = html; if (window.lucide) lucide.createIcons(); } /** * Render cache main panel */ async function renderCacheMainPanel() { var container = document.getElementById('provider-detail-panel'); if (!container) return; // Load cache stats var stats = await loadCacheStats(); if (!stats) { stats = { totalSize: 0, maxSize: 104857600, entries: 0 }; } var globalSettings = apiSettingsData.globalCache || { enabled: false }; var totalSize = stats.totalSize || 0; var maxSize = stats.maxSize || 104857600; // Default 100MB var usedMB = (totalSize / 1024 / 1024).toFixed(2); var maxMB = (maxSize / 1024 / 1024).toFixed(0); var usagePercent = maxSize > 0 ? ((totalSize / maxSize) * 100).toFixed(1) : 0; var html = '
' + '
' + '

' + t('apiSettings.cacheSettings') + '

' + '

' + t('apiSettings.cacheDescription') + '

' + '
' + // Global Cache Settings '
' + '
' + '

' + t('apiSettings.globalCache') + '

' + '' + '
' + '
' + // Cache Statistics '
' + '

' + t('apiSettings.cacheStatistics') + '

' + '
' + '
' + '
' + '
' + '
' + (stats.entries || 0) + '
' + '
' + t('apiSettings.cachedEntries') + '
' + '
' + '
' + '
' + '
' + '
' + '
' + usedMB + ' MB
' + '
' + t('apiSettings.storageUsed') + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + usedMB + ' MB / ' + maxMB + ' MB (' + usagePercent + '%)
' + '
' + '
' + // Cache Actions '
' + '

' + t('apiSettings.cacheActions') + '

' + '' + '
' + '
'; container.innerHTML = html; if (window.lucide) lucide.createIcons(); } /** * Render cache settings panel */ function renderCacheSettings(stats) { const container = document.getElementById('cache-settings-panel'); if (!container) return; const globalSettings = apiSettingsData.globalCache || { enabled: false }; const totalSize = stats.totalSize || 0; const maxSize = stats.maxSize || 104857600; // Default 100MB const usedMB = (totalSize / 1024 / 1024).toFixed(2); const maxMB = (maxSize / 1024 / 1024).toFixed(0); const usagePercent = maxSize > 0 ? ((totalSize / maxSize) * 100).toFixed(1) : 0; container.innerHTML = '
' + // Cache Header '
' + '
' + '

' + t('apiSettings.cacheSettings') + '

' + '
' + '' + '
' + // Cache Content '
' + // Visual Bar '
' + '
' + '
' + '
' + '
' + '' + usedMB + ' MB ' + t('apiSettings.used') + '' + '' + maxMB + ' MB ' + t('apiSettings.total') + '' + '
' + '
' + // Stats Grid '
' + '
' + '' + usagePercent + '%' + '' + t('apiSettings.cacheUsage') + '' + '
' + '
' + '' + (stats.entries || 0) + '' + '' + t('apiSettings.cacheEntries') + '' + '
' + '
' + '' + usedMB + ' MB' + '' + t('apiSettings.cacheSize') + '' + '
' + '
' + // Clear Button '' + '
' + '
'; if (window.lucide) lucide.createIcons(); } // ========== Multi-Key Management ========== /** * Generate unique ID for API keys */ function generateKeyId() { return 'key-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9); } // ========== Embedding Pool Management ========== /** * Render embedding pool main panel */ async function renderEmbeddingPoolMainPanel() { var container = document.getElementById('provider-detail-panel'); if (!container) return; // Load embedding pool config if not already loaded if (!embeddingPoolConfig) { await loadEmbeddingPoolConfig(); } const enabled = embeddingPoolConfig?.enabled || false; const targetModel = embeddingPoolConfig?.targetModel || ''; const strategy = embeddingPoolConfig?.strategy || 'round_robin'; const defaultCooldown = embeddingPoolConfig?.defaultCooldown || 60; const defaultMaxConcurrentPerKey = embeddingPoolConfig?.defaultMaxConcurrentPerKey || 4; // Build model dropdown options let modelOptionsHtml = ''; embeddingPoolAvailableModels.forEach(function(model) { const providerCount = model.providers.length; const selected = model.modelId === targetModel ? ' selected' : ''; modelOptionsHtml += ''; }); var html = '
' + '
' + '

' + t('apiSettings.embeddingPool') + '

' + '

' + t('apiSettings.embeddingPoolDesc') + '

' + '
' + // Enable/Disable Toggle '
' + '
' + '

' + t('apiSettings.poolEnabled') + '

' + '' + '
' + '
' + // Configuration Form '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + // Discovered Providers Section '
' + '
' + '' + '
' + '
' + '
'; container.innerHTML = html; if (window.lucide) lucide.createIcons(); // Render discovered providers if we have a target model if (enabled && targetModel) { renderDiscoveredProviders(); } } /** * Handle embedding pool enabled/disabled toggle */ function onEmbeddingPoolEnabledChange(enabled) { const configSection = document.getElementById('embedding-pool-config'); if (configSection) { configSection.style.display = enabled ? '' : 'none'; } } /** * Handle target model selection change */ async function onTargetModelChange(modelId) { if (!modelId) { embeddingPoolDiscoveredProviders = []; renderDiscoveredProviders(); return; } // Discover providers for this model await discoverProvidersForTargetModel(modelId); renderDiscoveredProviders(); } /** * Render discovered providers list */ function renderDiscoveredProviders() { const container = document.getElementById('discovered-providers-section'); if (!container) return; if (embeddingPoolDiscoveredProviders.length === 0) { container.innerHTML = '
' + ' ' + t('apiSettings.noProvidersFound') + '
'; if (window.lucide) lucide.createIcons(); return; } const excludedIds = embeddingPoolConfig?.excludedProviderIds || []; let totalProviders = 0; let totalKeys = 0; embeddingPoolDiscoveredProviders.forEach(function(p) { totalProviders++; totalKeys += p.apiKeys?.length || 1; }); let providersHtml = '
' + '
' + '

' + t('apiSettings.discoveredProviders') + '

' + '' + totalProviders + ' providers, ' + totalKeys + ' keys' + '
'; embeddingPoolDiscoveredProviders.forEach(function(provider) { const isExcluded = excludedIds.indexOf(provider.providerId) > -1; const icon = isExcluded ? 'x-circle' : 'check-circle'; const keyCount = provider.apiKeys?.length || 1; const keyInfo = keyCount > 1 ? ' (' + keyCount + ' keys)' : ''; providersHtml += '
' + '
' + '' + '
' + '
' + escapeHtml(provider.providerName) + '
' + '
' + provider.modelName + keyInfo + '
' + '
' + '
' + '
' + '' + '
' + '
'; }); providersHtml += '
'; container.innerHTML = providersHtml; if (window.lucide) lucide.createIcons(); } /** * Render API keys section */ function renderApiKeysSection(provider) { const keys = provider.apiKeys || []; const hasMultipleKeys = keys.length > 0; let keysHtml = ''; if (hasMultipleKeys) { keysHtml = keys.map(function(key, index) { return '
' + '' + '
' + '' + '' + '
' + '' + '
' + '' + '' + t('apiSettings.' + (key.healthStatus || 'unknown')) + '' + '
' + '
' + '' + '' + '
' + '
'; }).join(''); } else { keysHtml = '
' + t('apiSettings.noKeys') + '
'; } return '
' + '
' + '

' + t('apiSettings.apiKeys') + '

' + '' + '
' + '
' + keysHtml + '
' + '
'; } /** * Render routing strategy section */ function renderRoutingSection(provider) { const strategy = provider.routingStrategy || 'simple-shuffle'; return '
' + '' + '' + '
' + t('apiSettings.routingHint') + '
' + '
'; } /** * Render health check section */ function renderHealthCheckSection(provider) { const health = provider.healthCheck || { enabled: false, intervalSeconds: 300, cooldownSeconds: 5, failureThreshold: 3 }; return '
' + '
' + '
' + t('apiSettings.healthCheck') + '
' + '' + '
' + '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '
' + '
'; } /** * Show multi-key settings modal */ function showMultiKeyModal(providerId) { const provider = apiSettingsData.providers.find(function(p) { return p.id === providerId; }); if (!provider) return; const modalHtml = ''; document.body.insertAdjacentHTML('beforeend', modalHtml); if (window.lucide) lucide.createIcons(); } /** * Close multi-key settings modal */ function closeMultiKeyModal() { const modal = document.getElementById('multi-key-modal'); if (modal) modal.remove(); } /** * Refresh multi-key modal content */ function refreshMultiKeyModal(providerId) { const modal = document.getElementById('multi-key-modal'); if (!modal) return; const provider = apiSettingsData.providers.find(function(p) { return p.id === providerId; }); if (!provider) return; const modalBody = modal.querySelector('.modal-body'); if (modalBody) { modalBody.innerHTML = renderApiKeysSection(provider) + renderRoutingSection(provider) + renderHealthCheckSection(provider); if (window.lucide) lucide.createIcons(); } } /** * Add API key to provider */ function addApiKey(providerId) { const newKey = { id: generateKeyId(), key: '', label: '', weight: 1, enabled: true, healthStatus: 'unknown' }; fetch('/api/litellm-api/providers/' + providerId) .then(function(res) { return res.json(); }) .then(function(provider) { const apiKeys = provider.apiKeys || []; apiKeys.push(newKey); return fetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKeys: apiKeys }) }); }) .then(function() { loadApiSettings().then(function() { refreshMultiKeyModal(providerId); }); }) .catch(function(err) { console.error('Failed to add API key:', err); }); } /** * Remove API key from provider */ function removeApiKey(providerId, keyId) { if (!confirm(t('common.confirmDelete'))) return; fetch('/api/litellm-api/providers/' + providerId) .then(function(res) { return res.json(); }) .then(function(provider) { const apiKeys = (provider.apiKeys || []).filter(function(k) { return k.id !== keyId; }); return fetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKeys: apiKeys }) }); }) .then(function() { loadApiSettings().then(function() { refreshMultiKeyModal(providerId); }); }) .catch(function(err) { console.error('Failed to remove API key:', err); }); } /** * Update API key field */ function updateApiKeyField(providerId, keyId, field, value) { fetch('/api/litellm-api/providers/' + providerId) .then(function(res) { return res.json(); }) .then(function(provider) { const apiKeys = provider.apiKeys || []; const keyIndex = apiKeys.findIndex(function(k) { return k.id === keyId; }); if (keyIndex >= 0) { apiKeys[keyIndex][field] = value; } return fetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKeys: apiKeys }) }); }) .catch(function(err) { console.error('Failed to update API key:', err); }); } /** * Update provider routing strategy */ function updateProviderRouting(providerId, strategy) { fetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ routingStrategy: strategy }) }).catch(function(err) { console.error('Failed to update routing:', err); }); } /** * Update health check enabled status */ function updateHealthCheckEnabled(providerId, enabled) { fetch('/api/litellm-api/providers/' + providerId) .then(function(res) { return res.json(); }) .then(function(provider) { const healthCheck = provider.healthCheck || { intervalSeconds: 300, cooldownSeconds: 5, failureThreshold: 3 }; healthCheck.enabled = enabled; return fetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ healthCheck: healthCheck }) }); }) .then(function() { loadApiSettings().then(function() { refreshMultiKeyModal(providerId); }); }) .catch(function(err) { console.error('Failed to update health check:', err); }); } /** * Update health check field */ function updateHealthCheckField(providerId, field, value) { fetch('/api/litellm-api/providers/' + providerId) .then(function(res) { return res.json(); }) .then(function(provider) { const healthCheck = provider.healthCheck || { enabled: false, intervalSeconds: 300, cooldownSeconds: 5, failureThreshold: 3 }; healthCheck[field] = value; return fetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ healthCheck: healthCheck }) }); }) .catch(function(err) { console.error('Failed to update health check:', err); }); } /** * Test API key */ function testApiKey(providerId, keyId) { const btn = event.target; btn.disabled = true; btn.classList.add('testing'); btn.textContent = t('apiSettings.testingKey'); fetch('/api/litellm-api/providers/' + providerId + '/test-key', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keyId: keyId }) }) .then(function(res) { return res.json(); }) .then(function(result) { btn.disabled = false; btn.classList.remove('testing'); btn.textContent = t('apiSettings.testKey'); const keyItem = btn.closest('.api-key-item'); const statusIndicator = keyItem.querySelector('.key-status-indicator'); const statusText = keyItem.querySelector('.key-status-text'); if (result.valid) { statusIndicator.className = 'key-status-indicator healthy'; statusText.textContent = t('apiSettings.healthy'); showToast(t('apiSettings.keyValid'), 'success'); } else { statusIndicator.className = 'key-status-indicator unhealthy'; statusText.textContent = t('apiSettings.unhealthy'); showToast(t('apiSettings.keyInvalid') + ': ' + (result.error || ''), 'error'); } }) .catch(function(err) { btn.disabled = false; btn.classList.remove('testing'); btn.textContent = t('apiSettings.testKey'); showToast('Test failed: ' + err.message, 'error'); }); } /** * Toggle key visibility */ function toggleKeyVisibility(btn) { const input = btn.previousElementSibling; if (input.type === 'password') { input.type = 'text'; btn.textContent = '🔒'; } else { input.type = 'password'; btn.textContent = '👁️'; } } // ========== CCW-LiteLLM Management ========== /** * Check ccw-litellm installation status * @param {boolean} forceRefresh - Force refresh from server, bypass cache */ async function checkCcwLitellmStatus(forceRefresh = false) { // Check if cache is valid and not forcing refresh if (!forceRefresh && ccwLitellmStatusCache && (Date.now() - ccwLitellmStatusCacheTime < CCW_LITELLM_STATUS_CACHE_TTL)) { console.log('[API Settings] Using cached ccw-litellm status'); window.ccwLitellmStatus = ccwLitellmStatusCache; return ccwLitellmStatusCache; } try { console.log('[API Settings] Checking ccw-litellm status from server...'); // Add refresh=true to bypass backend cache when forceRefresh is true var statusUrl = '/api/litellm-api/ccw-litellm/status' + (forceRefresh ? '?refresh=true' : ''); var response = await fetch(statusUrl); console.log('[API Settings] Status response:', response.status); var status = await response.json(); console.log('[API Settings] ccw-litellm status:', status); // Update cache ccwLitellmStatusCache = status; ccwLitellmStatusCacheTime = Date.now(); window.ccwLitellmStatus = status; return status; } catch (e) { console.warn('[API Settings] Could not check ccw-litellm status:', e); var fallbackStatus = { installed: false }; // Cache the fallback result too ccwLitellmStatusCache = fallbackStatus; ccwLitellmStatusCacheTime = Date.now(); return fallbackStatus; } } /** * Render ccw-litellm status card */ function renderCcwLitellmStatusCard() { var container = document.getElementById('ccwLitellmStatusContainer'); if (!container) return; var status = window.ccwLitellmStatus || { installed: false }; if (status.installed) { container.innerHTML = '
' + '' + '' + 'ccw-litellm ' + (status.version || '') + '' + '
'; } else { container.innerHTML = '
' + '' + '' + 'ccw-litellm not installed' + '' + '' + '
'; } if (window.lucide) lucide.createIcons(); } /** * Install ccw-litellm package */ async function installCcwLitellm() { var container = document.getElementById('ccwLitellmStatusContainer'); if (container) { container.innerHTML = '
' + '
' + 'Installing ccw-litellm...' + '
'; } try { var response = await fetch('/api/litellm-api/ccw-litellm/install', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); var result = await response.json(); if (result.success) { showRefreshToast('ccw-litellm installed successfully!', 'success'); // Refresh status (force refresh after installation) await checkCcwLitellmStatus(true); renderCcwLitellmStatusCard(); } else { showRefreshToast('Failed to install ccw-litellm: ' + result.error, 'error'); renderCcwLitellmStatusCard(); } } catch (e) { showRefreshToast('Installation error: ' + e.message, 'error'); renderCcwLitellmStatusCard(); } } // Make functions globally accessible window.checkCcwLitellmStatus = checkCcwLitellmStatus; window.renderCcwLitellmStatusCard = renderCcwLitellmStatusCard; window.installCcwLitellm = installCcwLitellm; // ========== Utility Functions ========== /** * Mask API key for display */ function maskApiKey(apiKey) { if (!apiKey) return ''; if (apiKey.startsWith('${')) return apiKey; // Environment variable if (apiKey.length <= 8) return '***'; return apiKey.substring(0, 4) + '...' + apiKey.substring(apiKey.length - 4); }