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) { if (initialLoadCompleted.current && subscriptionData) {
const hasSeenUpgradeDialog = localStorage.getItem('suna_upgrade_dialog_displayed'); const hasSeenUpgradeDialog = localStorage.getItem('suna_upgrade_dialog_displayed');
const isFreeTier = subscriptionStatus === 'no_subscription'; const isFreeTier = subscriptionStatus === 'no_subscription';
if (!hasSeenUpgradeDialog && isFreeTier) { if (!hasSeenUpgradeDialog && isFreeTier && !isLocalMode()) {
setShowUpgradeDialog(true); 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_MODEL = 'suna-preferred-model';
export const STORAGE_KEY_CUSTOM_MODELS = 'customModels'; 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 const DEFAULT_PREMIUM_MODEL_ID = 'sonnet-3.7';
export type SubscriptionStatus = 'no_subscription' | 'active'; export type SubscriptionStatus = 'no_subscription' | 'active';
@ -19,6 +19,7 @@ export interface ModelOption {
description?: string; description?: string;
top?: boolean; top?: boolean;
isCustom?: boolean; isCustom?: boolean;
priority?: number;
} }
export interface CustomModel { export interface CustomModel {
@ -26,20 +27,156 @@ export interface CustomModel {
label: string; label: string;
} }
const MODEL_DESCRIPTIONS: Record<string, string> = { // SINGLE SOURCE OF TRUTH for all model data
'sonnet-3.7': 'Claude 3.7 Sonnet - Anthropic\'s powerful general-purpose AI assistant', export const MODELS = {
'gpt-4.1': 'GPT-4.1 - OpenAI\'s most advanced model with enhanced reasoning', // Premium high-priority models
'gpt-4o': 'GPT-4o - Optimized for speed, reliability, and cost-effectiveness', 'sonnet-3.7': {
'gpt-4-turbo': 'GPT-4 Turbo - OpenAI\'s powerful model with a great balance of performance and cost', tier: 'premium',
'gpt-4': 'GPT-4 - OpenAI\'s highly capable model with advanced reasoning', priority: 100,
'gemini-flash-2.5': 'Gemini Flash 2.5 - Google\'s fast, responsive AI model', recommended: true,
'grok-3': 'Grok-3 - xAI\'s latest large language model with enhanced capabilities', lowQuality: false,
'deepseek': 'DeepSeek - Free tier model with good general capabilities', description: 'Claude 3.7 Sonnet - Anthropic\'s powerful general-purpose AI assistant'
'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', 'claude-3.7': {
'qwen3': 'Qwen3 - Alibaba\'s powerful multilingual language model' 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 = ( export const canAccessModel = (
subscriptionStatus: SubscriptionStatus, subscriptionStatus: SubscriptionStatus,
requiresSubscription: boolean, requiresSubscription: boolean,
@ -67,8 +204,8 @@ export const getPrefixedModelId = (modelId: string, isCustom: boolean): string =
}; };
// Helper to get custom models from localStorage // Helper to get custom models from localStorage
export const getCustomModels = () => { export const getCustomModels = (): CustomModel[] => {
if (!isLocalMode()) return []; if (!isLocalMode() || typeof window === 'undefined') return [];
try { try {
const storedModels = localStorage.getItem(STORAGE_KEY_CUSTOM_MODELS); const storedModels = localStorage.getItem(STORAGE_KEY_CUSTOM_MODELS);
@ -77,32 +214,31 @@ export const getCustomModels = () => {
const parsedModels = JSON.parse(storedModels); const parsedModels = JSON.parse(storedModels);
if (!Array.isArray(parsedModels)) return []; if (!Array.isArray(parsedModels)) return [];
// Ensure all custom models have openrouter/ prefix
return parsedModels return parsedModels
.filter((model: any) => .filter((model: any) =>
model && typeof model === 'object' && model && typeof model === 'object' &&
typeof model.id === 'string' && typeof model.id === 'string' &&
typeof model.label === 'string') typeof model.label === 'string');
.map((model: any) => ({
...model,
id: model.id.startsWith('openrouter/') ? model.id : `openrouter/${model.id}`
}));
} catch (e) { } catch (e) {
console.error('Error parsing custom models:', e); console.error('Error parsing custom models:', e);
return []; 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 = () => { export const useModelSelection = () => {
const [selectedModel, setSelectedModel] = useState(DEFAULT_FREE_MODEL_ID); const [selectedModel, setSelectedModel] = useState(DEFAULT_FREE_MODEL_ID);
const [customModels, setCustomModels] = useState<CustomModel[]>([]); const [customModels, setCustomModels] = useState<CustomModel[]>([]);
const { data: subscriptionData } = useSubscription(); const { data: subscriptionData } = useSubscription();
//MOCK
// const subscriptionData = {
// status: 'no_subscription'
// };
const { data: modelsData, isLoading: isLoadingModels } = useAvailableModels({ const { data: modelsData, isLoading: isLoadingModels } = useAvailableModels({
refetchOnMount: false, refetchOnMount: false,
}); });
@ -114,41 +250,39 @@ export const useModelSelection = () => {
// Load custom models from localStorage // Load custom models from localStorage
useEffect(() => { useEffect(() => {
if (isLocalMode() && typeof window !== 'undefined') { if (isLocalMode() && typeof window !== 'undefined') {
const savedCustomModels = localStorage.getItem(STORAGE_KEY_CUSTOM_MODELS); setCustomModels(getCustomModels());
if (savedCustomModels) {
try {
setCustomModels(JSON.parse(savedCustomModels));
} catch (e) {
console.error('Failed to parse custom models from localStorage', e);
}
}
} }
}, []); }, []);
// Generate model options list with consistent structure
const MODEL_OPTIONS = useMemo(() => { const MODEL_OPTIONS = useMemo(() => {
let baseModels = []; let models = [];
// Default models if API data not available
if (!modelsData?.models || isLoadingModels) { if (!modelsData?.models || isLoadingModels) {
baseModels = [ models = [
{ {
id: DEFAULT_FREE_MODEL_ID, id: DEFAULT_FREE_MODEL_ID,
label: 'DeepSeek', label: 'Qwen3',
requiresSubscription: false, 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, id: DEFAULT_PREMIUM_MODEL_ID,
label: 'Claude 3.7 Sonnet', label: 'Sonnet 3.7',
requiresSubscription: true, 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 { } else {
const topModels = ['sonnet-3.7', 'gemini-flash-2.5']; // Process API-provided models
baseModels = modelsData.models.map(model => { models = modelsData.models.map(model => {
const shortName = model.short_name || model.id; const shortName = model.short_name || model.id;
const displayName = model.display_name || shortName; const displayName = model.display_name || shortName;
// Format the display label
let cleanLabel = displayName; let cleanLabel = displayName;
if (cleanLabel.includes('/')) { if (cleanLabel.includes('/')) {
cleanLabel = cleanLabel.split('/').pop() || cleanLabel; cleanLabel = cleanLabel.split('/').pop() || cleanLabel;
@ -160,16 +294,20 @@ export const useModelSelection = () => {
.map(word => word.charAt(0).toUpperCase() + word.slice(1)) .map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' '); .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 { return {
id: shortName, id: shortName,
label: cleanLabel, label: cleanLabel,
requiresSubscription: isPremium, requiresSubscription: isPremium,
description: MODEL_DESCRIPTIONS[shortName] || description: modelData.description ||
(isPremium (isPremium ? MODEL_TIERS.premium.baseDescription : MODEL_TIERS.free.baseDescription),
? 'Premium model with advanced capabilities' top: modelData.priority >= 90, // Mark high-priority models as "top"
: 'Free tier model with good general capabilities'), priority: modelData.priority || 0,
top: topModels.includes(shortName) lowQuality: modelData.lowQuality || false,
recommended: modelData.recommended || false
}; };
}); });
} }
@ -180,97 +318,38 @@ export const useModelSelection = () => {
id: model.id, id: model.id,
label: model.label || formatModelName(model.id), label: model.label || formatModelName(model.id),
requiresSubscription: false, requiresSubscription: false,
description: 'Custom model', description: MODEL_TIERS.custom.baseDescription,
top: false, 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:
}, [modelsData, isLoadingModels, customModels]); // 1. First by free/premium (free first)
// 2. Then by priority (higher first)
useEffect(() => { // 3. Finally by name (alphabetical)
if (typeof window === 'undefined') return; return models.sort((a, b) => {
// First by free/premium status
try { if (a.requiresSubscription !== b.requiresSubscription) {
const savedModel = localStorage.getItem(STORAGE_KEY_MODEL); return a.requiresSubscription ? 1 : -1;
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') { // Then by priority (higher first)
if (savedModel) { if (a.priority !== b.priority) {
const modelOption = MODEL_OPTIONS.find(option => option.id === savedModel); return b.priority - a.priority;
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); // Finally by name
} return a.label.localeCompare(b.label);
} catch (error) { });
console.warn('Failed to load preferences from localStorage:', error); }, [modelsData, isLoadingModels, customModels]);
}
}, [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(() => { const availableModels = useMemo(() => {
return isLocalMode() return isLocalMode()
? MODEL_OPTIONS ? MODEL_OPTIONS
@ -279,35 +358,97 @@ export const useModelSelection = () => {
); );
}, [MODEL_OPTIONS, subscriptionStatus]); }, [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 // Get the actual model ID to send to the backend
const getActualModelId = (modelId: string): string => { const getActualModelId = (modelId: string): string => {
// First, check if this is a standard model directly // No need for automatic prefixing in most cases - just return as is
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
return modelId; return modelId;
}; };
return { return {
selectedModel, selectedModel,
setSelectedModel: (modelId: string) => { setSelectedModel: handleModelChange,
handleModelChange(modelId);
},
subscriptionStatus, subscriptionStatus,
availableModels, availableModels,
allModels: MODEL_OPTIONS, allModels: MODEL_OPTIONS, // Already pre-sorted
customModels, customModels,
// This is the model ID that should be sent to the backend
getActualModelId, getActualModelId,
canAccessModel: (modelId: string) => { canAccessModel: (modelId: string) => {
if (isLocalMode()) return true; if (isLocalMode()) return true;
@ -318,4 +459,6 @@ export const useModelSelection = () => {
return MODEL_OPTIONS.find(m => m.id === modelId)?.requiresSubscription || false; return MODEL_OPTIONS.find(m => m.id === modelId)?.requiresSubscription || false;
} }
}; };
}; };
// 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 <DialogContent
className="sm:max-w-[425px]" className="sm:max-w-[430px]"
onEscapeKeyDown={handleClose} onEscapeKeyDown={handleClose}
onPointerDownOutside={handleClose} onPointerDownOutside={handleClose}
> >
<DialogHeader> <DialogHeader>
<DialogTitle>{mode === 'add' ? 'Add Custom Model' : 'Edit Custom Model'}</DialogTitle> <DialogTitle>{mode === 'add' ? 'Add Custom Model' : 'Edit Custom Model'}</DialogTitle>
<DialogDescription> <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> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="flex flex-col gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4"> <div className="flex flex-col items-start gap-4">
<Label htmlFor="modelId" className="text-right"> <Label htmlFor="modelId" className="text-right">
Model ID Model ID
</Label> </Label>
<Input <Input
id="modelId" id="modelId"
placeholder="e.g. gpt-4-vision" placeholder="e.g. openrouter/meta-llama/llama-4-maverick"
value={formData.id} value={formData.id}
onChange={handleChange} onChange={handleChange}
className="col-span-3" className="col-span-3"

View File

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