fix: custom modal handeling

This commit is contained in:
Vukasin 2025-05-22 14:55:30 +02:00
parent d7bb27c73a
commit b16a452920
4 changed files with 444 additions and 310 deletions

View File

@ -1067,7 +1067,7 @@ export default function ThreadPage({
if (initialLoadCompleted.current && subscriptionData) {
const hasSeenUpgradeDialog = localStorage.getItem('suna_upgrade_dialog_displayed');
const isFreeTier = subscriptionStatus === 'no_subscription';
if (!hasSeenUpgradeDialog && isFreeTier) {
if (!hasSeenUpgradeDialog && isFreeTier && !isLocalMode()) {
setShowUpgradeDialog(true);
}
}

View File

@ -7,7 +7,7 @@ import { useAvailableModels } from '@/hooks/react-query/subscriptions/use-model'
export const STORAGE_KEY_MODEL = 'suna-preferred-model';
export const STORAGE_KEY_CUSTOM_MODELS = 'customModels';
export const DEFAULT_FREE_MODEL_ID = 'deepseek';
export const DEFAULT_FREE_MODEL_ID = 'qwen3';
export const DEFAULT_PREMIUM_MODEL_ID = 'sonnet-3.7';
export type SubscriptionStatus = 'no_subscription' | 'active';
@ -19,6 +19,7 @@ export interface ModelOption {
description?: string;
top?: boolean;
isCustom?: boolean;
priority?: number;
}
export interface CustomModel {
@ -26,20 +27,156 @@ export interface CustomModel {
label: string;
}
const MODEL_DESCRIPTIONS: Record<string, string> = {
'sonnet-3.7': 'Claude 3.7 Sonnet - Anthropic\'s powerful general-purpose AI assistant',
'gpt-4.1': 'GPT-4.1 - OpenAI\'s most advanced model with enhanced reasoning',
'gpt-4o': 'GPT-4o - Optimized for speed, reliability, and cost-effectiveness',
'gpt-4-turbo': 'GPT-4 Turbo - OpenAI\'s powerful model with a great balance of performance and cost',
'gpt-4': 'GPT-4 - OpenAI\'s highly capable model with advanced reasoning',
'gemini-flash-2.5': 'Gemini Flash 2.5 - Google\'s fast, responsive AI model',
'grok-3': 'Grok-3 - xAI\'s latest large language model with enhanced capabilities',
'deepseek': 'DeepSeek - Free tier model with good general capabilities',
'deepseek-r1': 'DeepSeek R1 - Advanced model with enhanced reasoning and coding capabilities',
'grok-3-mini': 'Grok-3 Mini - Smaller, faster version of Grok-3 for simpler tasks',
'qwen3': 'Qwen3 - Alibaba\'s powerful multilingual language model'
// SINGLE SOURCE OF TRUTH for all model data
export const MODELS = {
// Premium high-priority models
'sonnet-3.7': {
tier: 'premium',
priority: 100,
recommended: true,
lowQuality: false,
description: 'Claude 3.7 Sonnet - Anthropic\'s powerful general-purpose AI assistant'
},
'claude-3.7': {
tier: 'premium',
priority: 100,
recommended: true,
lowQuality: false,
description: 'Claude 3.7 - Anthropic\'s most powerful AI assistant'
},
'claude-3.7-reasoning': {
tier: 'premium',
priority: 100,
recommended: true,
lowQuality: false,
description: 'Claude 3.7 with enhanced reasoning capabilities'
},
'gpt-4.1': {
tier: 'premium',
priority: 95,
recommended: true,
lowQuality: false,
description: 'GPT-4.1 - OpenAI\'s most advanced model with enhanced reasoning'
},
'gemini-2.5-pro-preview': {
tier: 'premium',
priority: 95,
recommended: true,
lowQuality: false,
description: 'Gemini Pro 2.5 - Google\'s latest powerful model with strong reasoning'
},
'gemini-2.5-pro': {
tier: 'premium',
priority: 95,
recommended: true,
lowQuality: false,
description: 'Gemini Pro 2.5 - Google\'s latest advanced model'
},
'claude-3.5': {
tier: 'premium',
priority: 90,
recommended: true,
lowQuality: false,
description: 'Claude 3.5 - Anthropic\'s balanced model with solid capabilities'
},
'gemini-2.5': {
tier: 'premium',
priority: 90,
recommended: true,
lowQuality: false,
description: 'Gemini 2.5 - Google\'s powerful versatile model'
},
'gemini-2.5-flash-preview': {
tier: 'premium',
priority: 90,
recommended: true,
lowQuality: false,
description: 'Gemini Flash 2.5 - Google\'s fast, responsive AI model'
},
'gpt-4o': {
tier: 'premium',
priority: 85,
recommended: false,
lowQuality: false,
description: 'GPT-4o - Optimized for speed, reliability, and cost-effectiveness'
},
'gpt-4-turbo': {
tier: 'premium',
priority: 85,
recommended: false,
lowQuality: false,
description: 'GPT-4 Turbo - OpenAI\'s powerful model with a great balance of performance and cost'
},
'gpt-4': {
tier: 'premium',
priority: 80,
recommended: false,
lowQuality: false,
description: 'GPT-4 - OpenAI\'s highly capable model with advanced reasoning'
},
'deepseek-chat-v3-0324': {
tier: 'premium',
priority: 75,
recommended: true,
lowQuality: false,
description: 'DeepSeek Chat - Advanced AI assistant with strong reasoning'
},
// Free tier models
'deepseek-r1': {
tier: 'free',
priority: 60,
recommended: false,
lowQuality: false,
description: 'DeepSeek R1 - Advanced model with enhanced reasoning and coding capabilities'
},
'deepseek': {
tier: 'free',
priority: 50,
recommended: false,
lowQuality: true,
description: 'DeepSeek - Free tier model with good general capabilities'
},
'google/gemini-2.5-flash-preview': {
tier: 'free',
priority: 50,
recommended: false,
lowQuality: true,
description: 'Gemini Flash - Google\'s faster, more efficient model'
},
'grok-3-mini': {
tier: 'free',
priority: 45,
recommended: false,
lowQuality: true,
description: 'Grok-3 Mini - Smaller, faster version of Grok-3 for simpler tasks'
},
'qwen3': {
tier: 'free',
priority: 40,
recommended: false,
lowQuality: true,
description: 'Qwen3 - Alibaba\'s powerful multilingual language model'
},
};
// Model tier definitions
export const MODEL_TIERS = {
premium: {
requiresSubscription: true,
baseDescription: 'Advanced model with superior capabilities'
},
free: {
requiresSubscription: false,
baseDescription: 'Available to all users'
},
custom: {
requiresSubscription: false,
baseDescription: 'User-defined model'
}
};
// Helper to check if a user can access a model based on subscription status
export const canAccessModel = (
subscriptionStatus: SubscriptionStatus,
requiresSubscription: boolean,
@ -67,8 +204,8 @@ export const getPrefixedModelId = (modelId: string, isCustom: boolean): string =
};
// Helper to get custom models from localStorage
export const getCustomModels = () => {
if (!isLocalMode()) return [];
export const getCustomModels = (): CustomModel[] => {
if (!isLocalMode() || typeof window === 'undefined') return [];
try {
const storedModels = localStorage.getItem(STORAGE_KEY_CUSTOM_MODELS);
@ -77,32 +214,31 @@ export const getCustomModels = () => {
const parsedModels = JSON.parse(storedModels);
if (!Array.isArray(parsedModels)) return [];
// Ensure all custom models have openrouter/ prefix
return parsedModels
.filter((model: any) =>
model && typeof model === 'object' &&
typeof model.id === 'string' &&
typeof model.label === 'string')
.map((model: any) => ({
...model,
id: model.id.startsWith('openrouter/') ? model.id : `openrouter/${model.id}`
}));
typeof model.label === 'string');
} catch (e) {
console.error('Error parsing custom models:', e);
return [];
}
};
// Helper to save model preference to localStorage safely
const saveModelPreference = (modelId: string): void => {
try {
localStorage.setItem(STORAGE_KEY_MODEL, modelId);
} catch (error) {
console.warn('Failed to save model preference to localStorage:', error);
}
};
export const useModelSelection = () => {
const [selectedModel, setSelectedModel] = useState(DEFAULT_FREE_MODEL_ID);
const [customModels, setCustomModels] = useState<CustomModel[]>([]);
const { data: subscriptionData } = useSubscription();
//MOCK
// const subscriptionData = {
// status: 'no_subscription'
// };
const { data: modelsData, isLoading: isLoadingModels } = useAvailableModels({
refetchOnMount: false,
});
@ -114,41 +250,39 @@ export const useModelSelection = () => {
// Load custom models from localStorage
useEffect(() => {
if (isLocalMode() && typeof window !== 'undefined') {
const savedCustomModels = localStorage.getItem(STORAGE_KEY_CUSTOM_MODELS);
if (savedCustomModels) {
try {
setCustomModels(JSON.parse(savedCustomModels));
} catch (e) {
console.error('Failed to parse custom models from localStorage', e);
}
}
setCustomModels(getCustomModels());
}
}, []);
// Generate model options list with consistent structure
const MODEL_OPTIONS = useMemo(() => {
let baseModels = [];
let models = [];
// Default models if API data not available
if (!modelsData?.models || isLoadingModels) {
baseModels = [
models = [
{
id: DEFAULT_FREE_MODEL_ID,
label: 'DeepSeek',
label: 'Qwen3',
requiresSubscription: false,
description: 'Free tier model with good general capabilities.'
description: MODELS[DEFAULT_FREE_MODEL_ID]?.description || MODEL_TIERS.free.baseDescription,
priority: MODELS[DEFAULT_FREE_MODEL_ID]?.priority || 50
},
{
id: DEFAULT_PREMIUM_MODEL_ID,
label: 'Claude 3.7 Sonnet',
label: 'Sonnet 3.7',
requiresSubscription: true,
description: 'Anthropic\'s powerful general-purpose AI assistant.'
description: MODELS[DEFAULT_PREMIUM_MODEL_ID]?.description || MODEL_TIERS.premium.baseDescription,
priority: MODELS[DEFAULT_PREMIUM_MODEL_ID]?.priority || 100
},
];
} else {
const topModels = ['sonnet-3.7', 'gemini-flash-2.5'];
baseModels = modelsData.models.map(model => {
// Process API-provided models
models = modelsData.models.map(model => {
const shortName = model.short_name || model.id;
const displayName = model.display_name || shortName;
// Format the display label
let cleanLabel = displayName;
if (cleanLabel.includes('/')) {
cleanLabel = cleanLabel.split('/').pop() || cleanLabel;
@ -160,16 +294,20 @@ export const useModelSelection = () => {
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
const isPremium = model?.requires_subscription || false;
// Get model data from our central MODELS constant
const modelData = MODELS[shortName] || {};
const isPremium = model?.requires_subscription || modelData.tier === 'premium' || false;
return {
id: shortName,
label: cleanLabel,
requiresSubscription: isPremium,
description: MODEL_DESCRIPTIONS[shortName] ||
(isPremium
? 'Premium model with advanced capabilities'
: 'Free tier model with good general capabilities'),
top: topModels.includes(shortName)
description: modelData.description ||
(isPremium ? MODEL_TIERS.premium.baseDescription : MODEL_TIERS.free.baseDescription),
top: modelData.priority >= 90, // Mark high-priority models as "top"
priority: modelData.priority || 0,
lowQuality: modelData.lowQuality || false,
recommended: modelData.recommended || false
};
});
}
@ -180,97 +318,38 @@ export const useModelSelection = () => {
id: model.id,
label: model.label || formatModelName(model.id),
requiresSubscription: false,
description: 'Custom model',
description: MODEL_TIERS.custom.baseDescription,
top: false,
isCustom: true
isCustom: true,
priority: 30, // Low priority by default
lowQuality: false,
recommended: false
}));
return [...baseModels, ...customModelOptions];
models = [...models, ...customModelOptions];
}
return baseModels;
// Sort models consistently in one place:
// 1. First by free/premium (free first)
// 2. Then by priority (higher first)
// 3. Finally by name (alphabetical)
return models.sort((a, b) => {
// First by free/premium status
if (a.requiresSubscription !== b.requiresSubscription) {
return a.requiresSubscription ? 1 : -1;
}
// Then by priority (higher first)
if (a.priority !== b.priority) {
return b.priority - a.priority;
}
// Finally by name
return a.label.localeCompare(b.label);
});
}, [modelsData, isLoadingModels, customModels]);
useEffect(() => {
if (typeof window === 'undefined') return;
try {
const savedModel = localStorage.getItem(STORAGE_KEY_MODEL);
if (isLocalMode()) {
if (savedModel && MODEL_OPTIONS.find(option => option.id === savedModel)) {
setSelectedModel(savedModel);
} else {
setSelectedModel(DEFAULT_PREMIUM_MODEL_ID);
try {
localStorage.setItem(STORAGE_KEY_MODEL, DEFAULT_PREMIUM_MODEL_ID);
} catch (error) {
console.warn('Failed to save model preference to localStorage:', error);
}
}
return;
}
if (subscriptionStatus === 'active') {
if (savedModel) {
const modelOption = MODEL_OPTIONS.find(option => option.id === savedModel);
if (modelOption && canAccessModel(subscriptionStatus, modelOption.requiresSubscription)) {
setSelectedModel(savedModel);
return;
}
}
setSelectedModel(DEFAULT_PREMIUM_MODEL_ID);
try {
localStorage.setItem(STORAGE_KEY_MODEL, DEFAULT_PREMIUM_MODEL_ID);
} catch (error) {
console.warn('Failed to save model preference to localStorage:', error);
}
}
else if (savedModel) {
const modelOption = MODEL_OPTIONS.find(option => option.id === savedModel);
if (modelOption && canAccessModel(subscriptionStatus, modelOption.requiresSubscription)) {
setSelectedModel(savedModel);
} else {
localStorage.removeItem(STORAGE_KEY_MODEL);
setSelectedModel(DEFAULT_FREE_MODEL_ID);
}
}
else {
setSelectedModel(DEFAULT_FREE_MODEL_ID);
}
} catch (error) {
console.warn('Failed to load preferences from localStorage:', error);
}
}, [subscriptionStatus, MODEL_OPTIONS]);
const handleModelChange = (modelId: string) => {
const modelOption = MODEL_OPTIONS.find(option => option.id === modelId);
// Check if it's a custom model (in local storage)
let isCustomModel = false;
if (isLocalMode() && !modelOption) {
isCustomModel = getCustomModels().some(model => model.id === modelId);
}
// Only return early if it's not a custom model and not in standard options
if (!modelOption && !isCustomModel) {
console.warn('Model not found in options:', modelId);
return;
}
// For standard models, check access permissions
if (!isCustomModel && !isLocalMode() && !canAccessModel(subscriptionStatus, modelOption?.requiresSubscription ?? false)) {
return;
}
setSelectedModel(modelId);
try {
localStorage.setItem(STORAGE_KEY_MODEL, modelId);
} catch (error) {
console.warn('Failed to save model preference to localStorage:', error);
}
};
// Get filtered list of models the user can access (no additional sorting)
const availableModels = useMemo(() => {
return isLocalMode()
? MODEL_OPTIONS
@ -279,35 +358,97 @@ export const useModelSelection = () => {
);
}, [MODEL_OPTIONS, subscriptionStatus]);
// Initialize selected model from localStorage or defaults
useEffect(() => {
if (typeof window === 'undefined') return;
try {
const savedModel = localStorage.getItem(STORAGE_KEY_MODEL);
// Local mode - allow any model
if (isLocalMode()) {
if (savedModel && MODEL_OPTIONS.find(option => option.id === savedModel)) {
setSelectedModel(savedModel);
} else {
setSelectedModel(DEFAULT_PREMIUM_MODEL_ID);
saveModelPreference(DEFAULT_PREMIUM_MODEL_ID);
}
return;
}
// Premium subscription - ALWAYS use premium model
if (subscriptionStatus === 'active') {
// If they had a premium model saved and it's still valid, use it
const hasSavedPremiumModel = savedModel &&
MODEL_OPTIONS.find(option =>
option.id === savedModel &&
option.requiresSubscription &&
canAccessModel(subscriptionStatus, true)
);
// Otherwise use the default premium model
if (hasSavedPremiumModel) {
setSelectedModel(savedModel!);
} else {
setSelectedModel(DEFAULT_PREMIUM_MODEL_ID);
saveModelPreference(DEFAULT_PREMIUM_MODEL_ID);
}
return;
}
// No subscription - use saved model if accessible (free tier), or default free
if (savedModel) {
const modelOption = MODEL_OPTIONS.find(option => option.id === savedModel);
if (modelOption && canAccessModel(subscriptionStatus, modelOption.requiresSubscription)) {
setSelectedModel(savedModel);
} else {
setSelectedModel(DEFAULT_FREE_MODEL_ID);
saveModelPreference(DEFAULT_FREE_MODEL_ID);
}
} else {
setSelectedModel(DEFAULT_FREE_MODEL_ID);
saveModelPreference(DEFAULT_FREE_MODEL_ID);
}
} catch (error) {
console.warn('Failed to load preferences from localStorage:', error);
setSelectedModel(DEFAULT_FREE_MODEL_ID);
}
}, [subscriptionStatus, MODEL_OPTIONS]);
// Handle model selection change
const handleModelChange = (modelId: string) => {
const modelOption = MODEL_OPTIONS.find(option => option.id === modelId);
const isCustomModel = isLocalMode() && customModels.some(model => model.id === modelId);
// Check if model exists
if (!modelOption && !isCustomModel) {
console.warn('Model not found in options:', modelId);
return;
}
// Check access permissions (except for custom models in local mode)
if (!isCustomModel && !isLocalMode() &&
!canAccessModel(subscriptionStatus, modelOption?.requiresSubscription ?? false)) {
return;
}
setSelectedModel(modelId);
saveModelPreference(modelId);
};
// Get the actual model ID to send to the backend
const getActualModelId = (modelId: string): string => {
// First, check if this is a standard model directly
const model = MODEL_OPTIONS.find(m => m.id === modelId);
// If it's a standard model, use its ID as is
if (model && !model.isCustom) {
return modelId;
}
// For custom models, ensure they have the openrouter/ prefix
if (model?.isCustom || isLocalMode() && getCustomModels().some(m => m.id === modelId)) {
return modelId.startsWith('openrouter/') ? modelId : `openrouter/${modelId}`;
}
// Default fallback
// No need for automatic prefixing in most cases - just return as is
return modelId;
};
return {
selectedModel,
setSelectedModel: (modelId: string) => {
handleModelChange(modelId);
},
setSelectedModel: handleModelChange,
subscriptionStatus,
availableModels,
allModels: MODEL_OPTIONS,
allModels: MODEL_OPTIONS, // Already pre-sorted
customModels,
// This is the model ID that should be sent to the backend
getActualModelId,
canAccessModel: (modelId: string) => {
if (isLocalMode()) return true;
@ -319,3 +460,5 @@ export const useModelSelection = () => {
}
};
};
// Export the hook but not any sorting logic - sorting is handled internally

View File

@ -70,24 +70,25 @@ export const CustomModelDialog: React.FC<CustomModelDialogProps> = ({
}}
>
<DialogContent
className="sm:max-w-[425px]"
className="sm:max-w-[430px]"
onEscapeKeyDown={handleClose}
onPointerDownOutside={handleClose}
>
<DialogHeader>
<DialogTitle>{mode === 'add' ? 'Add Custom Model' : 'Edit Custom Model'}</DialogTitle>
<DialogDescription>
Enter the model ID for your custom model. The display name will be auto-generated.
Enter the model ID (use <code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">openrouter/</code> prefix for OpenRouter models). See <a href="https://docs.litellm.ai/docs/" target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:text-blue-600 underline">LiteLLM docs</a>; you may need to modify <code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">llm.py</code>.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex flex-col gap-4 py-4">
<div className="flex flex-col items-start gap-4">
<Label htmlFor="modelId" className="text-right">
Model ID
</Label>
<Input
id="modelId"
placeholder="e.g. gpt-4-vision"
placeholder="e.g. openrouter/meta-llama/llama-4-maverick"
value={formData.id}
onChange={handleChange}
className="col-span-3"

View File

@ -23,7 +23,8 @@ import {
DEFAULT_FREE_MODEL_ID,
DEFAULT_PREMIUM_MODEL_ID,
formatModelName,
getCustomModels
getCustomModels,
MODELS // Import the centralized MODELS constant
} from './_use-model-selection';
import { PaywallDialog } from '@/components/payment/paywall-dialog';
import { cn } from '@/lib/utils';
@ -31,29 +32,12 @@ import { useRouter } from 'next/navigation';
import { isLocalMode } from '@/lib/config';
import { CustomModelDialog, CustomModelFormData } from './custom-model-dialog';
// Model capabilities map
const MODEL_CAPABILITIES = {
'gpt-4o': { recommended: false, lowQuality: false },
'sonnet-3.7': { recommended: true, lowQuality: false },
'qwen3': { recommended: false, lowQuality: true },
'deepseek': { recommended: false, lowQuality: true },
'gemini-2.5': { recommended: true, lowQuality: false },
'gemini-2.5-flash-preview': { recommended: true, lowQuality: false },
'gemini-2.5-pro-preview': { recommended: true, lowQuality: false },
'deepseek-chat-v3-0324': { recommended: true, lowQuality: false },
'google/gemini-2.5-flash-preview': { recommended: false, lowQuality: true },
'claude-3.5': { recommended: true, lowQuality: false },
'claude-3.7': { recommended: true, lowQuality: false },
'claude-3.7-reasoning': { recommended: true, lowQuality: false },
'gemini-2.5-pro': { recommended: true, lowQuality: false },
'grok-3-mini': { recommended: false, lowQuality: true },
};
interface CustomModel {
id: string;
label: string;
}
interface ModelSelectorProps {
selectedModel: string;
onModelChange: (modelId: string) => void;
@ -101,50 +85,54 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
// Get current custom models from state
const currentCustomModels = customModels || [];
// Enhance model options with capabilities
const enhancedModelOptions = [...modelOptions, ...currentCustomModels
// Only add custom models that aren't already in modelOptions
.filter(model => !modelOptions.some(m => m.id === model.id))
.map(model => ({
// Enhance model options with capabilities - using a Map to ensure uniqueness
const modelMap = new Map();
// First add all standard models to the map
modelOptions.forEach(model => {
modelMap.set(model.id, {
...model,
isCustom: false
});
});
// Then add custom models, overriding any with the same ID
currentCustomModels.forEach(model => {
if (!modelMap.has(model.id)) {
modelMap.set(model.id, {
...model,
requiresSubscription: false,
top: false,
isCustom: true
}))].map(model => {
const baseCapabilities = MODEL_CAPABILITIES[model.id] || { recommended: false, lowQuality: false };
return {
...model,
capabilities: baseCapabilities
};
});
}
});
// Convert map back to array
const enhancedModelOptions = Array.from(modelMap.values());
// Filter models based on search query
const filteredOptions = enhancedModelOptions.filter((opt) =>
opt.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
opt.id.toLowerCase().includes(searchQuery.toLowerCase())
);
// Get free models from modelOptions
// Get free models from modelOptions (helper function)
const getFreeModels = () => modelOptions.filter(m => !m.requiresSubscription).map(m => m.id);
// Sort models - free first, then premium, then by name
const sortedModels = filteredOptions.sort((a, b) => {
// First by free/premium status
const aIsFree = getFreeModels().some(id => a.id.includes(id)) || !a.requiresSubscription;
const bIsFree = getFreeModels().some(id => b.id.includes(id)) || !b.requiresSubscription;
// No sorting needed - models are already sorted in the hook
const sortedModels = filteredOptions;
if (aIsFree !== bIsFree) {
return aIsFree ? -1 : 1;
// Simplified premium models function - just filter without sorting
const getPremiumModels = () => {
return modelOptions
.filter(m => m.requiresSubscription)
.map((m, index) => ({
...m,
uniqueKey: getUniqueModelKey(m, index)
}));
}
// Then by "top" status
if (a.top !== b.top) {
return a.top ? -1 : 1;
}
// Finally by name
return a.label.localeCompare(b.label);
});
// Make sure model IDs are unique for rendering
const getUniqueModelKey = (model: any, index: number): string => {
return `model-${model.id}-${index}`;
@ -170,8 +158,6 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
const selectedLabel =
enhancedModelOptions.find((o) => o.id === selectedModel)?.label || 'Select model';
const isLowQualitySelected = MODEL_CAPABILITIES[selectedModel]?.lowQuality || false;
const handleSelect = (id: string) => {
// Check if it's a custom model
const isCustomModel = customModels.some(model => model.id === id);
@ -240,12 +226,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
const openEditCustomModelDialog = (model: CustomModel, e?: React.MouseEvent) => {
e?.stopPropagation();
// Remove openrouter/ prefix when showing in the edit form
const displayModelId = model.id.startsWith('openrouter/')
? model.id.replace('openrouter/', '')
: model.id;
setDialogInitialData({ id: displayModelId, label: model.label });
setDialogInitialData({ id: model.id, label: model.label });
setEditingModelId(model.id); // Keep the original ID with prefix for reference
setDialogMode('edit');
setIsCustomModelDialogOpen(true);
@ -254,24 +235,12 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
// Handle saving a custom model
const handleSaveCustomModel = (formData: CustomModelFormData) => {
// Ensure modelId is properly formatted
let modelId = formData.id.trim();
// Get model ID without automatically adding prefix
const modelId = formData.id.trim();
// For add mode, always add the prefix if missing
if (dialogMode === 'add') {
if (!modelId.startsWith('openrouter/')) {
modelId = `openrouter/${modelId}`;
}
} else {
// For edit mode, maintain the prefix status of the original ID
const originalHadPrefix = editingModelId?.startsWith('openrouter/') || false;
if (originalHadPrefix && !modelId.startsWith('openrouter/')) {
modelId = `openrouter/${modelId}`;
}
}
const modelLabel = formData.label.trim() || formatModelName(modelId.replace('openrouter/', ''));
// Generate display name based on model ID (remove prefix if present for display name)
const displayId = modelId.startsWith('openrouter/') ? modelId.replace('openrouter/', '') : modelId;
const modelLabel = formData.label.trim() || formatModelName(displayId);
if (!modelId) return;
@ -291,37 +260,43 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
? [...customModels, { id: modelId, label: modelLabel }]
: customModels.map(model => model.id === editingModelId ? { id: modelId, label: modelLabel } : model);
// Update state
setCustomModels(updatedModels);
// Save to storage first
// Save to localStorage first
try {
localStorage.setItem(STORAGE_KEY_CUSTOM_MODELS, JSON.stringify(updatedModels));
} catch (error) {
console.error('Failed to save custom models to localStorage:', error);
}
// Force a UI update cycle then update selection
// IMPORTANT: Use requestAnimationFrame to ensure DOM updates before selection changes
// Update state with new models
setCustomModels(updatedModels);
// Handle model selection changes
if (dialogMode === 'add') {
// Always select newly added models
requestAnimationFrame(() => {
// Direct model change for immediate effect
onModelChange(modelId);
// Also save the selection to localStorage
try {
localStorage.setItem(STORAGE_KEY_MODEL, modelId);
} catch (error) {
console.warn('Failed to save selected model to localStorage:', error);
}
});
} else if (selectedModel === editingModelId) {
// For edits, only update if the edited model was selected
requestAnimationFrame(() => {
onModelChange(modelId);
});
try {
localStorage.setItem(STORAGE_KEY_MODEL, modelId);
} catch (error) {
console.warn('Failed to save selected model to localStorage:', error);
}
}
// Force dropdown to close to ensure fresh data on next open
setIsOpen(false);
// Force a UI refresh by delaying the state update
setTimeout(() => {
setHighlightedIndex(-1);
}, 0);
};
// Handle closing the custom model dialog
@ -344,18 +319,10 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
e?.stopPropagation();
e?.preventDefault();
// Close dropdown first to force a refresh on next open
setIsOpen(false);
// Filter out the model to delete
const updatedCustomModels = customModels.filter(model => model.id !== modelId);
// Update state
setCustomModels(updatedCustomModels);
// Force a UI update by using requestAnimationFrame
requestAnimationFrame(() => {
// Update localStorage directly
// Update localStorage first to ensure data consistency
if (isLocalMode() && typeof window !== 'undefined') {
try {
localStorage.setItem(STORAGE_KEY_CUSTOM_MODELS, JSON.stringify(updatedCustomModels));
@ -364,7 +331,10 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
}
}
// If the deleted model was selected, switch to a default model
// Update state with the new list
setCustomModels(updatedCustomModels);
// Check if we need to change the selected model
if (selectedModel === modelId) {
const defaultModel = isLocalMode() ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID;
onModelChange(defaultModel);
@ -374,19 +344,26 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
console.warn('Failed to update selected model in localStorage:', error);
}
}
});
// Force dropdown to close to ensure proper refresh on next open
setIsOpen(false);
// Force a UI refresh by scheduling a state update after React completes this render cycle
setTimeout(() => {
setIsOpen(false);
}, 0);
};
const renderModelOption = (opt: any, index: number) => {
// Custom models are always accessible in local mode
const isCustomModel = customModels.some(model => model.id === opt.id);
const accessible = isCustomModel ? true : canAccessModel(opt.id);
const isCustom = opt.isCustom || customModels.some(model => model.id === opt.id);
const accessible = isCustom ? true : canAccessModel(opt.id);
// Fix the highlighting logic to use the index parameter instead of searching in filteredOptions
const isHighlighted = index === highlightedIndex;
const isPremium = opt.requiresSubscription;
const isLowQuality = MODEL_CAPABILITIES[opt.id]?.lowQuality || false;
const isRecommended = MODEL_CAPABILITIES[opt.id]?.recommended || false;
const isLowQuality = MODELS[opt.id]?.lowQuality || false;
const isRecommended = MODELS[opt.id]?.recommended || false;
return (
<TooltipProvider key={opt.uniqueKey || `model-${opt.id}-${index}`}>
@ -417,7 +394,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
<Crown className="h-3.5 w-3.5 text-blue-500" />
)}
{/* Custom model actions */}
{isLocalMode() && isCustomModel && (
{isLocalMode() && isCustom && (
<>
<button
onClick={(e) => {
@ -458,7 +435,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
<TooltipContent side="left" className="text-xs max-w-xs">
<p>Recommended for optimal performance</p>
</TooltipContent>
) : isCustomModel ? (
) : isCustom ? (
<TooltipContent side="left" className="text-xs max-w-xs">
<p>Custom model</p>
</TooltipContent>
@ -468,16 +445,20 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
);
};
// Update filtered options when customModels changes
// Update filtered options when customModels or search query changes
useEffect(() => {
// Recalculate filtered options when custom models change
const newFilteredOptions = enhancedModelOptions.filter((opt) =>
opt.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
opt.id.toLowerCase().includes(searchQuery.toLowerCase())
);
// Force the component to re-render with new data
// Force reset of enhancedModelOptions whenever customModels change
// The next render will regenerate enhancedModelOptions with the updated modelMap
setHighlightedIndex(-1);
}, [customModels, searchQuery]);
setSearchQuery('');
// Force React to fully re-evaluate the component rendering
if (isOpen) {
// If dropdown is open, briefly close and reopen to force refresh
setIsOpen(false);
setTimeout(() => setIsOpen(true), 10);
}
}, [customModels]);
return (
<div className="relative">
@ -489,7 +470,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
className="h-8 rounded-lg text-muted-foreground shadow-none border-none focus:ring-0 px-3"
>
<div className="flex items-center gap-1 text-sm font-medium">
{isLowQualitySelected && (
{MODELS[selectedModel]?.lowQuality && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@ -546,10 +527,10 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
</div>
<div className="flex items-center gap-2">
{/* Show capabilities */}
{MODEL_CAPABILITIES[model.id]?.lowQuality && (
{(MODELS[model.id]?.lowQuality || false) && (
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" />
)}
{MODEL_CAPABILITIES[model.id]?.recommended && (
{(MODELS[model.id]?.recommended || false) && (
<Brain className="h-3.5 w-3.5 text-blue-500" />
)}
{selectedModel === model.id && (
@ -559,7 +540,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
</DropdownMenuItem>
</div>
</TooltipTrigger>
{MODEL_CAPABILITIES[model.id]?.lowQuality && (
{MODELS[model.id]?.lowQuality && (
<TooltipContent side="left" className="text-xs max-w-xs">
<p>Basic model with limited capabilities</p>
</TooltipContent>
@ -578,7 +559,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
{/* Premium models container with paywall overlay */}
<div className="relative h-40 overflow-hidden px-2">
{uniqueModels
{getPremiumModels()
.filter(m =>
m.requiresSubscription &&
(m.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
@ -598,7 +579,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
</div>
<div className="flex items-center gap-2">
{/* Show capabilities */}
{MODEL_CAPABILITIES[model.id]?.recommended && (
{MODELS[model.id]?.recommended && (
<Brain className="h-3.5 w-3.5 text-blue-500" />
)}
<Crown className="h-3.5 w-3.5 text-blue-500" />
@ -645,6 +626,9 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
<div className="px-3 py-3 flex justify-between items-center">
<span className="text-xs font-medium text-muted-foreground">All Models</span>
{isLocalMode() && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
@ -656,6 +640,12 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
>
<Plus className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Add a custom model
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{uniqueModels.filter(m =>