diff --git a/.claude/cli-settings.json b/.claude/cli-settings.json new file mode 100644 index 00000000..3d4b8010 --- /dev/null +++ b/.claude/cli-settings.json @@ -0,0 +1,18 @@ +{ + "version": "1.0.0", + "defaultTool": "gemini", + "promptFormat": "plain", + "smartContext": { + "enabled": false, + "maxFiles": 10 + }, + "nativeResume": true, + "recursiveQuery": true, + "cache": { + "injectionMode": "auto", + "defaultPrefix": "", + "defaultSuffix": "" + }, + "codeIndexMcp": "ace", + "$schema": "./cli-settings.schema.json" +} diff --git a/.claude/cli-tools.json b/.claude/cli-tools.json index 412156ad..a644a7e3 100644 --- a/.claude/cli-tools.json +++ b/.claude/cli-tools.json @@ -1,39 +1,50 @@ { - "version": "1.0.0", + "version": "2.0.0", "tools": { "gemini": { "enabled": true, "isBuiltin": true, "command": "gemini", - "description": "Google AI for code analysis" + "description": "Google AI for code analysis", + "tags": [] }, "qwen": { "enabled": true, "isBuiltin": true, "command": "qwen", - "description": "Alibaba AI assistant" + "description": "Alibaba AI assistant", + "tags": [] }, "codex": { "enabled": true, "isBuiltin": true, "command": "codex", - "description": "OpenAI code generation" + "description": "OpenAI code generation", + "tags": [] }, "claude": { "enabled": true, "isBuiltin": true, "command": "claude", - "description": "Anthropic AI assistant" + "description": "Anthropic AI assistant", + "tags": [] }, "opencode": { "enabled": true, "isBuiltin": true, "command": "opencode", "description": "OpenCode AI assistant", - "primaryModel": "opencode/glm-4.7-free" + "primaryModel": "opencode/glm-4.7-free", + "tags": [] } }, - "customEndpoints": [], + "customEndpoints": [ + { + "id": "g25", + "name": "g25", + "enabled": true + } + ], "defaultTool": "gemini", "settings": { "promptFormat": "plain", @@ -48,7 +59,7 @@ "defaultPrefix": "", "defaultSuffix": "" }, - "codeIndexMcp": "ace" + "codeIndexMcp": "codexlens" }, "$schema": "./cli-tools.schema.json" } \ No newline at end of file diff --git a/ccw/src/config/litellm-api-config-manager.ts b/ccw/src/config/litellm-api-config-manager.ts index a5b12f20..a7ec0d54 100644 --- a/ccw/src/config/litellm-api-config-manager.ts +++ b/ccw/src/config/litellm-api-config-manager.ts @@ -1033,5 +1033,219 @@ function objectToYaml(obj: unknown, indent: number = 0): string { return String(obj); } +// =========================== +// Multi-Model Pool Management +// =========================== + +/** + * Migrate legacy embeddingPoolConfig to new modelPools array + * This function ensures backward compatibility with existing configurations + */ +function migrateEmbeddingPoolToModelPools(config: LiteLLMApiConfig): void { + // Skip if already has modelPools or no legacy config + if (config.modelPools && config.modelPools.length > 0) return; + if (!config.embeddingPoolConfig) return; + + // Convert legacy embeddingPoolConfig to ModelPoolConfig + const legacyPool = config.embeddingPoolConfig; + const modelPool: import('../types/litellm-api-config.js').ModelPoolConfig = { + id: `pool-embedding-${Date.now()}`, + modelType: 'embedding', + enabled: legacyPool.enabled, + targetModel: legacyPool.targetModel, + strategy: legacyPool.strategy, + autoDiscover: legacyPool.autoDiscover, + excludedProviderIds: legacyPool.excludedProviderIds || [], + defaultCooldown: legacyPool.defaultCooldown, + defaultMaxConcurrentPerKey: legacyPool.defaultMaxConcurrentPerKey, + name: `Embedding Pool - ${legacyPool.targetModel}`, + description: 'Migrated from legacy embeddingPoolConfig', + }; + + config.modelPools = [modelPool]; + // Keep legacy config for backward compatibility with old CodexLens versions +} + +/** + * Get all model pool configurations + * Returns empty array if no pools configured + */ +export function getModelPools(baseDir: string): import('../types/litellm-api-config.js').ModelPoolConfig[] { + const config = loadLiteLLMApiConfig(baseDir); + + // Auto-migrate if needed + migrateEmbeddingPoolToModelPools(config); + + return config.modelPools || []; +} + +/** + * Get a specific model pool by ID + */ +export function getModelPool( + baseDir: string, + poolId: string +): import('../types/litellm-api-config.js').ModelPoolConfig | undefined { + const pools = getModelPools(baseDir); + return pools.find(p => p.id === poolId); +} + +/** + * Add a new model pool configuration + */ +export function addModelPool( + baseDir: string, + poolConfig: Omit +): { poolId: string; syncResult?: { success: boolean; message: string; endpointCount?: number } } { + const config = loadLiteLLMApiConfig(baseDir); + + // Auto-migrate if needed + migrateEmbeddingPoolToModelPools(config); + + // Ensure modelPools array exists + if (!config.modelPools) { + config.modelPools = []; + } + + // Generate unique ID + const poolId = `pool-${poolConfig.modelType}-${Date.now()}`; + + const newPool: import('../types/litellm-api-config.js').ModelPoolConfig = { + ...poolConfig, + id: poolId, + }; + + config.modelPools.push(newPool); + saveConfig(baseDir, config); + + // Sync to CodexLens if this is an embedding pool + const syncResult = poolConfig.modelType === 'embedding' && poolConfig.enabled + ? syncCodexLensConfig(baseDir) + : undefined; + + return { poolId, syncResult }; +} + +/** + * Update an existing model pool configuration + */ +export function updateModelPool( + baseDir: string, + poolId: string, + updates: Partial> +): { success: boolean; syncResult?: { success: boolean; message: string; endpointCount?: number } } { + const config = loadLiteLLMApiConfig(baseDir); + + // Auto-migrate if needed + migrateEmbeddingPoolToModelPools(config); + + if (!config.modelPools) { + return { success: false }; + } + + const poolIndex = config.modelPools.findIndex(p => p.id === poolId); + if (poolIndex === -1) { + return { success: false }; + } + + // Apply updates + config.modelPools[poolIndex] = { + ...config.modelPools[poolIndex], + ...updates, + }; + + saveConfig(baseDir, config); + + // Sync to CodexLens if this is an enabled embedding pool + const pool = config.modelPools[poolIndex]; + const syncResult = pool.modelType === 'embedding' && pool.enabled + ? syncCodexLensConfig(baseDir) + : undefined; + + return { success: true, syncResult }; +} + +/** + * Delete a model pool configuration + */ +export function deleteModelPool( + baseDir: string, + poolId: string +): { success: boolean; syncResult?: { success: boolean; message: string; endpointCount?: number } } { + const config = loadLiteLLMApiConfig(baseDir); + + if (!config.modelPools) { + return { success: false }; + } + + const poolIndex = config.modelPools.findIndex(p => p.id === poolId); + if (poolIndex === -1) { + return { success: false }; + } + + const deletedPool = config.modelPools[poolIndex]; + config.modelPools.splice(poolIndex, 1); + + saveConfig(baseDir, config); + + // Sync to CodexLens if we deleted an embedding pool + const syncResult = deletedPool.modelType === 'embedding' + ? syncCodexLensConfig(baseDir) + : undefined; + + return { success: true, syncResult }; +} + +/** + * Get available models for a specific model type + * Used for pool configuration UI + */ +export function getAvailableModelsForType( + baseDir: string, + modelType: import('../types/litellm-api-config.js').ModelPoolType +): Array<{ modelId: string; modelName: string; providers: string[] }> { + const config = loadLiteLLMApiConfig(baseDir); + const availableModels: Array<{ modelId: string; modelName: string; providers: string[] }> = []; + const modelMap = new Map(); + + for (const provider of config.providers) { + if (!provider.enabled) continue; + + let models: typeof provider.embeddingModels | undefined; + + switch (modelType) { + case 'embedding': + models = provider.embeddingModels; + break; + case 'llm': + models = provider.llmModels; + break; + case 'reranker': + models = provider.rerankerModels; + break; + } + + if (!models) continue; + + for (const model of models) { + 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())); + return availableModels; +} + // Re-export types export type { ProviderCredential, CustomEndpoint, ProviderType, CacheStrategy, CodexLensEmbeddingRotation, CodexLensEmbeddingProvider, EmbeddingPoolConfig }; diff --git a/ccw/src/core/routes/cli-routes.ts b/ccw/src/core/routes/cli-routes.ts index c6b4e618..3c75a599 100644 --- a/ccw/src/core/routes/cli-routes.ts +++ b/ccw/src/core/routes/cli-routes.ts @@ -34,6 +34,8 @@ import { import { loadClaudeCliTools, saveClaudeCliTools, + loadClaudeCliSettings, + saveClaudeCliSettings, updateClaudeToolEnabled, updateClaudeCacheSettings, getClaudeCliToolsInfo, @@ -704,11 +706,13 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { // API: Get CLI Tools Config from .claude/cli-tools.json (with fallback to global) if (pathname === '/api/cli/tools-config' && req.method === 'GET') { try { - const config = loadClaudeCliTools(initialPath); + const toolsConfig = loadClaudeCliTools(initialPath); + const settingsConfig = loadClaudeCliSettings(initialPath); const info = getClaudeCliToolsInfo(initialPath); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ - ...config, + tools: toolsConfig, + settings: settingsConfig, _configInfo: info })); } catch (err) { @@ -722,32 +726,55 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { if (pathname === '/api/cli/tools-config' && req.method === 'PUT') { handlePostRequest(req, res, async (body: unknown) => { try { - const updates = body as Partial; - const config = loadClaudeCliTools(initialPath); + const updates = body as { tools?: any; settings?: any }; - // Merge updates - const updatedConfig = { - ...config, - ...updates, - tools: { ...config.tools, ...(updates.tools || {}) }, - settings: { - ...config.settings, - ...(updates.settings || {}), + // Update tools config if provided + if (updates.tools) { + const currentTools = loadClaudeCliTools(initialPath); + const updatedTools = { + ...currentTools, + tools: { ...currentTools.tools, ...(updates.tools.tools || {}) }, + customEndpoints: updates.tools.customEndpoints || currentTools.customEndpoints + }; + saveClaudeCliTools(initialPath, updatedTools); + } + + // Update settings config if provided + if (updates.settings) { + const currentSettings = loadClaudeCliSettings(initialPath); + const s = updates.settings; + + // Deep merge: only update fields that are explicitly provided + const updatedSettings = { + ...currentSettings, + // Scalar fields: only update if explicitly provided + ...(s.defaultTool !== undefined && { defaultTool: s.defaultTool }), + ...(s.promptFormat !== undefined && { promptFormat: s.promptFormat }), + ...(s.nativeResume !== undefined && { nativeResume: s.nativeResume }), + ...(s.recursiveQuery !== undefined && { recursiveQuery: s.recursiveQuery }), + ...(s.codeIndexMcp !== undefined && { codeIndexMcp: s.codeIndexMcp }), + // Nested objects: deep merge + smartContext: { + ...currentSettings.smartContext, + ...(s.smartContext || {}) + }, cache: { - ...config.settings.cache, - ...(updates.settings?.cache || {}) + ...currentSettings.cache, + ...(s.cache || {}) } - } - }; + }; + saveClaudeCliSettings(initialPath, updatedSettings); + } - saveClaudeCliTools(initialPath, updatedConfig); + const toolsConfig = loadClaudeCliTools(initialPath); + const settingsConfig = loadClaudeCliSettings(initialPath); broadcastToClients({ type: 'CLI_TOOLS_CONFIG_UPDATED', - payload: { config: updatedConfig, timestamp: new Date().toISOString() } + payload: { tools: toolsConfig, settings: settingsConfig, timestamp: new Date().toISOString() } }); - return { success: true, config: updatedConfig }; + return { success: true, tools: toolsConfig, settings: settingsConfig }; } catch (err) { return { error: (err as Error).message, status: 500 }; } @@ -782,14 +809,14 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { handlePostRequest(req, res, async (body: unknown) => { try { const cacheSettings = body as { injectionMode?: string; defaultPrefix?: string; defaultSuffix?: string }; - const config = updateClaudeCacheSettings(initialPath, cacheSettings as any); + const settings = updateClaudeCacheSettings(initialPath, cacheSettings as any); broadcastToClients({ type: 'CLI_CACHE_SETTINGS_UPDATED', - payload: { cache: config.settings.cache, timestamp: new Date().toISOString() } + payload: { cache: settings.cache, timestamp: new Date().toISOString() } }); - return { success: true, config }; + return { success: true, settings }; } catch (err) { return { error: (err as Error).message, status: 500 }; } diff --git a/ccw/src/core/routes/litellm-api-routes.ts b/ccw/src/core/routes/litellm-api-routes.ts index 6624cf94..384f8f77 100644 --- a/ccw/src/core/routes/litellm-api-routes.ts +++ b/ccw/src/core/routes/litellm-api-routes.ts @@ -4,9 +4,35 @@ */ import { fileURLToPath } from 'url'; import { dirname, join as pathJoin } from 'path'; +import { z } from 'zod'; import { getSystemPython } from '../../utils/python-utils.js'; import type { RouteContext } from './types.js'; +// ========== Input Validation Schemas ========== + +/** + * Validation schema for ModelPoolConfig + * Used to validate incoming API requests for model pool operations + */ +const ModelPoolConfigSchema = z.object({ + modelType: z.enum(['embedding', 'llm', 'reranker']), + enabled: z.boolean(), + targetModel: z.string().min(1, 'Target model is required'), + strategy: z.enum(['round_robin', 'latency_aware', 'weighted_random']), + autoDiscover: z.boolean(), + excludedProviderIds: z.array(z.string()).optional().default([]), + defaultCooldown: z.number().int().min(0).default(60), + defaultMaxConcurrentPerKey: z.number().int().min(1).default(4), + name: z.string().optional(), + description: z.string().optional(), +}); + +/** + * Partial schema for updating ModelPoolConfig + * All fields are optional for PATCH-like updates + */ +const ModelPoolConfigUpdateSchema = ModelPoolConfigSchema.partial(); + // Get current module path for package-relative lookups const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -39,6 +65,12 @@ import { getEmbeddingPoolConfig, updateEmbeddingPoolConfig, discoverProvidersForModel, + getModelPools, + getModelPool, + addModelPool, + updateModelPool, + deleteModelPool, + getAvailableModelsForType, type ProviderCredential, type CustomEndpoint, type ProviderType, @@ -856,6 +888,186 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise { + // Validate input using zod schema + const validationResult = ModelPoolConfigSchema.safeParse(body); + if (!validationResult.success) { + return { + error: 'Invalid request body', + details: validationResult.error.issues.map(e => ({ + field: String(e.path.join('.')), + message: e.message + })), + status: 400 + }; + } + + try { + const poolConfig = validationResult.data; + const result = addModelPool(initialPath, poolConfig); + + broadcastToClients({ + type: 'MODEL_POOL_CREATED', + payload: { poolId: result.poolId, timestamp: new Date().toISOString() } + }); + + return { success: true, ...result }; + } catch (err) { + return { error: (err as Error).message, status: 500 }; + } + }); + return true; + } + + // PUT /api/litellm-api/model-pools/:id - Update model pool + const poolPutMatch = pathname.match(/^\/api\/litellm-api\/model-pools\/([^/]+)$/); + if (poolPutMatch && req.method === 'PUT') { + const poolId = decodeURIComponent(poolPutMatch[1]); + + handlePostRequest(req, res, async (body: unknown) => { + // Validate input using partial schema (all fields optional for updates) + const validationResult = ModelPoolConfigUpdateSchema.safeParse(body); + if (!validationResult.success) { + return { + error: 'Invalid request body', + details: validationResult.error.issues.map(e => ({ + field: String(e.path.join('.')), + message: e.message + })), + status: 400 + }; + } + + try { + const updates = validationResult.data; + const result = updateModelPool(initialPath, poolId, updates); + + if (!result.success) { + return { error: 'Pool not found', status: 404 }; + } + + broadcastToClients({ + type: 'MODEL_POOL_UPDATED', + payload: { poolId, syncResult: result.syncResult, timestamp: new Date().toISOString() } + }); + + return result; + } catch (err) { + return { error: (err as Error).message, status: 500 }; + } + }); + return true; + } + + // DELETE /api/litellm-api/model-pools/:id - Delete model pool + const poolDeleteMatch = pathname.match(/^\/api\/litellm-api\/model-pools\/([^/]+)$/); + if (poolDeleteMatch && req.method === 'DELETE') { + const poolId = decodeURIComponent(poolDeleteMatch[1]); + + try { + const result = deleteModelPool(initialPath, poolId); + + if (!result.success) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Pool not found' })); + return true; + } + + broadcastToClients({ + type: 'MODEL_POOL_DELETED', + payload: { poolId, syncResult: result.syncResult, timestamp: new Date().toISOString() } + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (err as Error).message })); + } + return true; + } + + // GET /api/litellm-api/model-pools/available-models/:modelType - Get available models for type + const availableModelsMatch = pathname.match(/^\/api\/litellm-api\/model-pools\/available-models\/([^/]+)$/); + if (availableModelsMatch && req.method === 'GET') { + const modelType = decodeURIComponent(availableModelsMatch[1]) as import('../../types/litellm-api-config.js').ModelPoolType; + + try { + const availableModels = getAvailableModelsForType(initialPath, modelType); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ availableModels, modelType })); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (err as Error).message })); + } + return true; + } + + // GET /api/litellm-api/model-pools/discover/:modelType/:model - Discover providers for model + const discoverPoolMatch = pathname.match(/^\/api\/litellm-api\/model-pools\/discover\/([^/]+)\/([^/]+)$/); + if (discoverPoolMatch && req.method === 'GET') { + const modelType = decodeURIComponent(discoverPoolMatch[1]); + const targetModel = decodeURIComponent(discoverPoolMatch[2]); + + try { + const discovered = discoverProvidersForModel(initialPath, targetModel); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + modelType, + 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 () => { diff --git a/ccw/src/core/server.ts b/ccw/src/core/server.ts index 6d8a8b8c..9b90f68a 100644 --- a/ccw/src/core/server.ts +++ b/ccw/src/core/server.ts @@ -550,15 +550,15 @@ export async function startServer(options: ServerOptions = {}): Promise' + ' ' + t('apiSettings.cliSettings') + '' + + '' + '' + ''; } else if (activeSidebarTab === 'cache') { sidebarContentHtml = '
' + '

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

