feat: Implement CodexLens multi-provider embedding rotation management

- Added functions to get and update CodexLens embedding rotation configuration.
- Introduced functionality to retrieve enabled embedding providers for rotation.
- Created endpoints for managing rotation configuration via API.
- Enhanced dashboard UI to support multi-provider rotation configuration.
- Updated internationalization strings for new rotation features.
- Adjusted CLI commands and embedding manager to support increased concurrency limits.
- Modified hybrid search weights for improved ranking behavior.
This commit is contained in:
catlog22
2025-12-25 14:13:27 +08:00
parent dfa8b541b4
commit 8e744597d1
12 changed files with 713 additions and 20 deletions

View File

@@ -13,6 +13,8 @@ import type {
GlobalCacheSettings,
ProviderType,
CacheStrategy,
CodexLensEmbeddingRotation,
CodexLensEmbeddingProvider,
} from '../types/litellm-api-config.js';
/**
@@ -356,6 +358,199 @@ export function updateGlobalCacheSettings(
saveConfig(baseDir, config);
}
// ===========================
// CodexLens Embedding Rotation Management
// ===========================
/**
* Get CodexLens embedding rotation config
*/
export function getCodexLensEmbeddingRotation(baseDir: string): CodexLensEmbeddingRotation | undefined {
const config = loadLiteLLMApiConfig(baseDir);
return config.codexlensEmbeddingRotation;
}
/**
* Update CodexLens embedding rotation config
*/
export function updateCodexLensEmbeddingRotation(
baseDir: string,
rotationConfig: CodexLensEmbeddingRotation | undefined
): void {
const config = loadLiteLLMApiConfig(baseDir);
if (rotationConfig) {
config.codexlensEmbeddingRotation = rotationConfig;
} else {
delete config.codexlensEmbeddingRotation;
}
saveConfig(baseDir, config);
}
/**
* Get all enabled embedding providers with their API keys for rotation
* This aggregates all providers that have embedding models configured
*/
export function getEmbeddingProvidersForRotation(baseDir: string): Array<{
providerId: string;
providerName: string;
apiBase: string;
embeddingModels: Array<{
modelId: string;
modelName: string;
dimensions: number;
}>;
apiKeys: Array<{
keyId: string;
keyLabel: string;
enabled: boolean;
}>;
}> {
const config = loadLiteLLMApiConfig(baseDir);
const result: Array<{
providerId: string;
providerName: string;
apiBase: string;
embeddingModels: Array<{
modelId: string;
modelName: string;
dimensions: number;
}>;
apiKeys: Array<{
keyId: string;
keyLabel: string;
enabled: boolean;
}>;
}> = [];
for (const provider of config.providers) {
if (!provider.enabled) continue;
// Check if provider has embedding models
const embeddingModels = (provider.embeddingModels || [])
.filter(m => m.enabled)
.map(m => ({
modelId: m.id,
modelName: m.name,
dimensions: m.capabilities?.embeddingDimension || 1536,
}));
if (embeddingModels.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,
});
}
result.push({
providerId: provider.id,
providerName: provider.name,
apiBase: provider.apiBase || getDefaultApiBaseForType(provider.type),
embeddingModels,
apiKeys,
});
}
return result;
}
/**
* Generate rotation endpoints for ccw_litellm
* Creates endpoint list from rotation config for parallel embedding
*/
export function generateRotationEndpoints(baseDir: string): Array<{
name: string;
api_key: string;
api_base: string;
model: string;
weight: number;
max_concurrent: number;
}> {
const config = loadLiteLLMApiConfig(baseDir);
const rotationConfig = config.codexlensEmbeddingRotation;
if (!rotationConfig || !rotationConfig.enabled) {
return [];
}
const endpoints: Array<{
name: string;
api_key: string;
api_base: string;
model: string;
weight: number;
max_concurrent: number;
}> = [];
for (const rotationProvider of rotationConfig.providers) {
if (!rotationProvider.enabled) continue;
// Find the provider config
const provider = config.providers.find(p => p.id === rotationProvider.providerId);
if (!provider || !provider.enabled) continue;
// Find the embedding model
const embeddingModel = provider.embeddingModels?.find(m => m.id === rotationProvider.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) {
if (rotationProvider.useAllKeys) {
// 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 (rotationProvider.selectedKeyIds && rotationProvider.selectedKeyIds.length > 0) {
// Use only selected keys
keysToUse = provider.apiKeys
.filter(k => k.enabled && rotationProvider.selectedKeyIds!.includes(k.id))
.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: rotationProvider.weight,
max_concurrent: rotationProvider.maxConcurrentPerKey,
});
}
}
return endpoints;
}
// ===========================
// YAML Config Generation for ccw_litellm
// ===========================
@@ -518,4 +713,4 @@ function objectToYaml(obj: unknown, indent: number = 0): string {
}
// Re-export types
export type { ProviderCredential, CustomEndpoint, ProviderType, CacheStrategy };
export type { ProviderCredential, CustomEndpoint, ProviderType, CacheStrategy, CodexLensEmbeddingRotation, CodexLensEmbeddingProvider };

