builder / components /model-selector.tsx
Leon4gr45's picture
Upload folder using huggingface_hub
eeb9404 verified
Raw
History Blame Contribute Delete
25.8 kB
'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;
};
// 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<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 {
/* 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 <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';
};
// 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 (
<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>
);
// --- Inline mode ---
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>
);
}
// --- Popover mode (default) ---
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>
);
}