' + @@ -1177,6 +1259,16 @@ async function renderApiSettings() { renderEndpointsMainPanel(); } else if (activeSidebarTab === 'embedding-pool') { renderEmbeddingPoolMainPanel(); + } else if (activeSidebarTab === 'model-pools') { + renderModelPoolsList(); + // Auto-select first pool if exists + if (!selectedPoolId && modelPools && modelPools.length > 0) { + selectModelPool(modelPools[0].id); + } else if (selectedPoolId) { + renderModelPoolDetail(selectedPoolId); + } else { + renderModelPoolEmptyState(); + } } else if (activeSidebarTab === 'cache') { renderCacheMainPanel(); } else if (activeSidebarTab === 'cli-settings') { @@ -1574,7 +1666,8 @@ function getDefaultApiBase(type) { */ async function toggleProviderEnabled(providerId, enabled) { try { - var response = await fetch('/api/litellm-api/providers/' + providerId, { + await initCsrfToken(); + var response = await csrfFetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: enabled }) @@ -2015,7 +2108,7 @@ function saveNewModel(event, providerId, modelType) { } models.push(newModel); - return fetch('/api/litellm-api/providers/' + providerId, { + return csrfFetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ [modelsKey]: models }) @@ -2260,7 +2353,7 @@ function saveModelSettings(event, providerId, modelId, modelType) { var updateData = {}; updateData[modelsKey] = models; - return fetch('/api/litellm-api/providers/' + providerId, { + return csrfFetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updateData) @@ -2299,7 +2392,7 @@ function deleteModel(providerId, modelId, modelType) { var updateData = {}; updateData[modelsKey] = updatedModels; - return fetch('/api/litellm-api/providers/' + providerId, { + return csrfFetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updateData) @@ -2342,7 +2435,8 @@ async function saveProviderApiBase(providerId) { } try { - var response = await fetch('/api/litellm-api/providers/' + providerId, { + await initCsrfToken(); + var response = await csrfFetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiBase: newApiBase || undefined }) @@ -2394,7 +2488,8 @@ async function deleteProviderWithConfirm(providerId) { if (!confirm(t('apiSettings.confirmDeleteProvider'))) return; try { - var response = await fetch('/api/litellm-api/providers/' + providerId, { + await initCsrfToken(); + var response = await csrfFetch('/api/litellm-api/providers/' + providerId, { method: 'DELETE' }); @@ -2428,7 +2523,8 @@ async function deleteProviderWithConfirm(providerId) { */ async function syncConfigToCodexLens() { try { - var response = await fetch('/api/litellm-api/config/sync', { + await initCsrfToken(); + var response = await csrfFetch('/api/litellm-api/config/sync', { method: 'POST' }); @@ -2992,9 +3088,10 @@ async function onTargetModelChange(modelId) { // Update sidebar summary const sidebarContainer = document.querySelector('.api-settings-sidebar'); if (sidebarContainer) { - const contentArea = sidebarContainer.querySelector('.provider-list, .endpoints-list, .embedding-pool-sidebar-info, .embedding-pool-sidebar-summary, .cache-sidebar-info'); - if (contentArea && contentArea.parentElement) { - contentArea.parentElement.innerHTML = renderEmbeddingPoolSidebar(); + const contentArea = sidebarContainer.querySelector('.provider-list, .endpoints-list, .embedding-pool-sidebar-info, .embedding-pool-sidebar-summary, .cache-sidebar-info, .cli-settings-list'); + if (contentArea) { + // Use outerHTML to replace only the content area, not the entire sidebar + contentArea.outerHTML = renderEmbeddingPoolSidebar(); if (window.lucide) lucide.createIcons(); } } @@ -3253,7 +3350,7 @@ function addApiKey(providerId) { .then(function(provider) { const apiKeys = provider.apiKeys || []; apiKeys.push(newKey); - return fetch('/api/litellm-api/providers/' + providerId, { + return csrfFetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKeys: apiKeys }) @@ -3279,7 +3376,7 @@ function removeApiKey(providerId, keyId) { .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, { + return csrfFetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKeys: apiKeys }) @@ -3307,7 +3404,7 @@ function updateApiKeyField(providerId, keyId, field, value) { if (keyIndex >= 0) { apiKeys[keyIndex][field] = value; } - return fetch('/api/litellm-api/providers/' + providerId, { + return csrfFetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKeys: apiKeys }) @@ -3322,7 +3419,7 @@ function updateApiKeyField(providerId, keyId, field, value) { * Update provider routing strategy */ function updateProviderRouting(providerId, strategy) { - fetch('/api/litellm-api/providers/' + providerId, { + csrfFetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ routingStrategy: strategy }) @@ -3340,7 +3437,7 @@ function updateHealthCheckEnabled(providerId, enabled) { .then(function(provider) { const healthCheck = provider.healthCheck || { intervalSeconds: 300, cooldownSeconds: 5, failureThreshold: 3 }; healthCheck.enabled = enabled; - return fetch('/api/litellm-api/providers/' + providerId, { + return csrfFetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ healthCheck: healthCheck }) @@ -3365,7 +3462,7 @@ function updateHealthCheckField(providerId, field, value) { .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, { + return csrfFetch('/api/litellm-api/providers/' + providerId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ healthCheck: healthCheck }) @@ -3385,7 +3482,7 @@ function testApiKey(providerId, keyId) { btn.classList.add('testing'); btn.textContent = t('apiSettings.testingKey'); - fetch('/api/litellm-api/providers/' + providerId + '/test-key', { + csrfFetch('/api/litellm-api/providers/' + providerId + '/test-key', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keyId: keyId }) @@ -3525,7 +3622,8 @@ async function installCcwLitellm() { } try { - var response = await fetch('/api/litellm-api/ccw-litellm/install', { + await initCsrfToken(); + var response = await csrfFetch('/api/litellm-api/ccw-litellm/install', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) @@ -3566,7 +3664,8 @@ async function uninstallCcwLitellm() { } try { - var response = await fetch('/api/litellm-api/ccw-litellm/uninstall', { + await initCsrfToken(); + var response = await csrfFetch('/api/litellm-api/ccw-litellm/uninstall', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) @@ -3953,6 +4052,387 @@ async function submitCliSettings() { } } +// ========== Multi-Model Pool Management ========== + +/** + * Render model pools list in sidebar + */ +function renderModelPoolsList() { + var container = document.getElementById('model-pools-list'); + if (!container) return; + + if (!modelPools || modelPools.length === 0) { + container.innerHTML = '
' + + '' + + '

