Files
slate/app/components/chat/ModelSelector.tsx
Stijnus b7ef2247b8 feat: add Cerebras and Fireworks AI LLM providers (#2113)
* fix: improve local model provider robustness and UX

- Extract shared Docker URL rewriting and env conversion into BaseProvider
  to eliminate 4x duplicated code across Ollama and LMStudio
- Add error handling and 5s timeouts to all model-listing fetches so one
  unreachable provider doesn't block the entire model list
- Fix Ollama using createOllama() instead of mutating provider internals
- Fix LLMManager singleton ignoring env updates on subsequent requests
- Narrow cache key to only include provider-relevant env vars instead of
  the entire server environment
- Fix 'as any' casts in LMStudio and OpenAILike by using shared
  convertEnvToRecord helper
- Replace console.log/error with structured logger in OpenAILike
- Fix typo: filteredStaticModesl -> filteredStaticModels in manager
- Add connection status indicator (green/red dot) for local providers
  in the ModelSelector dropdown
- Show helpful "is X running?" message when local provider has no models

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add Cerebras LLM provider

- Add Cerebras provider with 8 models (Llama, GPT OSS, Qwen, ZAI GLM)
- Integrate @ai-sdk/cerebras@0.2.16 for compatibility
- Add CEREBRAS_API_KEY to environment configuration
- Register provider in LLMManager registry

Models included:
- llama3.1-8b, llama-3.3-70b
- gpt-oss-120b (reasoning)
- qwen-3-32b, qwen-3-235b variants
- zai-glm-4.6, zai-glm-4.7 (reasoning)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: add Fireworks AI LLM provider

- Add Fireworks provider with 6 popular models
- Integrate @ai-sdk/fireworks@0.2.16 for compatibility
- Add FIREWORKS_API_KEY to environment configuration
- Register provider in LLMManager registry

Models included:
- Llama 3.1 variants (405B, 70B, 8B Instruct)
- DeepSeek R1 (reasoning model)
- Qwen 2.5 72B Instruct
- FireFunction V2

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: add coding-specific models to existing providers

Enhanced providers with state-of-the-art coding models:

**DeepSeek Provider:**
+ DeepSeek V3.2 (integrates thinking + tool-use)
+ DeepSeek V3.2-Speciale (high-compute variant, beats GPT-5)

**Fireworks Provider:**
+ Qwen3-Coder 480B (262K context, best for coding)
+ Qwen3-Coder 30B (fast coding specialist)

**Cerebras Provider:**
+ Qwen3-Coder 480B (2000 tokens/sec!)
- Removed deprecated models (qwen-3-32b, llama-3.3-70b)

Total new models: 4
Total coding models across all providers: 12+

Performance highlights:
- Qwen3-Coder: State-of-the-art coding performance
- DeepSeek V3.2: Integrates thinking directly into tool-use
- ZAI GLM 4.6: 73.8% SWE-bench score
- Ultra-fast inference: 2000 tok/s on Cerebras

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: add dynamic model discovery to providers

Implemented getDynamicModels() for automatic model discovery:

**DeepSeek Provider:**
- Fetches models from https://api.deepseek.com/models
- Automatically discovers new models as DeepSeek adds them
- Filters out static models to avoid duplicates

**Cerebras Provider:**
- Fetches models from https://api.cerebras.ai/v1/models
- Auto-discovers new Cerebras models
- Keeps UI up-to-date with latest offerings

**Fireworks Provider:**
- Fetches from https://api.fireworks.ai/v1/accounts/fireworks/models
- Includes context_length from API response
- Discovers new Qwen-Coder and other models automatically

**Moonshot Provider:**
- Fetches from https://api.moonshot.ai/v1/models
- OpenAI-compatible endpoint
- Auto-discovers new Kimi models

Benefits:
-  No manual updates needed when providers add new models
-  Users always have access to latest models
-  Graceful fallback to static models if API fails
-  5-second timeout prevents hanging
-  Caching system built into BaseProvider

Technical details:
- Uses BaseProvider's built-in caching system
- Cache invalidates when API keys change
- Failed API calls fallback to static models
- All endpoints have 5-second timeout protection

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: add Z.AI provider with GLM models and JWT authentication

Merged changes from PR #2069 to add Z.AI provider:
- Added GLM-4.6 (200K), GLM-4.5 (128K), and GLM-4.5 Flash models
- Implemented secure JWT token generation with HMAC-SHA256 signing
- Added dynamic model discovery from Z.AI API
- Included proper error handling and token validation
- GLM-4.6 achieves 73.8% on SWE-bench coding benchmarks

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:26:46 +01:00

869 lines
34 KiB
TypeScript

import type { ProviderInfo } from '~/types/model';
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import type { KeyboardEvent } from 'react';
import type { ModelInfo } from '~/lib/modules/llm/types';
import { classNames } from '~/utils/classNames';
import { LOCAL_PROVIDERS } from '~/lib/stores/settings';
// Fuzzy search utilities
const levenshteinDistance = (str1: string, str2: string): number => {
const matrix = [];
for (let i = 0; i <= str2.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= str1.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= str2.length; i++) {
for (let j = 1; j <= str1.length; j++) {
if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
}
}
}
return matrix[str2.length][str1.length];
};
const fuzzyMatch = (query: string, text: string): { score: number; matches: boolean } => {
if (!query) {
return { score: 0, matches: true };
}
if (!text) {
return { score: 0, matches: false };
}
const queryLower = query.toLowerCase();
const textLower = text.toLowerCase();
// Exact substring match gets highest score
if (textLower.includes(queryLower)) {
return { score: 100 - (textLower.indexOf(queryLower) / textLower.length) * 20, matches: true };
}
// Fuzzy match with reasonable threshold
const distance = levenshteinDistance(queryLower, textLower);
const maxLen = Math.max(queryLower.length, textLower.length);
const similarity = 1 - distance / maxLen;
return {
score: similarity > 0.6 ? similarity * 80 : 0,
matches: similarity > 0.6,
};
};
const highlightText = (text: string, query: string): string => {
if (!query) {
return text;
}
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-800 text-current">$1</mark>');
};
const formatContextSize = (tokens: number): string => {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(1)}M`;
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(0)}K`;
}
return tokens.toString();
};
interface ModelSelectorProps {
model?: string;
setModel?: (model: string) => void;
provider?: ProviderInfo;
setProvider?: (provider: ProviderInfo) => void;
modelList: ModelInfo[];
providerList: ProviderInfo[];
apiKeys: Record<string, string>;
modelLoading?: string;
}
// Helper function to determine if a model is likely free
const isModelLikelyFree = (model: ModelInfo, providerName?: string): boolean => {
// OpenRouter models with zero pricing in the label
if (providerName === 'OpenRouter' && model.label.includes('in:$0.00') && model.label.includes('out:$0.00')) {
return true;
}
// Models with "free" in the name or label
if (model.name.toLowerCase().includes('free') || model.label.toLowerCase().includes('free')) {
return true;
}
return false;
};
export const ModelSelector = ({
model,
setModel,
provider,
setProvider,
modelList,
providerList,
modelLoading,
}: ModelSelectorProps) => {
const [modelSearchQuery, setModelSearchQuery] = useState('');
const [debouncedModelSearchQuery, setDebouncedModelSearchQuery] = useState('');
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
const [focusedModelIndex, setFocusedModelIndex] = useState(-1);
const modelSearchInputRef = useRef<HTMLInputElement>(null);
const modelOptionsRef = useRef<(HTMLDivElement | null)[]>([]);
const modelDropdownRef = useRef<HTMLDivElement>(null);
const [providerSearchQuery, setProviderSearchQuery] = useState('');
const [debouncedProviderSearchQuery, setDebouncedProviderSearchQuery] = useState('');
const [isProviderDropdownOpen, setIsProviderDropdownOpen] = useState(false);
const [focusedProviderIndex, setFocusedProviderIndex] = useState(-1);
const providerSearchInputRef = useRef<HTMLInputElement>(null);
const providerOptionsRef = useRef<(HTMLDivElement | null)[]>([]);
const providerDropdownRef = useRef<HTMLDivElement>(null);
const [showFreeModelsOnly, setShowFreeModelsOnly] = useState(false);
type ConnectionStatus = 'unknown' | 'connected' | 'disconnected';
const [localProviderStatus, setLocalProviderStatus] = useState<Record<string, ConnectionStatus>>({});
// Check connectivity of local providers when provider list changes
useEffect(() => {
const checkLocalProviders = async () => {
const statuses: Record<string, 'connected' | 'disconnected'> = {};
for (const p of providerList) {
if (!LOCAL_PROVIDERS.includes(p.name)) {
continue;
}
// If the provider has models loaded, it's connected
const hasModels = modelList.some((m) => m.provider === p.name);
statuses[p.name] = hasModels ? 'connected' : 'disconnected';
}
setLocalProviderStatus(statuses);
};
checkLocalProviders();
}, [providerList, modelList]);
// Debounce search queries
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedModelSearchQuery(modelSearchQuery);
}, 150);
return () => clearTimeout(timer);
}, [modelSearchQuery]);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedProviderSearchQuery(providerSearchQuery);
}, 150);
return () => clearTimeout(timer);
}, [providerSearchQuery]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modelDropdownRef.current && !modelDropdownRef.current.contains(event.target as Node)) {
setIsModelDropdownOpen(false);
setModelSearchQuery('');
}
if (providerDropdownRef.current && !providerDropdownRef.current.contains(event.target as Node)) {
setIsProviderDropdownOpen(false);
setProviderSearchQuery('');
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const filteredModels = useMemo(() => {
const baseModels = [...modelList].filter((e) => e.provider === provider?.name && e.name);
return baseModels
.filter((model) => {
// Apply free models filter
if (showFreeModelsOnly && !isModelLikelyFree(model, provider?.name)) {
return false;
}
return true;
})
.map((model) => {
// Calculate search scores for fuzzy matching
const labelMatch = fuzzyMatch(debouncedModelSearchQuery, model.label);
const nameMatch = fuzzyMatch(debouncedModelSearchQuery, model.name);
const contextMatch = fuzzyMatch(debouncedModelSearchQuery, formatContextSize(model.maxTokenAllowed));
const bestScore = Math.max(labelMatch.score, nameMatch.score, contextMatch.score);
const matches = labelMatch.matches || nameMatch.matches || contextMatch.matches || !debouncedModelSearchQuery; // Show all if no query
return {
...model,
searchScore: bestScore,
searchMatches: matches,
highlightedLabel: highlightText(model.label, debouncedModelSearchQuery),
highlightedName: highlightText(model.name, debouncedModelSearchQuery),
};
})
.filter((model) => model.searchMatches)
.sort((a, b) => {
// Sort by search score (highest first), then by label
if (debouncedModelSearchQuery) {
return b.searchScore - a.searchScore;
}
return a.label.localeCompare(b.label);
});
}, [modelList, provider?.name, showFreeModelsOnly, debouncedModelSearchQuery]);
const filteredProviders = useMemo(() => {
if (!debouncedProviderSearchQuery) {
return providerList;
}
return providerList
.map((provider) => {
const match = fuzzyMatch(debouncedProviderSearchQuery, provider.name);
return {
...provider,
searchScore: match.score,
searchMatches: match.matches,
highlightedName: highlightText(provider.name, debouncedProviderSearchQuery),
};
})
.filter((provider) => provider.searchMatches)
.sort((a, b) => b.searchScore - a.searchScore);
}, [providerList, debouncedProviderSearchQuery]);
// Reset free models filter when provider changes
useEffect(() => {
setShowFreeModelsOnly(false);
}, [provider?.name]);
useEffect(() => {
setFocusedModelIndex(-1);
}, [debouncedModelSearchQuery, isModelDropdownOpen, showFreeModelsOnly]);
useEffect(() => {
setFocusedProviderIndex(-1);
}, [debouncedProviderSearchQuery, isProviderDropdownOpen]);
// Clear search functions
const clearModelSearch = useCallback(() => {
setModelSearchQuery('');
setDebouncedModelSearchQuery('');
if (modelSearchInputRef.current) {
modelSearchInputRef.current.focus();
}
}, []);
const clearProviderSearch = useCallback(() => {
setProviderSearchQuery('');
setDebouncedProviderSearchQuery('');
if (providerSearchInputRef.current) {
providerSearchInputRef.current.focus();
}
}, []);
useEffect(() => {
if (isModelDropdownOpen && modelSearchInputRef.current) {
modelSearchInputRef.current.focus();
}
}, [isModelDropdownOpen]);
useEffect(() => {
if (isProviderDropdownOpen && providerSearchInputRef.current) {
providerSearchInputRef.current.focus();
}
}, [isProviderDropdownOpen]);
const handleModelKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (!isModelDropdownOpen) {
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setFocusedModelIndex((prev) => (prev + 1 >= filteredModels.length ? 0 : prev + 1));
break;
case 'ArrowUp':
e.preventDefault();
setFocusedModelIndex((prev) => (prev - 1 < 0 ? filteredModels.length - 1 : prev - 1));
break;
case 'Enter':
e.preventDefault();
if (focusedModelIndex >= 0 && focusedModelIndex < filteredModels.length) {
const selectedModel = filteredModels[focusedModelIndex];
setModel?.(selectedModel.name);
setIsModelDropdownOpen(false);
setModelSearchQuery('');
setDebouncedModelSearchQuery('');
}
break;
case 'Escape':
e.preventDefault();
setIsModelDropdownOpen(false);
setModelSearchQuery('');
setDebouncedModelSearchQuery('');
break;
case 'Tab':
if (!e.shiftKey && focusedModelIndex === filteredModels.length - 1) {
setIsModelDropdownOpen(false);
}
break;
case 'k':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
clearModelSearch();
}
break;
}
};
const handleProviderKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (!isProviderDropdownOpen) {
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setFocusedProviderIndex((prev) => (prev + 1 >= filteredProviders.length ? 0 : prev + 1));
break;
case 'ArrowUp':
e.preventDefault();
setFocusedProviderIndex((prev) => (prev - 1 < 0 ? filteredProviders.length - 1 : prev - 1));
break;
case 'Enter':
e.preventDefault();
if (focusedProviderIndex >= 0 && focusedProviderIndex < filteredProviders.length) {
const selectedProvider = filteredProviders[focusedProviderIndex];
if (setProvider) {
setProvider(selectedProvider);
const firstModel = modelList.find((m) => m.provider === selectedProvider.name);
if (firstModel && setModel) {
setModel(firstModel.name);
}
}
setIsProviderDropdownOpen(false);
setProviderSearchQuery('');
setDebouncedProviderSearchQuery('');
}
break;
case 'Escape':
e.preventDefault();
setIsProviderDropdownOpen(false);
setProviderSearchQuery('');
setDebouncedProviderSearchQuery('');
break;
case 'Tab':
if (!e.shiftKey && focusedProviderIndex === filteredProviders.length - 1) {
setIsProviderDropdownOpen(false);
}
break;
case 'k':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
clearProviderSearch();
}
break;
}
};
useEffect(() => {
if (focusedModelIndex >= 0 && modelOptionsRef.current[focusedModelIndex]) {
modelOptionsRef.current[focusedModelIndex]?.scrollIntoView({ block: 'nearest' });
}
}, [focusedModelIndex]);
useEffect(() => {
if (focusedProviderIndex >= 0 && providerOptionsRef.current[focusedProviderIndex]) {
providerOptionsRef.current[focusedProviderIndex]?.scrollIntoView({ block: 'nearest' });
}
}, [focusedProviderIndex]);
useEffect(() => {
if (providerList.length === 0) {
return;
}
if (provider && !providerList.some((p) => p.name === provider.name)) {
const firstEnabledProvider = providerList[0];
setProvider?.(firstEnabledProvider);
const firstModel = modelList.find((m) => m.provider === firstEnabledProvider.name);
if (firstModel) {
setModel?.(firstModel.name);
}
}
}, [providerList, provider, setProvider, modelList, setModel]);
if (providerList.length === 0) {
return (
<div className="mb-2 p-4 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary">
<p className="text-center">
No providers are currently enabled. Please enable at least one provider in the settings to start using the
chat.
</p>
</div>
);
}
return (
<div className="flex gap-2 flex-col sm:flex-row">
{/* Provider Combobox */}
<div className="relative flex w-full" onKeyDown={handleProviderKeyDown} ref={providerDropdownRef}>
<div
className={classNames(
'w-full p-2 rounded-lg border border-bolt-elements-borderColor',
'bg-bolt-elements-prompt-background text-bolt-elements-textPrimary',
'focus-within:outline-none focus-within:ring-2 focus-within:ring-bolt-elements-focus',
'transition-all cursor-pointer',
isProviderDropdownOpen ? 'ring-2 ring-bolt-elements-focus' : undefined,
)}
onClick={() => setIsProviderDropdownOpen(!isProviderDropdownOpen)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsProviderDropdownOpen(!isProviderDropdownOpen);
}
}}
role="combobox"
aria-expanded={isProviderDropdownOpen}
aria-controls="provider-listbox"
aria-haspopup="listbox"
tabIndex={0}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 truncate">
{provider?.name && LOCAL_PROVIDERS.includes(provider.name) && (
<span
className={classNames(
'inline-block w-2 h-2 rounded-full flex-shrink-0',
localProviderStatus[provider.name] === 'connected'
? 'bg-green-500'
: localProviderStatus[provider.name] === 'disconnected'
? 'bg-red-400'
: 'bg-bolt-elements-textTertiary',
)}
title={
localProviderStatus[provider.name] === 'connected'
? `${provider.name} is running`
: localProviderStatus[provider.name] === 'disconnected'
? `${provider.name} is not reachable`
: 'Checking...'
}
/>
)}
{provider?.name || 'Select provider'}
</div>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary opacity-75',
isProviderDropdownOpen ? 'rotate-180' : undefined,
)}
/>
</div>
</div>
{isProviderDropdownOpen && (
<div
className="absolute z-20 w-full mt-1 py-1 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 shadow-lg"
role="listbox"
id="provider-listbox"
>
<div className="px-2 pb-2">
<div className="relative">
<input
ref={providerSearchInputRef}
type="text"
value={providerSearchQuery}
onChange={(e) => setProviderSearchQuery(e.target.value)}
placeholder="Search providers... (⌘K to clear)"
className={classNames(
'w-full pl-8 pr-8 py-1.5 rounded-md text-sm',
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus',
'transition-all',
)}
onClick={(e) => e.stopPropagation()}
role="searchbox"
aria-label="Search providers"
/>
<div className="absolute left-2.5 top-1/2 -translate-y-1/2">
<span className="i-ph:magnifying-glass text-bolt-elements-textTertiary" />
</div>
{providerSearchQuery && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
clearProviderSearch();
}}
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-bolt-elements-background-depth-3 transition-colors"
aria-label="Clear search"
>
<span className="i-ph:x text-bolt-elements-textTertiary text-xs" />
</button>
)}
</div>
</div>
<div
className={classNames(
'max-h-60 overflow-y-auto',
'sm:scrollbar-none',
'[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2',
'[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor',
'[&::-webkit-scrollbar-thumb]:hover:bg-bolt-elements-borderColorHover',
'[&::-webkit-scrollbar-thumb]:rounded-full',
'[&::-webkit-scrollbar-track]:bg-bolt-elements-background-depth-2',
'[&::-webkit-scrollbar-track]:rounded-full',
'sm:[&::-webkit-scrollbar]:w-1.5 sm:[&::-webkit-scrollbar]:h-1.5',
'sm:hover:[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor/50',
'sm:hover:[&::-webkit-scrollbar-thumb:hover]:bg-bolt-elements-borderColor',
'sm:[&::-webkit-scrollbar-track]:bg-transparent',
)}
>
{filteredProviders.length === 0 ? (
<div className="px-3 py-3 text-sm">
<div className="text-bolt-elements-textTertiary mb-1">
{debouncedProviderSearchQuery
? `No providers match "${debouncedProviderSearchQuery}"`
: 'No providers found'}
</div>
{debouncedProviderSearchQuery && (
<div className="text-xs text-bolt-elements-textTertiary">
Try searching for provider names like "OpenAI", "Anthropic", or "Google"
</div>
)}
</div>
) : (
filteredProviders.map((providerOption, index) => (
<div
ref={(el) => (providerOptionsRef.current[index] = el)}
key={providerOption.name}
role="option"
aria-selected={provider?.name === providerOption.name}
className={classNames(
'px-3 py-2 text-sm cursor-pointer',
'hover:bg-bolt-elements-background-depth-3',
'text-bolt-elements-textPrimary',
'outline-none',
provider?.name === providerOption.name || focusedProviderIndex === index
? 'bg-bolt-elements-background-depth-2'
: undefined,
focusedProviderIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined,
)}
onClick={(e) => {
e.stopPropagation();
if (setProvider) {
setProvider(providerOption);
const firstModel = modelList.find((m) => m.provider === providerOption.name);
if (firstModel && setModel) {
setModel(firstModel.name);
}
}
setIsProviderDropdownOpen(false);
setProviderSearchQuery('');
setDebouncedProviderSearchQuery('');
}}
tabIndex={focusedProviderIndex === index ? 0 : -1}
>
<div className="flex items-center gap-2">
{LOCAL_PROVIDERS.includes(providerOption.name) && (
<span
className={classNames(
'inline-block w-2 h-2 rounded-full flex-shrink-0',
localProviderStatus[providerOption.name] === 'connected'
? 'bg-green-500'
: localProviderStatus[providerOption.name] === 'disconnected'
? 'bg-red-400'
: 'bg-bolt-elements-textTertiary',
)}
/>
)}
<span
dangerouslySetInnerHTML={{
__html: (providerOption as any).highlightedName || providerOption.name,
}}
/>
</div>
</div>
))
)}
</div>
</div>
)}
</div>
{/* Model Combobox */}
<div className="relative flex w-full min-w-[70%]" onKeyDown={handleModelKeyDown} ref={modelDropdownRef}>
<div
className={classNames(
'w-full p-2 rounded-lg border border-bolt-elements-borderColor',
'bg-bolt-elements-prompt-background text-bolt-elements-textPrimary',
'focus-within:outline-none focus-within:ring-2 focus-within:ring-bolt-elements-focus',
'transition-all cursor-pointer',
isModelDropdownOpen ? 'ring-2 ring-bolt-elements-focus' : undefined,
)}
onClick={() => setIsModelDropdownOpen(!isModelDropdownOpen)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsModelDropdownOpen(!isModelDropdownOpen);
}
}}
role="combobox"
aria-expanded={isModelDropdownOpen}
aria-controls="model-listbox"
aria-haspopup="listbox"
tabIndex={0}
>
<div className="flex items-center justify-between">
<div className="truncate">{modelList.find((m) => m.name === model)?.label || 'Select model'}</div>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary opacity-75',
isModelDropdownOpen ? 'rotate-180' : undefined,
)}
/>
</div>
</div>
{isModelDropdownOpen && (
<div
className="absolute z-10 w-full mt-1 py-1 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 shadow-lg"
role="listbox"
id="model-listbox"
>
<div className="px-2 pb-2 space-y-2">
{/* Free Models Filter Toggle - Only show for OpenRouter */}
{provider?.name === 'OpenRouter' && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowFreeModelsOnly(!showFreeModelsOnly);
}}
className={classNames(
'flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-all',
'hover:bg-bolt-elements-background-depth-3',
showFreeModelsOnly
? 'bg-purple-500/20 text-purple-400 border border-purple-500/30'
: 'bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary border border-bolt-elements-borderColor',
)}
>
<span className="i-ph:gift text-xs" />
Free models only
</button>
{showFreeModelsOnly && (
<span className="text-xs text-bolt-elements-textTertiary">
{filteredModels.length} free model{filteredModels.length !== 1 ? 's' : ''}
</span>
)}
</div>
)}
{/* Search Result Count */}
{debouncedModelSearchQuery && filteredModels.length > 0 && (
<div className="text-xs text-bolt-elements-textTertiary px-1">
{filteredModels.length} model{filteredModels.length !== 1 ? 's' : ''} found
{filteredModels.length > 5 && ' (showing best matches)'}
</div>
)}
{/* Search Input */}
<div className="relative">
<input
ref={modelSearchInputRef}
type="text"
value={modelSearchQuery}
onChange={(e) => setModelSearchQuery(e.target.value)}
placeholder="Search models... (⌘K to clear)"
className={classNames(
'w-full pl-8 pr-8 py-1.5 rounded-md text-sm',
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus',
'transition-all',
)}
onClick={(e) => e.stopPropagation()}
role="searchbox"
aria-label="Search models"
/>
<div className="absolute left-2.5 top-1/2 -translate-y-1/2">
<span className="i-ph:magnifying-glass text-bolt-elements-textTertiary" />
</div>
{modelSearchQuery && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
clearModelSearch();
}}
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-bolt-elements-background-depth-3 transition-colors"
aria-label="Clear search"
>
<span className="i-ph:x text-bolt-elements-textTertiary text-xs" />
</button>
)}
</div>
</div>
<div
className={classNames(
'max-h-60 overflow-y-auto',
'sm:scrollbar-none',
'[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2',
'[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor',
'[&::-webkit-scrollbar-thumb]:hover:bg-bolt-elements-borderColorHover',
'[&::-webkit-scrollbar-thumb]:rounded-full',
'[&::-webkit-scrollbar-track]:bg-bolt-elements-background-depth-2',
'[&::-webkit-scrollbar-track]:rounded-full',
'sm:[&::-webkit-scrollbar]:w-1.5 sm:[&::-webkit-scrollbar]:h-1.5',
'sm:hover:[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor/50',
'sm:hover:[&::-webkit-scrollbar-thumb:hover]:bg-bolt-elements-borderColor',
'sm:[&::-webkit-scrollbar-track]:bg-transparent',
)}
>
{modelLoading === 'all' || modelLoading === provider?.name ? (
<div className="px-3 py-3 text-sm">
<div className="flex items-center gap-2 text-bolt-elements-textTertiary">
<span className="i-ph:spinner animate-spin" />
Loading models...
</div>
</div>
) : filteredModels.length === 0 ? (
<div className="px-3 py-3 text-sm">
<div className="text-bolt-elements-textTertiary mb-1">
{debouncedModelSearchQuery
? `No models match "${debouncedModelSearchQuery}"${showFreeModelsOnly ? ' (free only)' : ''}`
: showFreeModelsOnly
? 'No free models available'
: provider?.name && LOCAL_PROVIDERS.includes(provider.name)
? `No models found — is ${provider.name} running?`
: 'No models available'}
</div>
{!debouncedModelSearchQuery && provider?.name && LOCAL_PROVIDERS.includes(provider.name) && (
<div className="text-xs text-bolt-elements-textTertiary mt-1">
Make sure {provider.name} is running and has at least one model loaded.
{provider.name === 'Ollama' && ' Try: ollama pull llama3.2'}
{provider.name === 'LMStudio' && ' Load a model in LM Studio first.'}
</div>
)}
{debouncedModelSearchQuery && (
<div className="text-xs text-bolt-elements-textTertiary">
Try searching for model names, context sizes (e.g., "128k", "1M"), or capabilities
</div>
)}
{showFreeModelsOnly && !debouncedModelSearchQuery && (
<div className="text-xs text-bolt-elements-textTertiary">
Try disabling the "Free models only" filter to see all available models
</div>
)}
</div>
) : (
filteredModels.map((modelOption, index) => (
<div
ref={(el) => (modelOptionsRef.current[index] = el)}
key={modelOption.name}
role="option"
aria-selected={model === modelOption.name}
className={classNames(
'px-3 py-2 text-sm cursor-pointer',
'hover:bg-bolt-elements-background-depth-3',
'text-bolt-elements-textPrimary',
'outline-none',
model === modelOption.name || focusedModelIndex === index
? 'bg-bolt-elements-background-depth-2'
: undefined,
focusedModelIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined,
)}
onClick={(e) => {
e.stopPropagation();
setModel?.(modelOption.name);
setIsModelDropdownOpen(false);
setModelSearchQuery('');
setDebouncedModelSearchQuery('');
}}
tabIndex={focusedModelIndex === index ? 0 : -1}
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="truncate">
<span
dangerouslySetInnerHTML={{
__html: (modelOption as any).highlightedLabel || modelOption.label,
}}
/>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-bolt-elements-textTertiary">
{formatContextSize(modelOption.maxTokenAllowed)} tokens
</span>
{debouncedModelSearchQuery && (modelOption as any).searchScore > 70 && (
<span className="text-xs text-green-500 font-medium">
{(modelOption as any).searchScore.toFixed(0)}% match
</span>
)}
</div>
</div>
<div className="flex items-center gap-1 ml-2">
{isModelLikelyFree(modelOption, provider?.name) && (
<span className="i-ph:gift text-xs text-purple-400" title="Free model" />
)}
{model === modelOption.name && (
<span className="i-ph:check text-xs text-green-500" title="Selected" />
)}
</div>
</div>
</div>
))
)}
</div>
</div>
)}
</div>
</div>
);
};