feat: Unified Embedding Pool with auto-discovery

Architecture refactoring for multi-provider rotation:

Backend:
- Add EmbeddingPoolConfig type with autoDiscover support
- Implement discoverProvidersForModel() for auto-aggregation
- Add GET/PUT /api/litellm-api/embedding-pool endpoints
- Add GET /api/litellm-api/embedding-pool/discover/:model preview
- Convert ccw-litellm status check to async with 5-min cache
- Maintain backward compatibility with legacy rotation config

Frontend:
- Add "Embedding Pool" tab in API Settings
- Auto-discover providers when target model selected
- Show provider/key count with include/exclude controls
- Increase sidebar width (280px → 320px)
- Add sync result feedback on save

Other:
- Remove worker count limits (was max=32)
- Add i18n translations (EN/CN)
- Update .gitignore for .mcp.json

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
catlog22
2025-12-25 16:06:49 +08:00
parent 4e6ee2db25
commit a1413dd1b3
10 changed files with 882 additions and 43 deletions

View File

@@ -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<string, unknown> = {};
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 };