View File

@@ -22,9 +22,14 @@ import {
loadLiteLLMApiConfig,
saveLiteLLMYamlConfig,
generateLiteLLMYamlConfig,
getCodexLensEmbeddingRotation,
updateCodexLensEmbeddingRotation,
getEmbeddingProvidersForRotation,
generateRotationEndpoints,
type ProviderCredential,
type CustomEndpoint,
type ProviderType,
type CodexLensEmbeddingRotation,
} from '../../config/litellm-api-config-manager.js';
import { getContextCacheStore } from '../../tools/context-cache-store.js';
import { getLiteLLMClient } from '../../tools/litellm-client.js';
@@ -568,6 +573,66 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise<boolean
return true;
}
// ===========================
// CodexLens Embedding Rotation Routes
// ===========================
// GET /api/litellm-api/codexlens/rotation - Get rotation config
if (pathname === '/api/litellm-api/codexlens/rotation' && req.method === 'GET') {
try {
const rotationConfig = getCodexLensEmbeddingRotation(initialPath);
const availableProviders = getEmbeddingProvidersForRotation(initialPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
rotationConfig: rotationConfig || null,
availableProviders,
}));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (err as Error).message }));
}
return true;
}
// PUT /api/litellm-api/codexlens/rotation - Update rotation config
if (pathname === '/api/litellm-api/codexlens/rotation' && req.method === 'PUT') {
handlePostRequest(req, res, async (body: unknown) => {
const rotationConfig = body as CodexLensEmbeddingRotation | null;
try {
updateCodexLensEmbeddingRotation(initialPath, rotationConfig || undefined);
broadcastToClients({
type: 'CODEXLENS_ROTATION_UPDATED',
payload: { rotationConfig, timestamp: new Date().toISOString() }
});
return { success: true, rotationConfig };
} catch (err) {
return { error: (err as Error).message, status: 500 };
}
});
return true;
}
// GET /api/litellm-api/codexlens/rotation/endpoints - Get generated rotation endpoints
if (pathname === '/api/litellm-api/codexlens/rotation/endpoints' && req.method === 'GET') {
try {
const endpoints = generateRotationEndpoints(initialPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
endpoints,
count: endpoints.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 () => {

View File

@@ -269,6 +269,28 @@ const i18n = {
'codexlens.concurrency': 'API Concurrency',
'codexlens.concurrencyHint': 'Number of parallel API calls (1-32). 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',
'codexlens.rotationEnabled': 'Enable Rotation',
'codexlens.rotationStrategy': 'Rotation Strategy',
'codexlens.strategyRoundRobin': 'Round Robin',
'codexlens.strategyLatencyAware': 'Latency Aware',
'codexlens.strategyWeightedRandom': 'Weighted Random',
'codexlens.targetModel': 'Target Model',
'codexlens.targetModelHint': 'Model name that all providers should support (e.g., qwen3-embedding)',
'codexlens.cooldownSeconds': 'Cooldown (seconds)',
'codexlens.cooldownHint': 'Default cooldown after rate limit (60s recommended)',
'codexlens.rotationProviders': 'Rotation Providers',
'codexlens.addProvider': 'Add Provider',
'codexlens.noRotationProviders': 'No providers configured for rotation',
'codexlens.providerWeight': 'Weight',
'codexlens.maxConcurrentPerKey': 'Max Concurrent/Key',
'codexlens.useAllKeys': 'Use All Keys',
'codexlens.selectKeys': 'Select Keys',
'codexlens.configureRotation': 'Configure Rotation',
'codexlens.rotationSaved': 'Rotation config saved successfully',
'codexlens.rotationDeleted': 'Rotation config deleted',
'codexlens.totalEndpoints': 'Total Endpoints',
'codexlens.fullIndex': 'Full',
'codexlens.vectorIndex': 'Vector',
'codexlens.ftsIndex': 'FTS',
@@ -1931,6 +1953,28 @@ const i18n = {
'codexlens.concurrency': 'API 并发数',
'codexlens.concurrencyHint': '并行 API 调用数量1-32。较高的值可加速索引但可能触发速率限制。',
'codexlens.concurrencyCustom': '自定义',
'codexlens.rotation': '多供应商轮训',
'codexlens.rotationDesc': '聚合多个 API 供应商和密钥进行并行嵌入生成',
'codexlens.rotationEnabled': '启用轮训',
'codexlens.rotationStrategy': '轮训策略',
'codexlens.strategyRoundRobin': '轮询',
'codexlens.strategyLatencyAware': '延迟感知',
'codexlens.strategyWeightedRandom': '加权随机',
'codexlens.targetModel': '目标模型',
'codexlens.targetModelHint': '所有供应商应支持的模型名称(例如 qwen3-embedding',
'codexlens.cooldownSeconds': '冷却时间(秒)',
'codexlens.cooldownHint': '速率限制后的默认冷却时间(推荐 60 秒)',
'codexlens.rotationProviders': '轮训供应商',
'codexlens.addProvider': '添加供应商',
'codexlens.noRotationProviders': '未配置轮训供应商',
'codexlens.providerWeight': '权重',
'codexlens.maxConcurrentPerKey': '每密钥最大并发',
'codexlens.useAllKeys': '使用所有密钥',
'codexlens.selectKeys': '选择密钥',
'codexlens.configureRotation': '配置轮训',
'codexlens.rotationSaved': '轮训配置保存成功',
'codexlens.rotationDeleted': '轮训配置已删除',
'codexlens.totalEndpoints': '总端点数',
'codexlens.fullIndex': '全部',
'codexlens.vectorIndex': '向量',
'codexlens.ftsIndex': 'FTS',

View File

@@ -2009,6 +2009,28 @@ function buildCodexLensManagerPage(config) {
'</div>' +
'<p class="text-xs text-muted-foreground mt-1">' + t('codexlens.concurrencyHint') + '</p>' +
'</div>' +
// Multi-Provider Rotation (only for LiteLLM backend)
'<div id="rotationSection" class="hidden">' +
'<div class="border border-border rounded-lg p-3 bg-muted/30">' +
'<div class="flex items-center justify-between mb-2">' +
'<div class="flex items-center gap-2">' +
'<i data-lucide="rotate-cw" class="w-4 h-4 text-primary"></i>' +
'<span class="text-sm font-medium">' + t('codexlens.rotation') + '</span>' +
'</div>' +
'<div id="rotationStatusBadge" class="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">' +
t('common.disabled') +
'</div>' +
'</div>' +
'<p class="text-xs text-muted-foreground mb-3">' + t('codexlens.rotationDesc') + '</p>' +
'<div class="flex items-center gap-2">' +
'<button class="btn-sm btn-outline flex items-center gap-1.5" onclick="showRotationConfigModal()">' +
'<i data-lucide="settings" class="w-3.5 h-3.5"></i>' +
t('codexlens.configureRotation') +
'</button>' +
'<span id="rotationEndpointCount" class="text-xs text-muted-foreground"></span>' +
'</div>' +
'</div>' +
'</div>' +
// Index buttons - two modes: full (FTS + Vector) or FTS only
'<div class="grid grid-cols-2 gap-3">' +
'<button class="btn btn-primary flex items-center justify-center gap-2 py-3" onclick="initCodexLensIndexFromPage(\'full\')" title="' + t('codexlens.fullIndexDesc') + '">' +
@@ -2224,6 +2246,7 @@ function onEmbeddingBackendChange() {
var backendSelect = document.getElementById('pageBackendSelect');
var modelSelect = document.getElementById('pageModelSelect');
var concurrencySelector = document.getElementById('concurrencySelector');
var rotationSection = document.getElementById('rotationSection');
if (!backendSelect || !modelSelect) {
console.warn('[CodexLens] Backend or model select not found');
return;
@@ -2243,6 +2266,11 @@ function onEmbeddingBackendChange() {
if (concurrencySelector) {
concurrencySelector.classList.remove('hidden');
}
// Show rotation section and load status
if (rotationSection) {
rotationSection.classList.remove('hidden');
loadRotationStatus();
}
} else {
// Load local fastembed models
modelSelect.innerHTML = buildModelSelectOptionsForPage();
@@ -2250,6 +2278,10 @@ function onEmbeddingBackendChange() {
if (concurrencySelector) {
concurrencySelector.classList.add('hidden');
}
// Hide rotation section for local backend
if (rotationSection) {
rotationSection.classList.add('hidden');
}
}
}
@@ -2553,3 +2585,308 @@ async function cleanAllIndexesFromPage() {
showRefreshToast((t('common.error') || 'Error') + ': ' + err.message, 'error');
}
}
// ============================================================
// MULTI-PROVIDER ROTATION CONFIGURATION
// ============================================================
/**
* Load and display rotation status in the page
*/
async function loadRotationStatus() {
try {
var response = await fetch('/api/litellm-api/codexlens/rotation');
if (!response.ok) {
console.warn('[CodexLens] Failed to load rotation config:', response.status);
return;
}
var data = await response.json();
window.rotationConfig = data.rotationConfig;
window.availableRotationProviders = data.availableProviders;
updateRotationStatusDisplay(data.rotationConfig);
} catch (err) {
console.error('[CodexLens] Error loading rotation status:', err);
}
}
/**
* Update the rotation status display in the page
*/
function updateRotationStatusDisplay(rotationConfig) {
var badge = document.getElementById('rotationStatusBadge');
var countEl = document.getElementById('rotationEndpointCount');
if (!badge) return;
if (rotationConfig && rotationConfig.enabled) {
badge.textContent = t('common.enabled');
badge.className = 'text-xs px-2 py-0.5 rounded-full bg-success/10 text-success';
// Show endpoint count
if (countEl && rotationConfig.providers) {
var totalEndpoints = 0;
rotationConfig.providers.forEach(function(p) {
if (p.enabled) totalEndpoints += (p.useAllKeys ? 4 : 1); // Estimate
});
countEl.textContent = '~' + totalEndpoints + ' ' + t('codexlens.totalEndpoints').toLowerCase();
}
} else {
badge.textContent = t('common.disabled');
badge.className = 'text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground';
if (countEl) countEl.textContent = '';
}
}
/**
* Show the rotation configuration modal
*/
async function showRotationConfigModal() {
try {
// Load current config if not already loaded
if (!window.rotationConfig) {
await loadRotationStatus();
}
var rotationConfig = window.rotationConfig || {
enabled: false,
strategy: 'round_robin',
defaultCooldown: 60,
targetModel: 'qwen3-embedding',
providers: []
};
var availableProviders = window.availableRotationProviders || [];
var modalHtml = buildRotationConfigModal(rotationConfig, availableProviders);
var tempContainer = document.createElement('div');
tempContainer.innerHTML = modalHtml;
var modal = tempContainer.firstElementChild;
document.body.appendChild(modal);
if (window.lucide) lucide.createIcons();
initRotationConfigEvents(rotationConfig, availableProviders);
} catch (err) {
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
}
}
/**
* Build the rotation configuration modal HTML
*/
function buildRotationConfigModal(rotationConfig, availableProviders) {
var isEnabled = rotationConfig.enabled || false;
var strategy = rotationConfig.strategy || 'round_robin';
var cooldown = rotationConfig.defaultCooldown || 60;
var targetModel = rotationConfig.targetModel || 'qwen3-embedding';
var configuredProviders = rotationConfig.providers || [];
// Build provider list HTML
var providerListHtml = '';
if (availableProviders.length === 0) {
providerListHtml = '<div class="text-sm text-muted-foreground py-4 text-center">' + t('codexlens.noRotationProviders') + '</div>';
} else {
availableProviders.forEach(function(provider, index) {
// Find if this provider is already configured
var configured = configuredProviders.find(function(p) { return p.providerId === provider.providerId; });
var isProviderEnabled = configured ? configured.enabled : false;
var weight = configured ? configured.weight : 1;
var maxConcurrent = configured ? configured.maxConcurrentPerKey : 4;
var useAllKeys = configured ? configured.useAllKeys : true;
// Get model options
var modelOptions = provider.embeddingModels.map(function(m) {
var selected = configured && configured.modelId === m.modelId ? 'selected' : '';
return '<option value="' + m.modelId + '" ' + selected + '>' + m.modelName + ' (' + m.dimensions + 'd)</option>';
}).join('');
// Get key count
var keyCount = provider.apiKeys.filter(function(k) { return k.enabled; }).length;
providerListHtml +=
'<div class="border border-border rounded-lg p-3 ' + (isProviderEnabled ? 'bg-success/5 border-success/30' : 'bg-muted/30') + '" data-provider-id="' + provider.providerId + '">' +
'<div class="flex items-center justify-between mb-2">' +
'<div class="flex items-center gap-2">' +
'<input type="checkbox" id="rotationProvider_' + index + '" ' + (isProviderEnabled ? 'checked' : '') +
' class="rotation-provider-toggle" data-provider-id="' + provider.providerId + '" />' +
'<label for="rotationProvider_' + index + '" class="font-medium text-sm">' + provider.providerName + '</label>' +
'<span class="text-xs px-1.5 py-0.5 bg-muted rounded text-muted-foreground">' + keyCount + ' keys</span>' +
'</div>' +
'</div>' +
'<div class="grid grid-cols-2 gap-2 text-xs">' +
'<div>' +
'<label class="text-muted-foreground">Model</label>' +
'<select class="w-full px-2 py-1 border border-border rounded bg-background text-sm rotation-model-select" data-provider-id="' + provider.providerId + '">' +
modelOptions +
'</select>' +
'</div>' +
'<div>' +
'<label class="text-muted-foreground">' + t('codexlens.providerWeight') + '</label>' +
'<input type="number" min="0.1" max="10" step="0.1" value="' + weight + '" ' +
'class="w-full px-2 py-1 border border-border rounded bg-background text-sm rotation-weight-input" data-provider-id="' + provider.providerId + '" />' +
'</div>' +
'<div>' +
'<label class="text-muted-foreground">' + t('codexlens.maxConcurrentPerKey') + '</label>' +
'<input type="number" min="1" max="16" value="' + maxConcurrent + '" ' +
'class="w-full px-2 py-1 border border-border rounded bg-background text-sm rotation-concurrent-input" data-provider-id="' + provider.providerId + '" />' +
'</div>' +
'<div class="flex items-center gap-1">' +
'<input type="checkbox" id="useAllKeys_' + index + '" ' + (useAllKeys ? 'checked' : '') +
' class="rotation-use-all-keys" data-provider-id="' + provider.providerId + '" />' +
'<label for="useAllKeys_' + index + '" class="text-muted-foreground">' + t('codexlens.useAllKeys') + '</label>' +
'</div>' +
'</div>' +
'</div>';
});
}
return '<div class="modal-backdrop" id="rotationConfigModal">' +
'<div class="modal-container max-w-2xl">' +
'<div class="modal-header">' +
'<div class="flex items-center gap-3">' +
'<div class="modal-icon">' +
'<i data-lucide="rotate-cw" class="w-5 h-5"></i>' +
'</div>' +
'<div>' +
'<h2 class="text-lg font-bold">' + t('codexlens.rotation') + '</h2>' +
'<p class="text-xs text-muted-foreground">' + t('codexlens.rotationDesc') + '</p>' +
'</div>' +
'</div>' +
'<button onclick="closeRotationModal()" class="text-muted-foreground hover:text-foreground">' +
'<i data-lucide="x" class="w-5 h-5"></i>' +
'</button>' +
'</div>' +
'<div class="modal-body space-y-4">' +
// Enable toggle
'<div class="flex items-center justify-between p-3 bg-muted/30 rounded-lg">' +
'<div class="flex items-center gap-2">' +
'<i data-lucide="power" class="w-4 h-4 text-primary"></i>' +
'<span class="font-medium">' + t('codexlens.rotationEnabled') + '</span>' +
'</div>' +
'<label class="relative inline-flex items-center cursor-pointer">' +
'<input type="checkbox" id="rotationEnabledToggle" ' + (isEnabled ? 'checked' : '') + ' class="sr-only peer" />' +
'<div class="w-11 h-6 bg-muted peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[\'\'] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>' +
'</label>' +
'</div>' +
// Strategy and settings
'<div class="grid grid-cols-2 gap-4">' +
'<div>' +
'<label class="block text-sm font-medium mb-1.5">' + t('codexlens.rotationStrategy') + '</label>' +
'<select id="rotationStrategy" class="w-full px-3 py-2 border border-border rounded-lg bg-background text-sm">' +
'<option value="round_robin" ' + (strategy === 'round_robin' ? 'selected' : '') + '>' + t('codexlens.strategyRoundRobin') + '</option>' +
'<option value="latency_aware" ' + (strategy === 'latency_aware' ? 'selected' : '') + '>' + t('codexlens.strategyLatencyAware') + '</option>' +
'<option value="weighted_random" ' + (strategy === 'weighted_random' ? 'selected' : '') + '>' + t('codexlens.strategyWeightedRandom') + '</option>' +
'</select>' +
'</div>' +
'<div>' +
'<label class="block text-sm font-medium mb-1.5">' + t('codexlens.cooldownSeconds') + '</label>' +
'<input type="number" id="rotationCooldown" min="1" max="300" value="' + cooldown + '" ' +
'class="w-full px-3 py-2 border border-border rounded-lg bg-background text-sm" />' +
'<p class="text-xs text-muted-foreground mt-1">' + t('codexlens.cooldownHint') + '</p>' +
'</div>' +
'</div>' +
// Target model
'<div>' +
'<label class="block text-sm font-medium mb-1.5">' + t('codexlens.targetModel') + '</label>' +
'<input type="text" id="rotationTargetModel" value="' + targetModel + '" ' +
'class="w-full px-3 py-2 border border-border rounded-lg bg-background text-sm" placeholder="qwen3-embedding" />' +
'<p class="text-xs text-muted-foreground mt-1">' + t('codexlens.targetModelHint') + '</p>' +
'</div>' +
// Provider list
'<div>' +
'<label class="block text-sm font-medium mb-1.5">' + t('codexlens.rotationProviders') + '</label>' +
'<div class="space-y-2 max-h-64 overflow-y-auto" id="rotationProviderList">' +
providerListHtml +
'</div>' +
'</div>' +
'</div>' +
'<div class="modal-footer">' +
'<button onclick="closeRotationModal()" class="btn btn-outline">' + t('common.cancel') + '</button>' +
'<button onclick="saveRotationConfig()" class="btn btn-primary">' +
'<i data-lucide="save" class="w-4 h-4"></i> ' + t('common.save') +
'</button>' +
'</div>' +
'</div>' +
'</div>';
}
/**
* Initialize rotation config modal events
*/
function initRotationConfigEvents(rotationConfig, availableProviders) {
// Store in window for save function
window._rotationAvailableProviders = availableProviders;
}
/**
* Close the rotation config modal
*/
function closeRotationModal() {
var modal = document.getElementById('rotationConfigModal');
if (modal) modal.remove();
}
/**
* Save the rotation configuration
*/
async function saveRotationConfig() {
try {
var enabledToggle = document.getElementById('rotationEnabledToggle');
var strategySelect = document.getElementById('rotationStrategy');
var cooldownInput = document.getElementById('rotationCooldown');
var targetModelInput = document.getElementById('rotationTargetModel');
var enabled = enabledToggle ? enabledToggle.checked : false;
var strategy = strategySelect ? strategySelect.value : 'round_robin';
var cooldown = cooldownInput ? parseInt(cooldownInput.value, 10) : 60;
var targetModel = targetModelInput ? targetModelInput.value.trim() : 'qwen3-embedding';
// Collect provider configurations
var providers = [];
var providerToggles = document.querySelectorAll('.rotation-provider-toggle');
providerToggles.forEach(function(toggle) {
var providerId = toggle.getAttribute('data-provider-id');
var isEnabled = toggle.checked;
var modelSelect = document.querySelector('.rotation-model-select[data-provider-id="' + providerId + '"]');
var weightInput = document.querySelector('.rotation-weight-input[data-provider-id="' + providerId + '"]');
var concurrentInput = document.querySelector('.rotation-concurrent-input[data-provider-id="' + providerId + '"]');
var useAllKeysToggle = document.querySelector('.rotation-use-all-keys[data-provider-id="' + providerId + '"]');
providers.push({
providerId: providerId,
modelId: modelSelect ? modelSelect.value : '',
weight: weightInput ? parseFloat(weightInput.value) || 1 : 1,
maxConcurrentPerKey: concurrentInput ? parseInt(concurrentInput.value, 10) || 4 : 4,
useAllKeys: useAllKeysToggle ? useAllKeysToggle.checked : true,
enabled: isEnabled
});
});
var rotationConfig = {
enabled: enabled,
strategy: strategy,
defaultCooldown: cooldown,
targetModel: targetModel,
providers: providers
};
var response = await fetch('/api/litellm-api/codexlens/rotation', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rotationConfig)
});
var result = await response.json();
if (result.success) {
showRefreshToast(t('codexlens.rotationSaved'), 'success');
window.rotationConfig = rotationConfig;
updateRotationStatusDisplay(rotationConfig);
closeRotationModal();
} else {
showRefreshToast(t('common.saveFailed') + ': ' + result.error, 'error');
}
} catch (err) {
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
}
}

View File

@@ -299,6 +299,54 @@ export interface GlobalCacheSettings {
maxTotalSizeMB: number;
}
/**
* CodexLens embedding provider selection for rotation
* Aggregates provider + model + all API keys
*/
export interface CodexLensEmbeddingProvider {
/** Reference to provider credential ID */
providerId: string;
/** Embedding model ID from the provider */
modelId: string;
/** Whether to use all API keys from this provider (default: true) */
useAllKeys: boolean;
/** Specific API key IDs to use (if useAllKeys is false) */
selectedKeyIds?: string[];
/** Weight for weighted routing (default: 1.0, applies to all keys from this provider) */
weight: number;
/** Maximum concurrent requests per key (default: 4) */
maxConcurrentPerKey: number;
/** Whether this provider is enabled for rotation */
enabled: boolean;
}
/**
* CodexLens multi-provider embedding rotation configuration
* Aggregates multiple providers with same model for parallel rotation
*/
export interface CodexLensEmbeddingRotation {
/** Whether multi-provider rotation is enabled */
enabled: boolean;
/** Selection strategy: round_robin, latency_aware, weighted_random */
strategy: 'round_robin' | 'latency_aware' | 'weighted_random';
/** Default cooldown seconds for rate-limited endpoints (default: 60) */
defaultCooldown: number;
/** Target model name that all providers should support (e.g., "qwen3-embedding") */
targetModel: string;
/** List of providers to aggregate for rotation */
providers: CodexLensEmbeddingProvider[];
}
/**
* Complete LiteLLM API configuration
* Root configuration object stored in JSON file
@@ -318,4 +366,7 @@ export interface LiteLLMApiConfig {
/** Global cache settings */
globalCacheSettings: GlobalCacheSettings;
/** CodexLens multi-provider embedding rotation config */
codexlensEmbeddingRotation?: CodexLensEmbeddingRotation;
}

1
ccw/tsconfig.tsbuildinfo Normal file
View File

@@ -0,0 +1 @@
{"root":["./src/cli.ts","./src/index.ts","./src/commands/cli.ts","./src/commands/core-memory.ts","./src/commands/hook.ts","./src/commands/install.ts","./src/commands/list.ts","./src/commands/memory.ts","./src/commands/serve.ts","./src/commands/session-path-resolver.ts","./src/commands/session.ts","./src/commands/stop.ts","./src/commands/tool.ts","./src/commands/uninstall.ts","./src/commands/upgrade.ts","./src/commands/view.ts","./src/config/litellm-api-config-manager.ts","./src/config/provider-models.ts","./src/config/storage-paths.ts","./src/core/cache-manager.ts","./src/core/claude-freshness.ts","./src/core/core-memory-store.ts","./src/core/dashboard-generator-patch.ts","./src/core/dashboard-generator.ts","./src/core/data-aggregator.ts","./src/core/history-importer.ts","./src/core/lite-scanner-complete.ts","./src/core/lite-scanner.ts","./src/core/manifest.ts","./src/core/memory-embedder-bridge.ts","./src/core/memory-store.ts","./src/core/server.ts","./src/core/session-clustering-service.ts","./src/core/session-scanner.ts","./src/core/websocket.ts","./src/core/routes/ccw-routes.ts","./src/core/routes/claude-routes.ts","./src/core/routes/cli-routes.ts","./src/core/routes/codexlens-routes.ts","./src/core/routes/core-memory-routes.ts","./src/core/routes/files-routes.ts","./src/core/routes/graph-routes.ts","./src/core/routes/help-routes.ts","./src/core/routes/hooks-routes.ts","./src/core/routes/litellm-api-routes.ts","./src/core/routes/litellm-routes.ts","./src/core/routes/mcp-routes.ts","./src/core/routes/mcp-templates-db.ts","./src/core/routes/memory-routes.ts","./src/core/routes/rules-routes.ts","./src/core/routes/session-routes.ts","./src/core/routes/skills-routes.ts","./src/core/routes/status-routes.ts","./src/core/routes/system-routes.ts","./src/mcp-server/index.ts","./src/tools/classify-folders.ts","./src/tools/claude-cli-tools.ts","./src/tools/cli-config-manager.ts","./src/tools/cli-executor.ts","./src/tools/cli-history-store.ts","./src/tools/codex-lens.ts","./src/tools/context-cache-store.ts","./src/tools/context-cache.ts","./src/tools/convert-tokens-to-css.ts","./src/tools/core-memory.ts","./src/tools/detect-changed-modules.ts","./src/tools/discover-design-files.ts","./src/tools/edit-file.ts","./src/tools/generate-module-docs.ts","./src/tools/get-modules-by-depth.ts","./src/tools/index.ts","./src/tools/litellm-client.ts","./src/tools/litellm-executor.ts","./src/tools/native-session-discovery.ts","./src/tools/notifier.ts","./src/tools/pattern-parser.ts","./src/tools/read-file.ts","./src/tools/resume-strategy.ts","./src/tools/session-content-parser.ts","./src/tools/session-manager.ts","./src/tools/smart-context.ts","./src/tools/smart-search.ts","./src/tools/storage-manager.ts","./src/tools/ui-generate-preview.js","./src/tools/ui-instantiate-prototypes.js","./src/tools/update-module-claude.js","./src/tools/write-file.ts","./src/types/config.ts","./src/types/index.ts","./src/types/litellm-api-config.ts","./src/types/session.ts","./src/types/tool.ts","./src/utils/browser-launcher.ts","./src/utils/file-utils.ts","./src/utils/path-resolver.ts","./src/utils/path-validator.ts","./src/utils/ui.ts"],"version":"5.9.3"}

View File

@@ -103,12 +103,12 @@ def init(
"-l",
help="Limit indexing to specific languages (repeat or comma-separated).",
),
workers: Optional[int] = typer.Option(None, "--workers", "-w", min=1, max=16, help="Parallel worker processes (default: auto-detect based on CPU count, max 16)."),
workers: Optional[int] = typer.Option(None, "--workers", "-w", min=1, max=32, help="Parallel worker processes (default: auto-detect based on CPU count, max 32)."),
force: bool = typer.Option(False, "--force", "-f", help="Force full reindex (skip incremental mode)."),
no_embeddings: bool = typer.Option(False, "--no-embeddings", help="Skip automatic embedding generation (if semantic deps installed)."),
embedding_backend: str = typer.Option("fastembed", "--embedding-backend", help="Embedding backend: fastembed (local) or litellm (remote API)."),
embedding_model: str = typer.Option("code", "--embedding-model", help="Embedding model: profile name for fastembed (fast/code/multilingual/balanced) or model name for litellm (e.g. text-embedding-3-small)."),
max_workers: int = typer.Option(1, "--max-workers", min=1, max=16, help="Max concurrent API calls for embedding generation. Recommended: 4-8 for litellm backend."),
max_workers: int = typer.Option(1, "--max-workers", min=1, max=32, help="Max concurrent API calls for embedding generation. Recommended: 4-8 for litellm backend."),
json_mode: bool = typer.Option(False, "--json", help="Output JSON response."),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging."),
) -> None:
@@ -351,7 +351,7 @@ def search(
Use 'codexlens embeddings-generate' to create embeddings first.
Hybrid Mode:
Default weights: exact=0.4, fuzzy=0.3, vector=0.3
Default weights: exact=0.3, fuzzy=0.1, vector=0.6
Use --weights to customize (e.g., --weights 0.5,0.3,0.2)
Examples:
@@ -1852,7 +1852,7 @@ def embeddings_generate(
"--max-workers",
"-w",
min=1,
max=16,
max=32,
help="Max concurrent API calls. Recommended: 4-8 for litellm backend. Default: 1 (sequential).",
),
json_mode: bool = typer.Option(False, "--json", help="Output JSON response."),

View File

@@ -331,7 +331,7 @@ def generate_embeddings(
if max_workers is None:
if embedding_backend == "litellm":
if endpoint_count > 1:
max_workers = min(endpoint_count * 2, 16) # Cap at 16 workers
max_workers = min(endpoint_count * 2, 32) # Cap at 32 workers
else:
max_workers = 4
else:
@@ -806,7 +806,7 @@ def generate_embeddings_recursive(
if max_workers is None:
if embedding_backend == "litellm":
if endpoint_count > 1:
max_workers = min(endpoint_count * 2, 16)
max_workers = min(endpoint_count * 2, 32)
else:
max_workers = 4
else:

View File

@@ -27,11 +27,11 @@ class HybridSearchEngine:
default_weights: Default RRF weights for each source
"""
# Default RRF weights (exact: 40%, fuzzy: 30%, vector: 30%)
# Default RRF weights (vector: 60%, exact: 30%, fuzzy: 10%)
DEFAULT_WEIGHTS = {
"exact": 0.4,
"fuzzy": 0.3,
"vector": 0.3,
"exact": 0.3,
"fuzzy": 0.1,
"vector": 0.6,
}
def __init__(self, weights: Optional[Dict[str, float]] = None):

View File

@@ -25,7 +25,7 @@ def reciprocal_rank_fusion(
results_map: Dictionary mapping source name to list of SearchResult objects
Sources: 'exact', 'fuzzy', 'vector'
weights: Dictionary mapping source name to weight (default: equal weights)
Example: {'exact': 0.4, 'fuzzy': 0.3, 'vector': 0.3}
Example: {'exact': 0.3, 'fuzzy': 0.1, 'vector': 0.6}
k: Constant to avoid division by zero and control rank influence (default 60)
Returns:

View File

@@ -45,9 +45,9 @@ class TestHybridSearchBasics:
"""Test HybridSearchEngine initializes with default weights."""
engine = HybridSearchEngine()
assert engine.weights == HybridSearchEngine.DEFAULT_WEIGHTS
assert engine.weights["exact"] == 0.4
assert engine.weights["fuzzy"] == 0.3
assert engine.weights["vector"] == 0.3
assert engine.weights["exact"] == 0.3
assert engine.weights["fuzzy"] == 0.1
assert engine.weights["vector"] == 0.6
def test_engine_custom_weights(self):
"""Test HybridSearchEngine accepts custom weights."""

View File

@@ -230,16 +230,16 @@ class TestRRFSyntheticRankings:
vector = [SearchResult(path="c.py", score=8.0, excerpt="...")]
results_map = {"exact": exact, "fuzzy": fuzzy, "vector": vector}
weights = {"exact": 0.4, "fuzzy": 0.3, "vector": 0.3}
weights = {"exact": 0.3, "fuzzy": 0.1, "vector": 0.6}
fused = reciprocal_rank_fusion(results_map, weights=weights)
assert len(fused) == 3
# Each appears in one source only, so scores differ by weights
# a.py: 0.4/61 ≈ 0.0066
# b.py: 0.3/61 ≈ 0.0049
# c.py: 0.3/61 ≈ 0.0049
assert fused[0].path == "a.py", "Exact (higher weight) should rank first"
# c.py: 0.6/61 ≈ 0.0098 (vector, highest weight)
# a.py: 0.3/61 ≈ 0.0049 (exact)
# b.py: 0.1/61 ≈ 0.0016 (fuzzy)
assert fused[0].path == "c.py", "Vector (higher weight) should rank first"
class TestNormalizeBM25Score: