| '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<ProviderModel[]>([]); |
| 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; |
| }; |
|
|
| |
| 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); |
| |
| |
| 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') { |
| |
| 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<string, unknown>; |
| 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 { |
| |
| } |
| } |
|
|
| 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) { |
| |
| 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) { |
| |
| loadedModels = providerConfig.models; |
| } else { |
| loadedModels = []; |
| } |
| |
| setModels(loadedModels); |
| |
| |
| if (providerConfig.isLocal && loadedModels.length === 0) { |
| toast.warning( |
| `No models found in ${providerConfig.name}. Please load some models in the application.`, |
| { duration: 5000 } |
| ); |
| } |
| |
| |
| if (loadedModels.length > 0) { |
| configManager.setCachedModels(currentProvider, loadedModels); |
| if (currentProvider === 'openrouter') { |
| registerPricingFromProviderModels('openrouter', loadedModels); |
| } |
| } |
| } catch (error) { |
| logger.error('Failed to load models:', error); |
| |
| |
| if (providerConfig.isLocal) { |
| toast.error( |
| `${providerConfig.name} server not running. Please start the server and load some models.`, |
| { duration: 5000 } |
| ); |
| } |
| |
| |
| if (providerConfig.models) { |
| setModels(providerConfig.models); |
| } |
| } finally { |
| setLoading(false); |
| } |
| }, [currentProvider, providerConfig]); |
|
|
| |
| useEffect(() => { |
| |
| setModels([]); |
| setSelectedModel(''); |
| setLoading(true); |
| |
| |
| configManager.clearModelCache(currentProvider); |
| |
| |
| loadModels(); |
| }, [currentProvider, loadModels]); |
|
|
| |
| useEffect(() => { |
| if (models.length === 0 || loading) return; |
|
|
| |
| const savedModel = configManager.getProviderModel(currentProvider); |
| |
| if (savedModel && models.some(m => m.id === savedModel)) { |
| setSelectedModel(savedModel); |
| onChangeRef.current?.(savedModel); |
| } else { |
| |
| const firstModel = models[0]?.id; |
| if (firstModel) { |
| setSelectedModel(firstModel); |
| if (!skipGlobalSync) { |
| configManager.setProviderModel(currentProvider, firstModel); |
| } |
| onChangeRef.current?.(firstModel); |
| } |
| } |
| |
| }, [models, loading, currentProvider]); |
|
|
| |
| const _refreshModels = (forceRefresh = false) => { |
| if (forceRefresh) { |
| configManager.clearModelCache(currentProvider); |
| } |
| loadModels(); |
| }; |
|
|
| |
| useEffect(() => { |
| const handleApiKeyUpdate = () => { |
| |
| setTimeout(() => { |
| loadModels(); |
| }, 100); |
| }; |
|
|
| |
| 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(''); |
| } |
| |
| setReasoningEnabled(configManager.getReasoningEnabled(modelId)); |
| }; |
|
|
| const handleReasoningToggle = (enabled: boolean) => { |
| setReasoningEnabled(enabled); |
| if (selectedModel) { |
| configManager.setReasoningEnabled(selectedModel, enabled); |
| } |
| }; |
|
|
| |
| useEffect(() => { |
| if (selectedModel) { |
| setReasoningEnabled(configManager.getReasoningEnabled(selectedModel)); |
| } |
| }, [selectedModel]); |
|
|
| const getModelIcon = (model: ProviderModel) => { |
| const id = model.id.toLowerCase(); |
| if (id.includes('deepseek')) return <Brain className="h-3 w-3" />; |
| if (id.includes('claude')) return <Sparkles className="h-3 w-3" />; |
| if (id.includes('gpt')) return <Zap className="h-3 w-3" />; |
| if (id.includes('gemini')) return <Cloud className="h-3 w-3" />; |
| if (id.includes('llama')) return <Server className="h-3 w-3" />; |
| if (id.includes('qwen')) return <Cpu className="h-3 w-3" />; |
| 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'; |
| }; |
|
|
| |
| 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 ( |
| <div className={className}> |
| <Label>AI Model</Label> |
| <div className="flex items-center gap-2 h-10 px-3 border rounded-md bg-muted"> |
| <Loader2 className="h-4 w-4 animate-spin" /> |
| <span className="text-sm text-muted-foreground">Loading models...</span> |
| </div> |
| </div> |
| ); |
| } |
|
|
| if (needsApiKey) { |
| return ( |
| <div className={className}> |
| <Label>AI Model</Label> |
| <div className="flex items-center gap-2 h-10 px-3 border rounded-md bg-muted/50 border-orange-200 dark:border-orange-800"> |
| <span className="text-sm text-orange-600 dark:text-orange-400"> |
| API key required for {providerConfig.name} |
| </span> |
| </div> |
| <p className="text-xs text-muted-foreground mt-1"> |
| Set your API key in settings to load available models |
| </p> |
| </div> |
| ); |
| } |
|
|
| const renderModelItem = (model: ProviderModel) => ( |
| <button |
| key={model.id} |
| onClick={() => handleModelSelect(model.id)} |
| className={cn( |
| "w-full text-left px-3 py-2 transition-colors rounded-lg", |
| mode === 'inline' |
| ? selectedModel === model.id |
| ? "bg-primary/10 border border-primary/30" |
| : "hover:bg-accent border border-transparent" |
| : selectedModel === model.id |
| ? "bg-accent" |
| : "hover:bg-accent hover:text-accent-foreground" |
| )} |
| > |
| <div className="flex flex-col gap-0.5"> |
| <div className="flex items-center gap-2"> |
| {getModelIcon(model)} |
| <span className={cn("font-medium text-sm", selectedModel === model.id && mode === 'inline' && "text-primary")}> |
| {getModelName(model)} |
| </span> |
| {currentProvider === 'openrouter' && ( |
| <Badge variant="secondary" className={`text-xs ${getProviderColor(model.id)}`}> |
| {model.id.split('/')[0]} |
| </Badge> |
| )} |
| {model.supportsFunctions === false && ( |
| <Badge variant="outline" className="text-[9px] px-1 py-0 text-muted-foreground/50">No tools</Badge> |
| )} |
| </div> |
| <div className="flex items-center gap-3 text-xs text-muted-foreground"> |
| <span>Context: {Math.round(model.contextLength / 1000)}K</span> |
| {model.pricing && ( |
| model.pricing.input === 0 && model.pricing.output === 0 ? ( |
| <> |
| <span>·</span> |
| <span>Free</span> |
| </> |
| ) : ( |
| <> |
| <span>·</span> |
| <span> |
| {formatModelPrice(model.pricing.input)}/K | {formatModelPrice(model.pricing.output)}/K |
| </span> |
| </> |
| ) |
| )} |
| {!model.pricing && currentProvider !== 'openrouter' && ( |
| <> |
| <span>·</span> |
| <span>Pricing varies</span> |
| </> |
| )} |
| </div> |
| </div> |
| </button> |
| ); |
|
|
| const modelDetailsSection = !hideModelDetails && selectedModelData && ( |
| <div className="mt-1 text-xs text-muted-foreground max-h-[150px] overflow-y-auto pr-2"> |
| <div className="font-medium mb-1"> |
| {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' |
| )} |
| </div> |
| <div className="flex flex-wrap gap-1 my-1"> |
| {selectedModelData.supportsFunctions && ( |
| <Badge variant="outline" className="text-[10px] px-1.5 py-0">Tools</Badge> |
| )} |
| {selectedModelData.supportsVision && ( |
| <Badge variant="outline" className="text-[10px] px-1.5 py-0">Vision</Badge> |
| )} |
| {selectedModelData.supportsReasoning && ( |
| <Badge variant="outline" className="text-[10px] px-1.5 py-0">Reasoning</Badge> |
| )} |
| {!selectedModelData.supportsFunctions && ( |
| <Badge variant="outline" className="text-[10px] px-1.5 py-0 text-muted-foreground/60">No native tools</Badge> |
| )} |
| </div> |
| {selectedModelData.description && ( |
| <div>{selectedModelData.description}</div> |
| )} |
| </div> |
| ); |
|
|
| const reasoningSection = selectedModelData?.supportsReasoning && ( |
| <div className="mt-3 flex items-center justify-between gap-2 p-2 rounded-md bg-muted/50 border"> |
| <div className="flex items-center gap-2"> |
| <Lightbulb className="h-4 w-4 text-amber-500" /> |
| <div> |
| <Label htmlFor="reasoning-toggle" className="text-sm font-medium cursor-pointer"> |
| Enable Reasoning |
| </Label> |
| <p className="text-xs text-muted-foreground"> |
| Show step-by-step thinking process |
| </p> |
| </div> |
| </div> |
| <Switch |
| id="reasoning-toggle" |
| checked={reasoningEnabled} |
| onCheckedChange={handleReasoningToggle} |
| /> |
| </div> |
| ); |
|
|
| |
| if (mode === 'inline') { |
| return ( |
| <div className={className}> |
| <div className="border rounded-lg overflow-hidden"> |
| {/* Search bar */} |
| <div className="flex items-center border-b px-3"> |
| <Search className="h-3.5 w-3.5 shrink-0 opacity-50" /> |
| <Input |
| placeholder="Search models..." |
| value={searchQuery} |
| onChange={(e) => 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 && ( |
| <Button |
| variant="ghost" |
| size="sm" |
| onClick={() => setSearchQuery('')} |
| className="h-5 w-5 p-0" |
| > |
| <X className="h-3 w-3" /> |
| </Button> |
| )} |
| </div> |
| {/* Model list */} |
| <div className="max-h-[240px] overflow-y-auto p-1"> |
| {filteredModels.length === 0 ? ( |
| <div className="py-5 text-center text-sm text-muted-foreground"> |
| No models found |
| </div> |
| ) : ( |
| filteredModels.map(renderModelItem) |
| )} |
| </div> |
| </div> |
| {modelDetailsSection} |
| {reasoningSection} |
| </div> |
| ); |
| } |
|
|
| |
| return ( |
| <div className={className}> |
| <Label htmlFor="model-select">AI Model</Label> |
| <Popover open={open} onOpenChange={setOpen}> |
| <PopoverTrigger asChild> |
| <Button |
| variant="outline" |
| role="combobox" |
| aria-expanded={open} |
| className="justify-between font-normal min-w-[200px]" |
| > |
| {selectedModelData ? ( |
| <div className="flex items-center gap-2 truncate"> |
| {getModelIcon(selectedModelData)} |
| <span className="truncate">{getModelName(selectedModelData)}</span> |
| </div> |
| ) : ( |
| <span className="text-muted-foreground">Select a model...</span> |
| )} |
| <ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> |
| </Button> |
| </PopoverTrigger> |
| <PopoverContent |
| className="w-[32rem] p-0" |
| align="start" |
| side="bottom" |
| sideOffset={5} |
| > |
| <div className="flex items-center border-b px-3"> |
| <Search className="h-4 w-4 shrink-0 opacity-50" /> |
| <Input |
| placeholder="Search models..." |
| value={searchQuery} |
| onChange={(e) => setSearchQuery(e.target.value)} |
| className="h-10 border-0 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0" |
| /> |
| {searchQuery && ( |
| <Button |
| variant="ghost" |
| size="sm" |
| onClick={() => setSearchQuery('')} |
| className="h-5 w-5 p-0" |
| > |
| <X className="h-3 w-3" /> |
| </Button> |
| )} |
| </div> |
| <div className="max-h-[400px] min-h-[300px] overflow-y-auto"> |
| {filteredModels.length === 0 ? ( |
| <div className="py-6 text-center text-sm text-muted-foreground"> |
| No models found |
| </div> |
| ) : ( |
| filteredModels.map((model) => ( |
| <button |
| key={model.id} |
| onClick={() => handleModelSelect(model.id)} |
| className={cn( |
| "w-full text-left px-3 py-3 hover:bg-accent hover:text-accent-foreground transition-colors", |
| selectedModel === model.id && "bg-accent" |
| )} |
| > |
| <div className="flex flex-col gap-1"> |
| <div className="flex items-center gap-2"> |
| {getModelIcon(model)} |
| <span className="font-medium">{getModelName(model)}</span> |
| {currentProvider === 'openrouter' && ( |
| <Badge variant="secondary" className={`text-xs ${getProviderColor(model.id)}`}> |
| {model.id.split('/')[0]} |
| </Badge> |
| )} |
| </div> |
| <div className="flex items-center gap-3 text-xs text-muted-foreground"> |
| <span>Context: {Math.round(model.contextLength / 1000)}K</span> |
| {model.pricing && ( |
| model.pricing.input === 0 && model.pricing.output === 0 ? ( |
| <> |
| <span>•</span> |
| <span>Free</span> |
| </> |
| ) : ( |
| <> |
| <span>•</span> |
| <span> |
| {formatModelPrice(model.pricing.input)}/K | {formatModelPrice(model.pricing.output)}/K |
| </span> |
| </> |
| ) |
| )} |
| {!model.pricing && currentProvider !== 'openrouter' && ( |
| <> |
| <span>•</span> |
| <span>Pricing varies</span> |
| </> |
| )} |
| </div> |
| </div> |
| </button> |
| )) |
| )} |
| </div> |
| </PopoverContent> |
| </Popover> |
| {modelDetailsSection} |
| {reasoningSection} |
| </div> |
| ); |
| } |
|
|