'use client'; import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { logger, cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { Loader2, Sparkles, Zap, Brain, Server, Cloud, Cpu, ChevronDown, Search, X, Lightbulb } from 'lucide-react'; import { configManager } from '@/lib/config/storage'; import { ProviderId, ProviderModel } from '@/lib/llm/providers/types'; import { getProvider } from '@/lib/llm/providers/registry'; import { getAvailableModels } from '@/lib/llm/llm-client'; import { fetchAvailableModels, formatModelPrice } from '@/lib/llm/models-api'; import { registerOpenRouterPricingFromApi, registerPricingFromProviderModels } from '@/lib/llm/pricing-cache'; import { toast } from 'sonner'; import { track } from '@/lib/telemetry'; interface ModelSelectorProps { provider?: ProviderId; value?: string; onChange?: (modelId: string) => void; className?: string; hideModelDetails?: boolean; mode?: 'popover' | 'inline'; skipGlobalSync?: boolean; autoFocus?: boolean; } export function ModelSelector({ provider, value: _value, onChange, className, hideModelDetails, mode = 'popover', skipGlobalSync, autoFocus }: ModelSelectorProps) { const currentProvider = provider || configManager.getSelectedProvider(); const providerConfig = getProvider(currentProvider); const onChangeRef = useRef(onChange); onChangeRef.current = onChange; const [models, setModels] = useState([]); const [loading, setLoading] = useState(true); const [selectedModel, setSelectedModel] = useState(''); const [open, setOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [needsApiKey, setNeedsApiKey] = useState(false); const [reasoningEnabled, setReasoningEnabled] = useState(false); const getModelName = (model: ProviderModel) => { return model.name; }; // Define loadModels before useEffects that use it const loadModels = useCallback(async () => { try { setLoading(true); const apiKey = configManager.getProviderApiKey(currentProvider); if (providerConfig.apiKeyRequired && !apiKey) { setNeedsApiKey(true); if (providerConfig.models) { setModels(providerConfig.models); } else { setModels([]); } return; } setNeedsApiKey(false); // Check cache first const cachedModels = configManager.getCachedModels(currentProvider); if (cachedModels) { const modelsFromCache = cachedModels.models as ProviderModel[]; setModels(modelsFromCache); if (currentProvider === 'openrouter') { registerPricingFromProviderModels('openrouter', modelsFromCache); } return; } let loadedModels: ProviderModel[] = []; if (currentProvider === 'openrouter') { // Use the existing OpenRouter models API const availableModels = await fetchAvailableModels(); registerOpenRouterPricingFromApi(availableModels); const norm = (desc: unknown): string => { if (typeof desc === 'string') { return desc; } if (desc && typeof desc === 'object') { const record = desc as Record; const candidate = ['description', 'name', 'summary'] .map((key) => record[key]) .find((value): value is string => typeof value === 'string'); if (candidate) { return candidate; } try { return JSON.stringify(record); } catch { /* ignore */ } } if (desc == null) { return ''; } return String(desc); }; loadedModels = availableModels.map((model) => { const promptRate = model.pricing?.prompt ? Number(model.pricing.prompt) : undefined; const completionRate = model.pricing?.completion ? Number(model.pricing.completion) : undefined; const reasoningRate = model.pricing?.internal_reasoning ? Number(model.pricing.internal_reasoning) : undefined; const normalizeRate = (value?: number) => { if (value === undefined || !Number.isFinite(value)) return undefined; return value * 1_000_000; }; const normalizedInput = normalizeRate(promptRate); const normalizedOutput = normalizeRate(completionRate); const normalizedReasoning = normalizeRate(reasoningRate); const pricing = (normalizedInput !== undefined && normalizedOutput !== undefined) ? { input: normalizedInput, output: normalizedOutput, reasoning: normalizedReasoning } : undefined; const orModalities = model.architecture?.input_modalities as import('@/lib/llm/providers/types').InputModality[] | undefined; const providerModel: ProviderModel = { id: model.id, name: model.name, description: norm(model.description), contextLength: model.context_length, maxTokens: model.top_provider?.max_completion_tokens, supportsFunctions: model.supported_parameters?.includes('tools'), supportsVision: orModalities?.includes('image'), supportsReasoning: model.supported_parameters?.includes('reasoning'), ...(orModalities ? { inputModalities: orModalities } : {}), pricing }; return providerModel; }); } else if (currentProvider === 'huggingface') { try { const hfResponse = await fetch('https://router.huggingface.co/v1/models'); if (hfResponse.ok) { const hfData = await hfResponse.json(); loadedModels = (hfData.data || []).map((model: any) => { const hfProviders = model.providers || []; const bestProvider = hfProviders.find((p: any) => p.supports_tools && p.status === 'live') || hfProviders.find((p: any) => p.status === 'live') || hfProviders[0]; const contextLength = bestProvider?.context_length || 32768; const supportsFunctions = hfProviders.some((p: any) => p.supports_tools); const hfModalities = model.architecture?.input_modalities as import('@/lib/llm/providers/types').InputModality[] | undefined; const supportsVision = hfModalities?.includes('image'); let pricing: { input: number; output: number } | undefined; if (bestProvider?.pricing?.input != null && bestProvider?.pricing?.output != null) { pricing = { input: bestProvider.pricing.input, output: bestProvider.pricing.output, }; } return { id: model.id, name: model.id.split('/').pop() || model.id, contextLength, supportsFunctions, supportsVision, ...(hfModalities ? { inputModalities: hfModalities } : {}), pricing, } as ProviderModel; }); } } catch (error) { logger.error('HuggingFace models fetch error:', error); } if (loadedModels.length > 0) { registerPricingFromProviderModels('huggingface', loadedModels); } } else if (providerConfig.supportsModelDiscovery) { // Try to discover models (we know API key exists at this point) const modelEntries = await getAvailableModels(apiKey || undefined, currentProvider); loadedModels = modelEntries.map(entry => { const id = typeof entry === 'string' ? entry : entry.id; const contextLength = typeof entry === 'object' && entry.contextLength ? entry.contextLength : 32000; const inputModalities = typeof entry === 'object' && entry.inputModalities ? entry.inputModalities as import('@/lib/llm/providers/types').InputModality[] : undefined; return { id, name: id.split('/').pop() || id, contextLength, supportsFunctions: true, ...(inputModalities ? { inputModalities, supportsVision: inputModalities.includes('image') } : {}), }; }); } else if (providerConfig.models) { // Use hardcoded models loadedModels = providerConfig.models; } else { loadedModels = []; } setModels(loadedModels); // Show warning for local providers with no models if (providerConfig.isLocal && loadedModels.length === 0) { toast.warning( `No models found in ${providerConfig.name}. Please load some models in the application.`, { duration: 5000 } ); } // Cache the loaded models if (loadedModels.length > 0) { configManager.setCachedModels(currentProvider, loadedModels); if (currentProvider === 'openrouter') { registerPricingFromProviderModels('openrouter', loadedModels); } } } catch (error) { logger.error('Failed to load models:', error); // Show helpful message for local providers if (providerConfig.isLocal) { toast.error( `${providerConfig.name} server not running. Please start the server and load some models.`, { duration: 5000 } ); } // Fall back to hardcoded models if available if (providerConfig.models) { setModels(providerConfig.models); } } finally { setLoading(false); } }, [currentProvider, providerConfig]); // Single effect to handle provider changes and model loading useEffect(() => { // Immediately clear models array to prevent stale state setModels([]); setSelectedModel(''); setLoading(true); // Clear cache for this provider to get fresh models configManager.clearModelCache(currentProvider); // Load models immediately loadModels(); }, [currentProvider, loadModels]); // Single effect to initialize selectedModel when models are loaded useEffect(() => { if (models.length === 0 || loading) return; // Get the saved model for this provider const savedModel = configManager.getProviderModel(currentProvider); // Check if saved model exists in loaded models if (savedModel && models.some(m => m.id === savedModel)) { setSelectedModel(savedModel); onChangeRef.current?.(savedModel); } else { // No saved model or saved model doesn't exist, use first model const firstModel = models[0]?.id; if (firstModel) { setSelectedModel(firstModel); if (!skipGlobalSync) { configManager.setProviderModel(currentProvider, firstModel); } onChangeRef.current?.(firstModel); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [models, loading, currentProvider]); // Method to refresh models (can be called externally) const _refreshModels = (forceRefresh = false) => { if (forceRefresh) { configManager.clearModelCache(currentProvider); } loadModels(); }; // Expose refresh method via ref or global method (for when API key is added) useEffect(() => { const handleApiKeyUpdate = () => { // Small delay to ensure config is saved setTimeout(() => { loadModels(); }, 100); }; // Listen for a custom event when API keys are updated window.addEventListener('apiKeyUpdated', handleApiKeyUpdate); return () => { window.removeEventListener('apiKeyUpdated', handleApiKeyUpdate); }; }, [loadModels]); const handleModelSelect = (modelId: string) => { setSelectedModel(modelId); if (!skipGlobalSync) { configManager.setProviderModel(currentProvider, modelId); } onChange?.(modelId); track('model_selected', { provider: currentProvider, model: modelId, previous_model: selectedModel !== modelId ? selectedModel : undefined, }); if (mode === 'popover') { setOpen(false); setSearchQuery(''); } // Load reasoning state for the new model setReasoningEnabled(configManager.getReasoningEnabled(modelId)); }; const handleReasoningToggle = (enabled: boolean) => { setReasoningEnabled(enabled); if (selectedModel) { configManager.setReasoningEnabled(selectedModel, enabled); } }; // Sync reasoning state when selected model changes useEffect(() => { if (selectedModel) { setReasoningEnabled(configManager.getReasoningEnabled(selectedModel)); } }, [selectedModel]); const getModelIcon = (model: ProviderModel) => { const id = model.id.toLowerCase(); if (id.includes('deepseek')) return ; if (id.includes('claude')) return ; if (id.includes('gpt')) return ; if (id.includes('gemini')) return ; if (id.includes('llama')) return ; if (id.includes('qwen')) return ; return null; }; const getProviderColor = (modelId: string) => { const id = modelId.toLowerCase(); if (id.includes('deepseek')) return 'bg-blue-500/10 text-blue-500'; if (id.includes('claude')) return 'bg-orange-500/10 text-orange-500'; if (id.includes('openai') || id.includes('gpt')) return 'bg-green-500/10 text-green-500'; if (id.includes('qwen')) return 'bg-orange-500/10 text-orange-500'; if (id.includes('google')) return 'bg-red-500/10 text-red-500'; if (id.includes('meta')) return 'bg-indigo-500/10 text-indigo-500'; return 'bg-gray-500/10 text-gray-500'; }; // Filter models based on search query const filteredModels = useMemo(() => { if (!searchQuery.trim()) return models; const query = searchQuery.toLowerCase(); return models.filter(model => { const modelId = model.id.toLowerCase(); const modelName = getModelName(model).toLowerCase(); const providerName = model.id.split('/')[0].toLowerCase(); return ( modelId.includes(query) || modelName.includes(query) || providerName.includes(query) ); }); }, [models, searchQuery]); const selectedModelData = models.find(m => m.id === selectedModel); if (loading) { return (
Loading models...
); } if (needsApiKey) { return (
API key required for {providerConfig.name}

Set your API key in settings to load available models

); } const renderModelItem = (model: ProviderModel) => ( ); const modelDetailsSection = !hideModelDetails && selectedModelData && (
{selectedModelData.pricing ? ( selectedModelData.pricing.input === 0 && selectedModelData.pricing.output === 0 ? 'Free' : `Input: ${formatModelPrice(selectedModelData.pricing.input)}/K • Output: ${formatModelPrice(selectedModelData.pricing.output)}/K` ) : ( 'Pricing varies by provider' )}
{selectedModelData.supportsFunctions && ( Tools )} {selectedModelData.supportsVision && ( Vision )} {selectedModelData.supportsReasoning && ( Reasoning )} {!selectedModelData.supportsFunctions && ( No native tools )}
{selectedModelData.description && (
{selectedModelData.description}
)}
); const reasoningSection = selectedModelData?.supportsReasoning && (

Show step-by-step thinking process

); // --- Inline mode --- if (mode === 'inline') { return (
{/* Search bar */}
setSearchQuery(e.target.value)} className="h-9 border-0 bg-transparent dark:bg-transparent shadow-none focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 text-sm" autoFocus={autoFocus} /> {searchQuery && ( )}
{/* Model list */}
{filteredModels.length === 0 ? (
No models found
) : ( filteredModels.map(renderModelItem) )}
{modelDetailsSection} {reasoningSection}
); } // --- Popover mode (default) --- return (
setSearchQuery(e.target.value)} className="h-10 border-0 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0" /> {searchQuery && ( )}
{filteredModels.length === 0 ? (
No models found
) : ( filteredModels.map((model) => ( )) )}
{modelDetailsSection} {reasoningSection}
); }