' + t('apiSettings.noPoolsConfigured') + '

' + + '
'; + if (window.lucide) lucide.createIcons(); + return; + } + + // Group pools by type + var poolsByType = { + embedding: [], + llm: [], + reranker: [] + }; + + modelPools.forEach(function(pool) { + if (poolsByType[pool.modelType]) { + poolsByType[pool.modelType].push(pool); + } + }); + + var html = ''; + + // Render each type group + ['embedding', 'llm', 'reranker'].forEach(function(type) { + var pools = poolsByType[type]; + if (pools.length === 0) return; + + var typeLabel = type === 'embedding' ? t('apiSettings.embeddingPools') : + type === 'llm' ? t('apiSettings.llmPools') : + t('apiSettings.rerankerPools'); + + html += '
' + + '
' + + typeLabel + + '
'; + + pools.forEach(function(pool) { + var isSelected = selectedPoolId === pool.id; + var statusClass = pool.enabled ? 'status-enabled' : 'status-disabled'; + var statusText = pool.enabled ? t('common.enabled') : t('common.disabled'); + + html += '
' + + '
' + + '
' + + '
' + escapeHtml(pool.name || pool.targetModel) + '
' + + '
' + escapeHtml(pool.targetModel) + '
' + + '
' + + '' + statusText + '' + + '
' + + '
'; + }); + + html += '
'; + }); + + container.innerHTML = html; + if (window.lucide) lucide.createIcons(); +} + +/** + * Select a model pool + */ +function selectModelPool(poolId) { + selectedPoolId = poolId; + renderModelPoolsList(); + renderModelPoolDetail(poolId); +} + +/** + * Render model pool detail in main panel + */ +function renderModelPoolDetail(poolId) { + var container = document.getElementById('provider-detail-panel'); + if (!container) return; + + var pool = modelPools.find(function(p) { return p.id === poolId; }); + if (!pool) { + renderModelPoolEmptyState(); + return; + } + + var typeLabel = pool.modelType === 'embedding' ? t('apiSettings.embedding') : + pool.modelType === 'llm' ? t('apiSettings.llm') : + t('apiSettings.reranker'); + + var html = '
' + + '
' + + '
' + + '

' + escapeHtml(pool.name || pool.targetModel) + '

' + + '

' + typeLabel + ' Pool

' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + + // Basic Info + '
' + + '

' + t('apiSettings.basicInfo') + '

' + + '
' + + '
' + (pool.enabled ? t('common.enabled') : t('common.disabled')) + '
' + + '
' + typeLabel + '
' + + '
' + escapeHtml(pool.targetModel) + '
' + + '
' + pool.strategy + '
' + + '
' + (pool.autoDiscover ? t('common.yes') : t('common.no')) + '
' + + '
' + pool.defaultCooldown + 's
' + + '
' + pool.defaultMaxConcurrentPerKey + '
' + + '
' + + '
'; + + if (pool.description) { + html += '
' + + '

' + t('apiSettings.description') + '

' + + '

' + escapeHtml(pool.description) + '

' + + '
'; + } + + // Excluded Providers + if (pool.excludedProviderIds && pool.excludedProviderIds.length > 0) { + html += '
' + + '

' + t('apiSettings.excludedProviders') + '

' + + '
'; + + pool.excludedProviderIds.forEach(function(providerId) { + html += '' + escapeHtml(providerId) + ''; + }); + + html += '
'; + } + + html += '
'; + + container.innerHTML = html; + if (window.lucide) lucide.createIcons(); +} + +/** + * Render empty state for model pools + */ +function renderModelPoolEmptyState() { + var container = document.getElementById('provider-detail-panel'); + if (!container) return; + + container.innerHTML = '
' + + '' + + '

