diff --git a/.gitignore b/.gitignore index f579cb55..45a18015 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ COMMAND_TEMPLATE_ORCHESTRATOR.md settings.json *.mcp.json .mcp.json +.ace-tool/ diff --git a/ccw/src/config/litellm-api-config-manager.ts b/ccw/src/config/litellm-api-config-manager.ts index 998c1290..3447ddb9 100644 --- a/ccw/src/config/litellm-api-config-manager.ts +++ b/ccw/src/config/litellm-api-config-manager.ts @@ -3,7 +3,8 @@ * Manages provider credentials, custom endpoints, and cache settings */ -import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { homedir } from 'os'; import { join } from 'path'; import { StoragePaths, GlobalPaths, ensureStorageDir } from './storage-paths.js'; import type { @@ -15,6 +16,7 @@ import type { CacheStrategy, CodexLensEmbeddingRotation, CodexLensEmbeddingProvider, + EmbeddingPoolConfig, } from '../types/litellm-api-config.js'; /** @@ -372,11 +374,12 @@ export function getCodexLensEmbeddingRotation(baseDir: string): CodexLensEmbeddi /** * Update CodexLens embedding rotation config + * Also triggers sync to CodexLens settings.json */ export function updateCodexLensEmbeddingRotation( baseDir: string, rotationConfig: CodexLensEmbeddingRotation | undefined -): void { +): { syncResult: { success: boolean; message: string; endpointCount?: number } } { const config = loadLiteLLMApiConfig(baseDir); if (rotationConfig) { @@ -386,6 +389,10 @@ export function updateCodexLensEmbeddingRotation( } saveConfig(baseDir, config); + + // Auto-sync to CodexLens settings.json + const syncResult = syncCodexLensConfig(baseDir); + return { syncResult }; } /** @@ -474,6 +481,7 @@ export function getEmbeddingProvidersForRotation(baseDir: string): Array<{ /** * Generate rotation endpoints for ccw_litellm * Creates endpoint list from rotation config for parallel embedding + * Supports both legacy codexlensEmbeddingRotation and new embeddingPoolConfig */ export function generateRotationEndpoints(baseDir: string): Array<{ name: string; @@ -484,12 +492,115 @@ export function generateRotationEndpoints(baseDir: string): Array<{ max_concurrent: number; }> { const config = loadLiteLLMApiConfig(baseDir); + + // Prefer embeddingPoolConfig, fallback to codexlensEmbeddingRotation for backward compatibility + const poolConfig = config.embeddingPoolConfig; const rotationConfig = config.codexlensEmbeddingRotation; - if (!rotationConfig || !rotationConfig.enabled) { - return []; + // Check if new poolConfig is enabled + if (poolConfig && poolConfig.enabled) { + return generateEndpointsFromPool(baseDir, poolConfig, config); } + // Fallback to legacy rotation config + if (rotationConfig && rotationConfig.enabled) { + return generateEndpointsFromLegacyRotation(baseDir, rotationConfig, config); + } + + return []; +} + +/** + * Generate endpoints from new embeddingPoolConfig (with auto-discovery support) + */ +function generateEndpointsFromPool( + baseDir: string, + poolConfig: EmbeddingPoolConfig, + config: LiteLLMApiConfig +): Array<{ + name: string; + api_key: string; + api_base: string; + model: string; + weight: number; + max_concurrent: number; +}> { + const endpoints: Array<{ + name: string; + api_key: string; + api_base: string; + model: string; + weight: number; + max_concurrent: number; + }> = []; + + if (poolConfig.autoDiscover) { + // Auto-discover all providers offering targetModel + const discovered = discoverProvidersForModel(baseDir, poolConfig.targetModel); + const excludedIds = new Set(poolConfig.excludedProviderIds || []); + + for (const disc of discovered) { + // Skip excluded providers + if (excludedIds.has(disc.providerId)) continue; + + // Find the provider config + const provider = config.providers.find(p => p.id === disc.providerId); + if (!provider || !provider.enabled) continue; + + // Find the embedding model + const embeddingModel = provider.embeddingModels?.find(m => m.id === disc.modelId); + if (!embeddingModel || !embeddingModel.enabled) continue; + + // Get API base (model-specific or provider default) + const apiBase = embeddingModel.endpointSettings?.baseUrl || + provider.apiBase || + getDefaultApiBaseForType(provider.type); + + // Get API keys to use + let keysToUse: Array<{ id: string; key: string; label: string }> = []; + + if (provider.apiKeys && provider.apiKeys.length > 0) { + // Use all enabled keys + keysToUse = provider.apiKeys + .filter(k => k.enabled) + .map(k => ({ id: k.id, key: k.key, label: k.label || k.id })); + } else if (provider.apiKey) { + // Single key fallback + keysToUse = [{ id: 'default', key: provider.apiKey, label: 'Default' }]; + } + + // Create endpoint for each key + for (const keyInfo of keysToUse) { + endpoints.push({ + name: `${provider.name}-${keyInfo.label}`, + api_key: resolveEnvVar(keyInfo.key), + api_base: apiBase, + model: embeddingModel.name, + weight: 1.0, // Default weight for auto-discovered providers + max_concurrent: poolConfig.defaultMaxConcurrentPerKey, + }); + } + } + } + + return endpoints; +} + +/** + * Generate endpoints from legacy codexlensEmbeddingRotation config + */ +function generateEndpointsFromLegacyRotation( + baseDir: string, + rotationConfig: CodexLensEmbeddingRotation, + config: LiteLLMApiConfig +): Array<{ + name: string; + api_key: string; + api_base: string; + model: string; + weight: number; + max_concurrent: number; +}> { const endpoints: Array<{ name: string; api_key: string; @@ -551,6 +662,191 @@ export function generateRotationEndpoints(baseDir: string): Array<{ return endpoints; } +/** + * Sync CodexLens settings with CCW API config + * Writes rotation endpoints to ~/.codexlens/settings.json + * This enables the Python backend to use UI-configured rotation + * Supports both new embeddingPoolConfig and legacy codexlensEmbeddingRotation + */ +export function syncCodexLensConfig(baseDir: string): { success: boolean; message: string; endpointCount?: number } { + try { + const config = loadLiteLLMApiConfig(baseDir); + + // Prefer embeddingPoolConfig, fallback to codexlensEmbeddingRotation + const poolConfig = config.embeddingPoolConfig; + const rotationConfig = config.codexlensEmbeddingRotation; + + // Get CodexLens settings path + const codexlensDir = join(homedir(), '.codexlens'); + const settingsPath = join(codexlensDir, 'settings.json'); + + // Ensure directory exists + if (!existsSync(codexlensDir)) { + mkdirSync(codexlensDir, { recursive: true }); + } + + // Load existing settings or create new + let settings: Record = {}; + if (existsSync(settingsPath)) { + try { + settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); + } catch { + settings = {}; + } + } + + // Check if either config is enabled + const isPoolEnabled = poolConfig && poolConfig.enabled; + const isRotationEnabled = rotationConfig && rotationConfig.enabled; + + // If neither is enabled, remove rotation endpoints and return + if (!isPoolEnabled && !isRotationEnabled) { + if (settings.litellm_rotation_endpoints) { + delete settings.litellm_rotation_endpoints; + delete settings.litellm_rotation_strategy; + delete settings.litellm_rotation_cooldown; + delete settings.litellm_target_model; + writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); + } + return { success: true, message: 'Rotation disabled, cleared endpoints', endpointCount: 0 }; + } + + // Generate rotation endpoints (function handles priority internally) + const endpoints = generateRotationEndpoints(baseDir); + + if (endpoints.length === 0) { + return { success: false, message: 'No valid endpoints generated from rotation config' }; + } + + // Update settings with rotation config (use poolConfig if available) + settings.litellm_rotation_endpoints = endpoints; + + if (isPoolEnabled) { + settings.litellm_rotation_strategy = poolConfig!.strategy; + settings.litellm_rotation_cooldown = poolConfig!.defaultCooldown; + settings.litellm_target_model = poolConfig!.targetModel; + } else { + settings.litellm_rotation_strategy = rotationConfig!.strategy; + settings.litellm_rotation_cooldown = rotationConfig!.defaultCooldown; + settings.litellm_target_model = rotationConfig!.targetModel; + } + + // Write updated settings + writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); + + return { + success: true, + message: `Synced ${endpoints.length} rotation endpoints to CodexLens`, + endpointCount: endpoints.length, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('[LiteLLM Config] Failed to sync CodexLens config:', errorMessage); + return { success: false, message: `Sync failed: ${errorMessage}` }; + } +} + +// =========================== +// Embedding Pool Management (Generic, with Auto-Discovery) +// =========================== + +/** + * Get embedding pool config + */ +export function getEmbeddingPoolConfig(baseDir: string): EmbeddingPoolConfig | undefined { + const config = loadLiteLLMApiConfig(baseDir); + return config.embeddingPoolConfig; +} + +/** + * Update embedding pool config + * Also triggers sync to CodexLens settings.json if enabled + */ +export function updateEmbeddingPoolConfig( + baseDir: string, + poolConfig: EmbeddingPoolConfig | undefined +): { syncResult: { success: boolean; message: string; endpointCount?: number } } { + const config = loadLiteLLMApiConfig(baseDir); + + if (poolConfig) { + config.embeddingPoolConfig = poolConfig; + } else { + delete config.embeddingPoolConfig; + } + + saveConfig(baseDir, config); + + // Auto-sync to CodexLens settings.json + const syncResult = syncCodexLensConfig(baseDir); + return { syncResult }; +} + +/** + * Discover all providers that offer a specific embedding model + * Returns list of {providerId, providerName, modelId, modelName, apiKeys[]} + */ +export function discoverProvidersForModel(baseDir: string, targetModel: string): Array<{ + providerId: string; + providerName: string; + modelId: string; + modelName: string; + apiKeys: Array<{ keyId: string; keyLabel: string; enabled: boolean }>; +}> { + const config = loadLiteLLMApiConfig(baseDir); + const result: Array<{ + providerId: string; + providerName: string; + modelId: string; + modelName: string; + apiKeys: Array<{ keyId: string; keyLabel: string; enabled: boolean }>; + }> = []; + + for (const provider of config.providers) { + if (!provider.enabled) continue; + + // Check if provider has embedding models matching targetModel + const matchingModels = (provider.embeddingModels || []).filter( + m => m.enabled && (m.id === targetModel || m.name === targetModel) + ); + + if (matchingModels.length === 0) continue; + + // Get API keys (single key or multiple from apiKeys array) + const apiKeys: Array<{ keyId: string; keyLabel: string; enabled: boolean }> = []; + + if (provider.apiKeys && provider.apiKeys.length > 0) { + // Use multi-key configuration + for (const keyEntry of provider.apiKeys) { + apiKeys.push({ + keyId: keyEntry.id, + keyLabel: keyEntry.label || keyEntry.id, + enabled: keyEntry.enabled, + }); + } + } else if (provider.apiKey) { + // Single key fallback + apiKeys.push({ + keyId: 'default', + keyLabel: 'Default Key', + enabled: true, + }); + } + + // Add each matching model + for (const model of matchingModels) { + result.push({ + providerId: provider.id, + providerName: provider.name, + modelId: model.id, + modelName: model.name, + apiKeys, + }); + } + } + + return result; +} + // =========================== // YAML Config Generation for ccw_litellm // =========================== @@ -713,4 +1009,4 @@ function objectToYaml(obj: unknown, indent: number = 0): string { } // Re-export types -export type { ProviderCredential, CustomEndpoint, ProviderType, CacheStrategy, CodexLensEmbeddingRotation, CodexLensEmbeddingProvider }; +export type { ProviderCredential, CustomEndpoint, ProviderType, CacheStrategy, CodexLensEmbeddingRotation, CodexLensEmbeddingProvider, EmbeddingPoolConfig }; diff --git a/ccw/src/core/routes/litellm-api-routes.ts b/ccw/src/core/routes/litellm-api-routes.ts index 95d82622..9a73ec76 100644 --- a/ccw/src/core/routes/litellm-api-routes.ts +++ b/ccw/src/core/routes/litellm-api-routes.ts @@ -26,14 +26,36 @@ import { updateCodexLensEmbeddingRotation, getEmbeddingProvidersForRotation, generateRotationEndpoints, + syncCodexLensConfig, + getEmbeddingPoolConfig, + updateEmbeddingPoolConfig, + discoverProvidersForModel, type ProviderCredential, type CustomEndpoint, type ProviderType, type CodexLensEmbeddingRotation, + type EmbeddingPoolConfig, } from '../../config/litellm-api-config-manager.js'; import { getContextCacheStore } from '../../tools/context-cache-store.js'; import { getLiteLLMClient } from '../../tools/litellm-client.js'; +// Cache for ccw-litellm status check +let ccwLitellmStatusCache: { + data: { installed: boolean; version?: string; error?: string } | null; + timestamp: number; + ttl: number; +} = { + data: null, + timestamp: 0, + ttl: 5 * 60 * 1000, // 5 minutes +}; + +// Clear cache (call after install) +export function clearCcwLitellmStatusCache() { + ccwLitellmStatusCache.data = null; + ccwLitellmStatusCache.timestamp = 0; +} + export interface RouteContext { pathname: string; url: URL; @@ -533,42 +555,56 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise = []; + const modelMap = new Map(); + + for (const provider of config.providers) { + if (!provider.enabled || !provider.embeddingModels) continue; + + for (const model of provider.embeddingModels) { + if (!model.enabled) continue; + + const key = model.id; + if (modelMap.has(key)) { + modelMap.get(key)!.providers.push(provider.name); + } else { + modelMap.set(key, { + modelId: model.id, + modelName: model.name, + providers: [provider.name], + }); + } + } + } + + availableModels.push(...Array.from(modelMap.values())); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + poolConfig: poolConfig || null, + availableModels, + })); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (err as Error).message })); + } + return true; + } + + // PUT /api/litellm-api/embedding-pool - Update pool config + if (pathname === '/api/litellm-api/embedding-pool' && req.method === 'PUT') { + handlePostRequest(req, res, async (body: unknown) => { + const poolConfig = body as EmbeddingPoolConfig | null; + + try { + const { syncResult } = updateEmbeddingPoolConfig(initialPath, poolConfig || undefined); + + broadcastToClients({ + type: 'EMBEDDING_POOL_UPDATED', + payload: { poolConfig, syncResult, timestamp: new Date().toISOString() } + }); + + return { success: true, poolConfig, syncResult }; + } catch (err) { + return { error: (err as Error).message, status: 500 }; + } + }); + return true; + } + + // GET /api/litellm-api/embedding-pool/discover/:model - Preview auto-discovery results + const discoverMatch = pathname.match(/^\/api\/litellm-api\/embedding-pool\/discover\/([^/]+)$/); + if (discoverMatch && req.method === 'GET') { + const targetModel = decodeURIComponent(discoverMatch[1]); + + try { + const discovered = discoverProvidersForModel(initialPath, targetModel); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + targetModel, + discovered, + count: discovered.length, + })); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (err as Error).message })); + } + return true; + } + // POST /api/litellm-api/ccw-litellm/install - Install ccw-litellm package if (pathname === '/api/litellm-api/ccw-litellm/install' && req.method === 'POST') { handlePostRequest(req, res, async () => { @@ -667,6 +813,8 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise { error += data.toString(); }); proc.on('close', (code) => { if (code === 0) { + // Clear status cache after successful installation + clearCcwLitellmStatusCache(); resolve({ success: true, message: 'ccw-litellm installed from PyPI' }); } else { resolve({ success: false, error: error || 'Installation failed' }); @@ -685,6 +833,9 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise { error += data.toString(); }); proc.on('close', (code) => { if (code === 0) { + // Clear status cache after successful installation + clearCcwLitellmStatusCache(); + // Broadcast installation event broadcastToClients({ type: 'CCW_LITELLM_INSTALLED', diff --git a/ccw/src/templates/dashboard-css/31-api-settings.css b/ccw/src/templates/dashboard-css/31-api-settings.css index 81103e2c..8619ce36 100644 --- a/ccw/src/templates/dashboard-css/31-api-settings.css +++ b/ccw/src/templates/dashboard-css/31-api-settings.css @@ -958,8 +958,8 @@ select.cli-input { /* Left Sidebar */ .api-settings-sidebar { - width: 280px; - min-width: 240px; + width: 320px; + min-width: 280px; border-right: 1px solid hsl(var(--border)); display: flex; flex-direction: column; diff --git a/ccw/src/templates/dashboard-js/i18n.js b/ccw/src/templates/dashboard-js/i18n.js index 620785cb..fa1fa1eb 100644 --- a/ccw/src/templates/dashboard-js/i18n.js +++ b/ccw/src/templates/dashboard-js/i18n.js @@ -19,6 +19,7 @@ const i18n = { 'common.delete': 'Delete', 'common.cancel': 'Cancel', 'common.save': 'Save', + 'common.include': 'Include', 'common.close': 'Close', 'common.loading': 'Loading...', 'common.error': 'Error', @@ -28,6 +29,8 @@ const i18n = { 'common.retry': 'Retry', 'common.refresh': 'Refresh', 'common.minutes': 'minutes', + 'common.enabled': 'Enabled', + 'common.disabled': 'Disabled', // Header 'header.project': 'Project:', @@ -267,7 +270,7 @@ const i18n = { 'codexlens.embeddingModel': 'Embedding Model', 'codexlens.modelHint': 'Select embedding model for vector search (models with ✓ are installed)', 'codexlens.concurrency': 'API Concurrency', - 'codexlens.concurrencyHint': 'Number of parallel API calls (1-32). Higher values speed up indexing but may hit rate limits.', + 'codexlens.concurrencyHint': 'Number of parallel API calls. Higher values speed up indexing but may hit rate limits.', 'codexlens.concurrencyCustom': 'Custom', 'codexlens.rotation': 'Multi-Provider Rotation', 'codexlens.rotationDesc': 'Aggregate multiple API providers and keys for parallel embedding generation', @@ -289,6 +292,8 @@ const i18n = { 'codexlens.selectKeys': 'Select Keys', 'codexlens.configureRotation': 'Configure Rotation', 'codexlens.rotationSaved': 'Rotation config saved successfully', + 'codexlens.endpointsSynced': 'endpoints synced to CodexLens', + 'codexlens.syncFailed': 'Sync failed', 'codexlens.rotationDeleted': 'Rotation config deleted', 'codexlens.totalEndpoints': 'Total Endpoints', 'codexlens.fullIndex': 'Full', @@ -312,6 +317,9 @@ const i18n = { 'codexlens.runSearch': 'Run Search', 'codexlens.results': 'Results', 'codexlens.resultsCount': 'results', + 'codexlens.resultLimit': 'Limit', + 'codexlens.contentLength': 'Content Length', + 'codexlens.extraFiles': 'Extra Files', 'codexlens.saveConfig': 'Save Configuration', 'codexlens.searching': 'Searching...', 'codexlens.searchCompleted': 'Search completed', @@ -1470,6 +1478,20 @@ const i18n = { 'apiSettings.endpointDeleted': 'Endpoint deleted successfully', 'apiSettings.cacheCleared': 'Cache cleared successfully', 'apiSettings.cacheSettingsUpdated': 'Cache settings updated', + 'apiSettings.embeddingPool': 'Embedding Pool', + 'apiSettings.embeddingPoolDesc': 'Auto-rotate between providers with same model', + 'apiSettings.targetModel': 'Target Model', + 'apiSettings.discoveredProviders': 'Discovered Providers', + 'apiSettings.autoDiscover': 'Auto-discover providers', + 'apiSettings.excludeProvider': 'Exclude', + 'apiSettings.defaultCooldown': 'Cooldown (seconds)', + 'apiSettings.defaultConcurrent': 'Concurrent per key', + 'apiSettings.poolEnabled': 'Enable Embedding Pool', + 'apiSettings.noProvidersFound': 'No providers found for this model', + 'apiSettings.poolSaved': 'Embedding pool config saved', + 'apiSettings.strategy': 'Strategy', + 'apiSettings.providerKeys': 'keys', + 'apiSettings.selectTargetModel': 'Select target model', 'apiSettings.confirmDeleteProvider': 'Are you sure you want to delete this provider?', 'apiSettings.confirmDeleteEndpoint': 'Are you sure you want to delete this endpoint?', 'apiSettings.confirmClearCache': 'Are you sure you want to clear the cache?', @@ -1703,6 +1725,7 @@ const i18n = { 'common.delete': '删除', 'common.cancel': '取消', 'common.save': '保存', + 'common.include': '包含', 'common.close': '关闭', 'common.loading': '加载中...', 'common.error': '错误', @@ -1712,6 +1735,8 @@ const i18n = { 'common.retry': '重试', 'common.refresh': '刷新', 'common.minutes': '分钟', + 'common.enabled': '已启用', + 'common.disabled': '已禁用', // Header 'header.project': '项目:', @@ -1951,7 +1976,7 @@ const i18n = { 'codexlens.embeddingModel': '嵌入模型', 'codexlens.modelHint': '选择向量搜索的嵌入模型(带 ✓ 的已安装)', 'codexlens.concurrency': 'API 并发数', - 'codexlens.concurrencyHint': '并行 API 调用数量(1-32)。较高的值可加速索引但可能触发速率限制。', + 'codexlens.concurrencyHint': '并行 API 调用数量。较高的值可加速索引但可能触发速率限制。', 'codexlens.concurrencyCustom': '自定义', 'codexlens.rotation': '多供应商轮训', 'codexlens.rotationDesc': '聚合多个 API 供应商和密钥进行并行嵌入生成', @@ -1973,6 +1998,8 @@ const i18n = { 'codexlens.selectKeys': '选择密钥', 'codexlens.configureRotation': '配置轮训', 'codexlens.rotationSaved': '轮训配置保存成功', + 'codexlens.endpointsSynced': '个端点已同步到 CodexLens', + 'codexlens.syncFailed': '同步失败', 'codexlens.rotationDeleted': '轮训配置已删除', 'codexlens.totalEndpoints': '总端点数', 'codexlens.fullIndex': '全部', @@ -1996,6 +2023,9 @@ const i18n = { 'codexlens.runSearch': '运行搜索', 'codexlens.results': '结果', 'codexlens.resultsCount': '个结果', + 'codexlens.resultLimit': '数量限制', + 'codexlens.contentLength': '内容长度', + 'codexlens.extraFiles': '额外文件', 'codexlens.saveConfig': '保存配置', 'codexlens.searching': '搜索中...', 'codexlens.searchCompleted': '搜索完成', @@ -3163,6 +3193,20 @@ const i18n = { 'apiSettings.endpointDeleted': '端点删除成功', 'apiSettings.cacheCleared': '缓存清除成功', 'apiSettings.cacheSettingsUpdated': '缓存设置已更新', + 'apiSettings.embeddingPool': '高可用嵌入', + 'apiSettings.embeddingPoolDesc': '自动轮训相同模型的供应商', + 'apiSettings.targetModel': '目标模型', + 'apiSettings.discoveredProviders': '发现的供应商', + 'apiSettings.autoDiscover': '自动发现供应商', + 'apiSettings.excludeProvider': '排除', + 'apiSettings.defaultCooldown': '冷却时间(秒)', + 'apiSettings.defaultConcurrent': '每密钥并发数', + 'apiSettings.poolEnabled': '启用嵌入池', + 'apiSettings.noProvidersFound': '未找到提供此模型的供应商', + 'apiSettings.poolSaved': '嵌入池配置已保存', + 'apiSettings.strategy': '策略', + 'apiSettings.providerKeys': '密钥', + 'apiSettings.selectTargetModel': '选择目标模型', 'apiSettings.confirmDeleteProvider': '确定要删除此提供商吗?', 'apiSettings.confirmDeleteEndpoint': '确定要删除此端点吗?', 'apiSettings.confirmClearCache': '确定要清除缓存吗?', diff --git a/ccw/src/templates/dashboard-js/views/api-settings.js b/ccw/src/templates/dashboard-js/views/api-settings.js index 5f07eee7..65d4f2e5 100644 --- a/ccw/src/templates/dashboard-js/views/api-settings.js +++ b/ccw/src/templates/dashboard-js/views/api-settings.js @@ -11,7 +11,12 @@ let selectedProviderId = null; let providerSearchQuery = ''; let activeModelTab = 'llm'; let expandedModelGroups = new Set(); -let activeSidebarTab = 'providers'; // 'providers' | 'endpoints' | 'cache' +let activeSidebarTab = 'providers'; // 'providers' | 'endpoints' | 'cache' | 'embedding-pool' + +// Embedding Pool state +let embeddingPoolConfig = null; +let embeddingPoolAvailableModels = []; +let embeddingPoolDiscoveredProviders = []; // ========== Data Loading ========== @@ -61,6 +66,112 @@ async function loadCacheStats() { } } +/** + * 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'); + + // 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 ========== /** @@ -825,6 +936,9 @@ async function renderApiSettings() { '' + + '' + '' + @@ -833,7 +947,7 @@ async function renderApiSettings() { // Build sidebar content based on active tab var sidebarContentHtml = ''; var addButtonHtml = ''; - + if (activeSidebarTab === 'providers') { sidebarContentHtml = '