' + t('apiSettings.noPoolSelected') + '

' + + '

' + t('apiSettings.selectPoolFromList') + '

' + + '
'; + + if (window.lucide) lucide.createIcons(); +} + +/** + * Show add model pool modal + */ +function showAddModelPoolModal() { + var modalHtml = '
' + + '
' + + '
' + + '

' + t('apiSettings.addModelPool') + '

' + + '' + + '
' + + '
' + + '
' + + + '
' + + '' + + '' + + '
' + + + '
' + + '' + + '' + + '
' + + + '
' + + '' + + '' + + '
' + + + '
' + + '' + + '' + + '
' + + + '
' + + '' + + '' + + '
' + + + '
' + + '' + + '' + + '
' + + + '
' + + '' + + '' + + '
' + + + '
' + + '' + + '
' + + + '
' + + '' + + '
' + + + '' + + '
' + + '
' + + '
' + + '
'; + + document.body.insertAdjacentHTML('beforeend', modalHtml); + if (window.lucide) lucide.createIcons(); +} + +/** + * Close add pool modal + */ +function closeAddPoolModal() { + var modal = document.getElementById('add-pool-modal'); + if (modal) modal.remove(); +} + +/** + * Handle pool model type change + */ +async function onPoolModelTypeChange() { + var modelType = document.getElementById('pool-model-type').value; + var targetModelSelect = document.getElementById('pool-target-model'); + + if (!modelType) { + targetModelSelect.disabled = true; + targetModelSelect.innerHTML = ''; + return; + } + + // Load available models for this type + var models = await loadAvailableModelsForType(modelType); + + targetModelSelect.disabled = false; + targetModelSelect.innerHTML = ''; + + models.forEach(function(model) { + var option = document.createElement('option'); + option.value = model.modelId; + option.textContent = model.modelName + ' (' + model.providers.length + ' providers)'; + targetModelSelect.appendChild(option); + }); +} + +/** + * Submit model pool form + */ +async function submitModelPool(event) { + event.preventDefault(); + + var poolData = { + modelType: document.getElementById('pool-model-type').value, + name: document.getElementById('pool-name').value, + targetModel: document.getElementById('pool-target-model').value, + strategy: document.getElementById('pool-strategy').value, + defaultCooldown: parseInt(document.getElementById('pool-cooldown').value), + defaultMaxConcurrentPerKey: parseInt(document.getElementById('pool-max-concurrent').value), + description: document.getElementById('pool-description').value, + enabled: document.getElementById('pool-enabled').checked, + autoDiscover: document.getElementById('pool-auto-discover').checked, + excludedProviderIds: [] + }; + + try { + await initCsrfToken(); + var response = await csrfFetch('/api/litellm-api/model-pools', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(poolData) + }); + + if (!response.ok) { + var err = await response.json(); + throw new Error(err.error || 'Failed to create pool'); + } + + var result = await response.json(); + showRefreshToast(t('apiSettings.poolCreated'), 'success'); + + closeAddPoolModal(); + + // Reload pools and switch to model-pools tab + await loadModelPools(); + activeSidebarTab = 'model-pools'; + renderApiSettings(); + } catch (err) { + showRefreshToast(t('common.error') + ': ' + err.message, 'error'); + } +} + +/** + * Edit model pool + */ +function editModelPool(poolId) { + // TODO: Implement edit modal + showRefreshToast('Edit functionality coming soon', 'info'); +} + +/** + * Delete model pool + */ +async function deleteModelPool(poolId) { + if (!confirm(t('apiSettings.confirmDeletePool'))) { + return; + } + + try { + await initCsrfToken(); + var response = await csrfFetch('/api/litellm-api/model-pools/' + poolId, { + method: 'DELETE' + }); + + if (!response.ok) { + var err = await response.json(); + throw new Error(err.error || 'Failed to delete pool'); + } + + showRefreshToast(t('apiSettings.poolDeleted'), 'success'); + + // Reload pools + selectedPoolId = null; + await loadModelPools(); + renderApiSettings(); + } catch (err) { + showRefreshToast(t('common.error') + ': ' + err.message, 'error'); + } +} + +// Make model pool functions globally accessible +window.loadModelPools = loadModelPools; +window.renderModelPoolsList = renderModelPoolsList; +window.selectModelPool = selectModelPool; +window.renderModelPoolDetail = renderModelPoolDetail; +window.renderModelPoolEmptyState = renderModelPoolEmptyState; +window.showAddModelPoolModal = showAddModelPoolModal; +window.closeAddPoolModal = closeAddPoolModal; +window.onPoolModelTypeChange = onPoolModelTypeChange; +window.submitModelPool = submitModelPool; +window.editModelPool = editModelPool; +window.deleteModelPool = deleteModelPool; + // Make CLI Settings functions globally accessible window.loadCliSettings = loadCliSettings; window.saveCliSettingsEndpoint = saveCliSettingsEndpoint; diff --git a/ccw/src/templates/dashboard-js/views/cli-manager.js b/ccw/src/templates/dashboard-js/views/cli-manager.js index 3e6f3e84..9dea6a18 100644 --- a/ccw/src/templates/dashboard-js/views/cli-manager.js +++ b/ccw/src/templates/dashboard-js/views/cli-manager.js @@ -22,6 +22,12 @@ async function csrfFetch(url, options) { // Add CSRF token header for state-changing methods var method = (options.method || 'GET').toUpperCase(); + + // Auto-initialize CSRF token for state-changing requests + if (['POST', 'PUT', 'PATCH', 'DELETE'].indexOf(method) !== -1) { + await initCsrfToken(); + } + if (['POST', 'PUT', 'PATCH', 'DELETE'].indexOf(method) !== -1 && csrfToken) { options.headers['X-CSRF-Token'] = csrfToken; } diff --git a/ccw/src/tools/claude-cli-tools.ts b/ccw/src/tools/claude-cli-tools.ts index 9f5fedc1..922b6a2a 100644 --- a/ccw/src/tools/claude-cli-tools.ts +++ b/ccw/src/tools/claude-cli-tools.ts @@ -1,8 +1,9 @@ /** * Claude CLI Tools Configuration Manager - * Manages .claude/cli-tools.json with fallback: - * 1. Project workspace: {projectDir}/.claude/cli-tools.json (priority) - * 2. Global: ~/.claude/cli-tools.json (fallback) + * Manages .claude/cli-tools.json (tools) and .claude/cli-settings.json (settings) + * with fallback: + * 1. Project workspace: {projectDir}/.claude/ (priority) + * 2. Global: ~/.claude/ (fallback) */ import * as fs from 'fs'; import * as path from 'path'; @@ -15,6 +16,15 @@ export interface ClaudeCliTool { isBuiltin: boolean; command: string; description: string; + primaryModel?: string; + tags: string[]; +} + +export interface ClaudeCustomEndpoint { + id: string; + name: string; + enabled: boolean; + tags: string[]; } export interface ClaudeCacheSettings { @@ -23,76 +33,107 @@ export interface ClaudeCacheSettings { defaultSuffix: string; } +// New: Tools-only config (cli-tools.json) export interface ClaudeCliToolsConfig { $schema?: string; version: string; tools: Record; - customEndpoints: Array<{ - id: string; - name: string; - enabled: boolean; - }>; + customEndpoints: ClaudeCustomEndpoint[]; +} + +// New: Settings-only config (cli-settings.json) +export interface ClaudeCliSettingsConfig { + $schema?: string; + version: string; defaultTool: string; - settings: { - promptFormat: 'plain' | 'yaml' | 'json'; - smartContext: { - enabled: boolean; - maxFiles: number; + promptFormat: 'plain' | 'yaml' | 'json'; + smartContext: { + enabled: boolean; + maxFiles: number; + }; + nativeResume: boolean; + recursiveQuery: boolean; + cache: ClaudeCacheSettings; + codeIndexMcp: 'codexlens' | 'ace' | 'none'; +} + +// Legacy combined config (for backward compatibility) +export interface ClaudeCliCombinedConfig extends ClaudeCliToolsConfig { + defaultTool?: string; + settings?: { + promptFormat?: 'plain' | 'yaml' | 'json'; + smartContext?: { + enabled?: boolean; + maxFiles?: number; }; - nativeResume: boolean; - recursiveQuery: boolean; - cache: ClaudeCacheSettings; - codeIndexMcp: 'codexlens' | 'ace' | 'none'; // Code Index MCP provider + nativeResume?: boolean; + recursiveQuery?: boolean; + cache?: Partial; + codeIndexMcp?: 'codexlens' | 'ace' | 'none'; }; } // ========== Default Config ========== -const DEFAULT_CONFIG: ClaudeCliToolsConfig = { - version: '1.0.0', +const DEFAULT_TOOLS_CONFIG: ClaudeCliToolsConfig = { + version: '2.0.0', tools: { gemini: { enabled: true, isBuiltin: true, command: 'gemini', - description: 'Google AI for code analysis' + description: 'Google AI for code analysis', + tags: [] }, qwen: { enabled: true, isBuiltin: true, command: 'qwen', - description: 'Alibaba AI assistant' + description: 'Alibaba AI assistant', + tags: [] }, codex: { enabled: true, isBuiltin: true, command: 'codex', - description: 'OpenAI code generation' + description: 'OpenAI code generation', + tags: [] }, claude: { enabled: true, isBuiltin: true, command: 'claude', - description: 'Anthropic AI assistant' + description: 'Anthropic AI assistant', + tags: [] + }, + opencode: { + enabled: true, + isBuiltin: true, + command: 'opencode', + description: 'OpenCode AI assistant', + primaryModel: 'opencode/glm-4.7-free', + tags: [] } }, - customEndpoints: [], + customEndpoints: [] +}; + +const DEFAULT_SETTINGS_CONFIG: ClaudeCliSettingsConfig = { + version: '1.0.0', defaultTool: 'gemini', - settings: { - promptFormat: 'plain', - smartContext: { - enabled: false, - maxFiles: 10 - }, - nativeResume: true, - recursiveQuery: true, - cache: { - injectionMode: 'auto', - defaultPrefix: '', - defaultSuffix: '' - }, - codeIndexMcp: 'codexlens' // Default to CodexLens - } + promptFormat: 'plain', + smartContext: { + enabled: false, + maxFiles: 10 + }, + nativeResume: true, + recursiveQuery: true, + cache: { + injectionMode: 'auto', + defaultPrefix: '', + defaultSuffix: '' + }, + codeIndexMcp: 'ace' }; // ========== Helper Functions ========== @@ -101,10 +142,18 @@ function getProjectConfigPath(projectDir: string): string { return path.join(projectDir, '.claude', 'cli-tools.json'); } +function getProjectSettingsPath(projectDir: string): string { + return path.join(projectDir, '.claude', 'cli-settings.json'); +} + function getGlobalConfigPath(): string { return path.join(os.homedir(), '.claude', 'cli-tools.json'); } +function getGlobalSettingsPath(): string { + return path.join(os.homedir(), '.claude', 'cli-settings.json'); +} + /** * Resolve config path with fallback: * 1. Project: {projectDir}/.claude/cli-tools.json @@ -125,6 +174,25 @@ function resolveConfigPath(projectDir: string): { path: string; source: 'project return { path: projectPath, source: 'default' }; } +/** + * Resolve settings path with fallback: + * 1. Project: {projectDir}/.claude/cli-settings.json + * 2. Global: ~/.claude/cli-settings.json + */ +function resolveSettingsPath(projectDir: string): { path: string; source: 'project' | 'global' | 'default' } { + const projectPath = getProjectSettingsPath(projectDir); + if (fs.existsSync(projectPath)) { + return { path: projectPath, source: 'project' }; + } + + const globalPath = getGlobalSettingsPath(); + if (fs.existsSync(globalPath)) { + return { path: globalPath, source: 'global' }; + } + + return { path: projectPath, source: 'default' }; +} + function ensureClaudeDir(projectDir: string): void { const claudeDir = path.join(projectDir, '.claude'); if (!fs.existsSync(claudeDir)) { @@ -134,6 +202,20 @@ function ensureClaudeDir(projectDir: string): void { // ========== Main Functions ========== +/** + * Ensure tool has tags field (for backward compatibility) + */ +function ensureToolTags(tool: Partial): ClaudeCliTool { + return { + enabled: tool.enabled ?? true, + isBuiltin: tool.isBuiltin ?? false, + command: tool.command ?? '', + description: tool.description ?? '', + primaryModel: tool.primaryModel, + tags: tool.tags ?? [] + }; +} + /** * Load CLI tools configuration with fallback: * 1. Project: {projectDir}/.claude/cli-tools.json @@ -145,61 +227,115 @@ export function loadClaudeCliTools(projectDir: string): ClaudeCliToolsConfig & { try { if (resolved.source === 'default') { - // No config file found, return defaults - return { ...DEFAULT_CONFIG, _source: 'default' }; + return { ...DEFAULT_TOOLS_CONFIG, _source: 'default' }; } const content = fs.readFileSync(resolved.path, 'utf-8'); - const parsed = JSON.parse(content) as Partial; + const parsed = JSON.parse(content) as Partial; - // Merge with defaults - const config = { - ...DEFAULT_CONFIG, - ...parsed, - tools: { ...DEFAULT_CONFIG.tools, ...(parsed.tools || {}) }, - settings: { - ...DEFAULT_CONFIG.settings, - ...(parsed.settings || {}), - smartContext: { - ...DEFAULT_CONFIG.settings.smartContext, - ...(parsed.settings?.smartContext || {}) - }, - cache: { - ...DEFAULT_CONFIG.settings.cache, - ...(parsed.settings?.cache || {}) - } - }, + // Merge tools with defaults and ensure tags exist + const mergedTools: Record = {}; + for (const [key, tool] of Object.entries({ ...DEFAULT_TOOLS_CONFIG.tools, ...(parsed.tools || {}) })) { + mergedTools[key] = ensureToolTags(tool); + } + + // Ensure customEndpoints have tags + const mergedEndpoints = (parsed.customEndpoints || []).map(ep => ({ + ...ep, + tags: ep.tags ?? [] + })); + + const config: ClaudeCliToolsConfig & { _source?: string } = { + version: parsed.version || DEFAULT_TOOLS_CONFIG.version, + tools: mergedTools, + customEndpoints: mergedEndpoints, + $schema: parsed.$schema, _source: resolved.source }; - console.log(`[claude-cli-tools] Loaded config from ${resolved.source}: ${resolved.path}`); + console.log(`[claude-cli-tools] Loaded tools config from ${resolved.source}: ${resolved.path}`); return config; } catch (err) { - console.error('[claude-cli-tools] Error loading config:', err); - return { ...DEFAULT_CONFIG, _source: 'default' }; + console.error('[claude-cli-tools] Error loading tools config:', err); + return { ...DEFAULT_TOOLS_CONFIG, _source: 'default' }; } } /** * Save CLI tools configuration to project .claude/cli-tools.json - * Always saves to project directory (not global) */ export function saveClaudeCliTools(projectDir: string, config: ClaudeCliToolsConfig & { _source?: string }): void { ensureClaudeDir(projectDir); const configPath = getProjectConfigPath(projectDir); - // Remove internal _source field before saving const { _source, ...configToSave } = config; try { fs.writeFileSync(configPath, JSON.stringify(configToSave, null, 2), 'utf-8'); - console.log(`[claude-cli-tools] Saved config to project: ${configPath}`); + console.log(`[claude-cli-tools] Saved tools config to: ${configPath}`); } catch (err) { - console.error('[claude-cli-tools] Error saving config:', err); + console.error('[claude-cli-tools] Error saving tools config:', err); throw new Error(`Failed to save CLI tools config: ${err}`); } } +/** + * Load CLI settings configuration with fallback: + * 1. Project: {projectDir}/.claude/cli-settings.json + * 2. Global: ~/.claude/cli-settings.json + * 3. Default settings + */ +export function loadClaudeCliSettings(projectDir: string): ClaudeCliSettingsConfig & { _source?: string } { + const resolved = resolveSettingsPath(projectDir); + + try { + if (resolved.source === 'default') { + return { ...DEFAULT_SETTINGS_CONFIG, _source: 'default' }; + } + + const content = fs.readFileSync(resolved.path, 'utf-8'); + const parsed = JSON.parse(content) as Partial; + + const config: ClaudeCliSettingsConfig & { _source?: string } = { + ...DEFAULT_SETTINGS_CONFIG, + ...parsed, + smartContext: { + ...DEFAULT_SETTINGS_CONFIG.smartContext, + ...(parsed.smartContext || {}) + }, + cache: { + ...DEFAULT_SETTINGS_CONFIG.cache, + ...(parsed.cache || {}) + }, + _source: resolved.source + }; + + console.log(`[claude-cli-tools] Loaded settings from ${resolved.source}: ${resolved.path}`); + return config; + } catch (err) { + console.error('[claude-cli-tools] Error loading settings:', err); + return { ...DEFAULT_SETTINGS_CONFIG, _source: 'default' }; + } +} + +/** + * Save CLI settings configuration to project .claude/cli-settings.json + */ +export function saveClaudeCliSettings(projectDir: string, config: ClaudeCliSettingsConfig & { _source?: string }): void { + ensureClaudeDir(projectDir); + const settingsPath = getProjectSettingsPath(projectDir); + + const { _source, ...configToSave } = config; + + try { + fs.writeFileSync(settingsPath, JSON.stringify(configToSave, null, 2), 'utf-8'); + console.log(`[claude-cli-tools] Saved settings to: ${settingsPath}`); + } catch (err) { + console.error('[claude-cli-tools] Error saving settings:', err); + throw new Error(`Failed to save CLI settings: ${err}`); + } +} + /** * Update enabled status for a specific tool */ @@ -224,16 +360,16 @@ export function updateClaudeToolEnabled( export function updateClaudeCacheSettings( projectDir: string, cacheSettings: Partial -): ClaudeCliToolsConfig { - const config = loadClaudeCliTools(projectDir); +): ClaudeCliSettingsConfig { + const settings = loadClaudeCliSettings(projectDir); - config.settings.cache = { - ...config.settings.cache, + settings.cache = { + ...settings.cache, ...cacheSettings }; - saveClaudeCliTools(projectDir, config); - return config; + saveClaudeCliSettings(projectDir, settings); + return settings; } /** @@ -242,11 +378,11 @@ export function updateClaudeCacheSettings( export function updateClaudeDefaultTool( projectDir: string, defaultTool: string -): ClaudeCliToolsConfig { - const config = loadClaudeCliTools(projectDir); - config.defaultTool = defaultTool; - saveClaudeCliTools(projectDir, config); - return config; +): ClaudeCliSettingsConfig { + const settings = loadClaudeCliSettings(projectDir); + settings.defaultTool = defaultTool; + saveClaudeCliSettings(projectDir, settings); + return settings; } /** @@ -254,16 +390,23 @@ export function updateClaudeDefaultTool( */ export function addClaudeCustomEndpoint( projectDir: string, - endpoint: { id: string; name: string; enabled: boolean } + endpoint: { id: string; name: string; enabled: boolean; tags?: string[] } ): ClaudeCliToolsConfig { const config = loadClaudeCliTools(projectDir); + const newEndpoint: ClaudeCustomEndpoint = { + id: endpoint.id, + name: endpoint.name, + enabled: endpoint.enabled, + tags: endpoint.tags || [] + }; + // Check if endpoint already exists const existingIndex = config.customEndpoints.findIndex(e => e.id === endpoint.id); if (existingIndex >= 0) { - config.customEndpoints[existingIndex] = endpoint; + config.customEndpoints[existingIndex] = newEndpoint; } else { - config.customEndpoints.push(endpoint); + config.customEndpoints.push(newEndpoint); } saveClaudeCliTools(projectDir, config); @@ -309,12 +452,12 @@ export function getClaudeCliToolsInfo(projectDir: string): { export function updateCodeIndexMcp( projectDir: string, provider: 'codexlens' | 'ace' | 'none' -): { success: boolean; error?: string; config?: ClaudeCliToolsConfig } { +): { success: boolean; error?: string; settings?: ClaudeCliSettingsConfig } { try { - // Update config - const config = loadClaudeCliTools(projectDir); - config.settings.codeIndexMcp = provider; - saveClaudeCliTools(projectDir, config); + // Update settings config + const settings = loadClaudeCliSettings(projectDir); + settings.codeIndexMcp = provider; + saveClaudeCliSettings(projectDir, settings); // Only update global CLAUDE.md (consistent with Chinese response / Windows platform) const globalClaudeMdPath = path.join(os.homedir(), '.claude', 'CLAUDE.md'); @@ -358,7 +501,7 @@ export function updateCodeIndexMcp( console.log(`[claude-cli-tools] Updated global CLAUDE.md to use ${provider}`); } - return { success: true, config }; + return { success: true, settings }; } catch (err) { console.error('[claude-cli-tools] Error updating Code Index MCP:', err); return { success: false, error: (err as Error).message }; @@ -369,8 +512,8 @@ export function updateCodeIndexMcp( * Get current Code Index MCP provider */ export function getCodeIndexMcp(projectDir: string): 'codexlens' | 'ace' | 'none' { - const config = loadClaudeCliTools(projectDir); - return config.settings.codeIndexMcp || 'codexlens'; + const settings = loadClaudeCliSettings(projectDir); + return settings.codeIndexMcp || 'ace'; } /** diff --git a/ccw/src/tools/cli-config-manager.ts b/ccw/src/tools/cli-config-manager.ts index 876a72ea..b94e3e74 100644 --- a/ccw/src/tools/cli-config-manager.ts +++ b/ccw/src/tools/cli-config-manager.ts @@ -6,6 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { StoragePaths, ensureStorageDir } from '../config/storage-paths.js'; +import { loadClaudeCliTools, saveClaudeCliTools } from './claude-cli-tools.js'; // ========== Types ========== @@ -234,6 +235,20 @@ export function updateToolConfig( config.tools[tool] = updatedToolConfig; saveCliConfig(baseDir, config); + // Also sync tags to cli-tools.json + if (updates.tags !== undefined) { + try { + const claudeCliTools = loadClaudeCliTools(baseDir); + if (claudeCliTools.tools[tool]) { + claudeCliTools.tools[tool].tags = updatedToolConfig.tags || []; + saveClaudeCliTools(baseDir, claudeCliTools); + } + } catch (err) { + // Log warning instead of ignoring errors syncing to cli-tools.json + console.warn(`[cli-config] Failed to sync tags to cli-tools.json for tool '${tool}'.`, err); + } + } + return updatedToolConfig; } @@ -298,14 +313,30 @@ export function getPredefinedModels(tool: string): string[] { } /** - * Get full config response for API (includes predefined models) + * Get full config response for API (includes predefined models and tags from cli-tools.json) */ export function getFullConfigResponse(baseDir: string): { config: CliConfig; predefinedModels: Record; } { + const config = loadCliConfig(baseDir); + + // Merge tags from cli-tools.json + try { + const claudeCliTools = loadClaudeCliTools(baseDir); + for (const [toolName, toolConfig] of Object.entries(config.tools)) { + const claudeTool = claudeCliTools.tools[toolName]; + if (claudeTool && claudeTool.tags) { + toolConfig.tags = claudeTool.tags; + } + } + } catch (err) { + // Log warning instead of ignoring errors loading cli-tools.json + console.warn('[cli-config] Could not merge tags from cli-tools.json.', err); + } + return { - config: loadCliConfig(baseDir), + config, predefinedModels: { ...PREDEFINED_MODELS } }; } diff --git a/ccw/src/types/litellm-api-config.ts b/ccw/src/types/litellm-api-config.ts index 4fcf1ebb..5d77baa7 100644 --- a/ccw/src/types/litellm-api-config.ts +++ b/ccw/src/types/litellm-api-config.ts @@ -353,6 +353,7 @@ export interface CodexLensEmbeddingRotation { /** * Generic embedding pool configuration (refactored from CodexLensEmbeddingRotation) * Supports automatic discovery of all providers offering a specific model + * @deprecated Use ModelPoolConfig instead */ export interface EmbeddingPoolConfig { /** Whether embedding pool is enabled */ @@ -377,6 +378,50 @@ export interface EmbeddingPoolConfig { defaultMaxConcurrentPerKey: number; } +/** + * Model type for pool configuration + */ +export type ModelPoolType = 'embedding' | 'llm' | 'reranker'; + +/** + * Individual model pool configuration + * Supports embedding, LLM, and reranker models with high availability + */ +export interface ModelPoolConfig { + /** Unique identifier for this pool */ + id: string; + + /** Model type: embedding, llm, or reranker */ + modelType: ModelPoolType; + + /** Whether this pool is enabled */ + enabled: boolean; + + /** Target model name (e.g., "text-embedding-3-small", "gpt-4o") */ + targetModel: string; + + /** Selection strategy: round_robin, latency_aware, weighted_random */ + strategy: 'round_robin' | 'latency_aware' | 'weighted_random'; + + /** Whether to automatically discover all providers offering targetModel */ + autoDiscover: boolean; + + /** Provider IDs to exclude from auto-discovery (optional) */ + excludedProviderIds?: string[]; + + /** Default cooldown seconds for rate-limited endpoints (default: 60) */ + defaultCooldown: number; + + /** Default maximum concurrent requests per key (default: 4) */ + defaultMaxConcurrentPerKey: number; + + /** Optional display name for this pool */ + name?: string; + + /** Optional description */ + description?: string; +} + /** * Complete LiteLLM API configuration * Root configuration object stored in JSON file @@ -400,6 +445,9 @@ export interface LiteLLMApiConfig { /** CodexLens multi-provider embedding rotation config (deprecated, use embeddingPoolConfig) */ codexlensEmbeddingRotation?: CodexLensEmbeddingRotation; - /** Generic embedding pool configuration with auto-discovery support */ + /** Generic embedding pool configuration with auto-discovery support (deprecated, use modelPools) */ embeddingPoolConfig?: EmbeddingPoolConfig; + + /** Multi-model pool configurations (supports embedding, LLM, reranker) */ + modelPools?: ModelPoolConfig[]; }