"use client"; import { useState, useMemo, useEffect } from "react"; import PropTypes from "prop-types"; import Modal from "./Modal"; import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; // Provider order: OAuth first, then API Key (matches dashboard/providers) const PROVIDER_ORDER = [ ...Object.keys(OAUTH_PROVIDERS), ...Object.keys(APIKEY_PROVIDERS), ]; export default function ModelSelectModal({ isOpen, onClose, onSelect, selectedModel, activeProviders = [], title = "Select Model", modelAliases = {}, }) { const [searchQuery, setSearchQuery] = useState(""); const [combos, setCombos] = useState([]); const [providerNodes, setProviderNodes] = useState([]); const fetchCombos = async () => { try { const res = await fetch("/api/combos"); if (!res.ok) throw new Error(`Failed to fetch combos: ${res.status}`); const data = await res.json(); setCombos(data.combos || []); } catch (error) { console.error("Error fetching combos:", error); setCombos([]); } }; useEffect(() => { if (isOpen) fetchCombos(); }, [isOpen]); const fetchProviderNodes = async () => { try { const res = await fetch("/api/provider-nodes"); if (!res.ok) throw new Error(`Failed to fetch provider nodes: ${res.status}`); const data = await res.json(); setProviderNodes(data.nodes || []); } catch (error) { console.error("Error fetching provider nodes:", error); setProviderNodes([]); } }; useEffect(() => { if (isOpen) fetchProviderNodes(); }, [isOpen]); const allProviders = useMemo(() => ({ ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS }), []); // Group models by provider with priority order const groupedModels = useMemo(() => { const groups = {}; // Get all active provider IDs from connections const activeConnectionIds = activeProviders.map(p => p.provider); // Only show connected providers (including both standard and custom) const providerIdsToShow = new Set([ ...activeConnectionIds, // Only connected providers ]); // Sort by PROVIDER_ORDER const sortedProviderIds = [...providerIdsToShow].sort((a, b) => { const indexA = PROVIDER_ORDER.indexOf(a); const indexB = PROVIDER_ORDER.indexOf(b); return (indexA === -1 ? 999 : indexA) - (indexB === -1 ? 999 : indexB); }); sortedProviderIds.forEach((providerId) => { const alias = PROVIDER_ID_TO_ALIAS[providerId] || providerId; const providerInfo = allProviders[providerId] || { name: providerId, color: "#666" }; const isCustomProvider = isOpenAICompatibleProvider(providerId) || isAnthropicCompatibleProvider(providerId); if (providerInfo.passthroughModels) { const aliasModels = Object.entries(modelAliases) .filter(([, fullModel]) => fullModel.startsWith(`${alias}/`)) .map(([aliasName, fullModel]) => ({ id: fullModel.replace(`${alias}/`, ""), name: aliasName, value: fullModel, })); if (aliasModels.length > 0) { // Check for custom name from providerNodes (for compatible providers) const matchedNode = providerNodes.find(node => node.id === providerId); const displayName = matchedNode?.name || providerInfo.name; groups[providerId] = { name: displayName, alias: alias, color: providerInfo.color, models: aliasModels, }; } } else if (isCustomProvider) { // Match provider node to get custom name const matchedNode = providerNodes.find(node => node.id === providerId); const displayName = matchedNode?.name || providerInfo.name; // Get models from modelAliases using providerId (not prefix) // modelAliases format: { alias: "providerId/modelId" } const nodeModels = Object.entries(modelAliases) .filter(([, fullModel]) => fullModel.startsWith(`${providerId}/`)) .map(([aliasName, fullModel]) => ({ id: fullModel.replace(`${providerId}/`, ""), name: aliasName, value: fullModel, })); // Only add to groups if there are models (consistent with other provider types) if (nodeModels.length > 0) { groups[providerId] = { name: displayName, alias: matchedNode?.prefix || providerId, color: providerInfo.color, models: nodeModels, isCustom: true, hasModels: true, }; } } else { const models = getModelsByProviderId(providerId); if (models.length > 0) { groups[providerId] = { name: providerInfo.name, alias: alias, color: providerInfo.color, models: models.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}`, })), }; } } }); return groups; }, [activeProviders, modelAliases, allProviders, providerNodes]); // Filter combos by search query const filteredCombos = useMemo(() => { if (!searchQuery.trim()) return combos; const query = searchQuery.toLowerCase(); return combos.filter(c => c.name.toLowerCase().includes(query)); }, [combos, searchQuery]); // Filter models by search query const filteredGroups = useMemo(() => { if (!searchQuery.trim()) return groupedModels; const query = searchQuery.toLowerCase(); const filtered = {}; Object.entries(groupedModels).forEach(([providerId, group]) => { const matchedModels = group.models.filter( (m) => m.name.toLowerCase().includes(query) || m.id.toLowerCase().includes(query) ); const providerNameMatches = group.name.toLowerCase().includes(query); if (matchedModels.length > 0 || providerNameMatches) { filtered[providerId] = { ...group, models: matchedModels, }; } }); return filtered; }, [groupedModels, searchQuery]); const handleSelect = (model) => { onSelect(model); onClose(); setSearchQuery(""); }; return ( { onClose(); setSearchQuery(""); }} title={title} size="md" className="p-4!" > {/* Search - compact */}
search setSearchQuery(e.target.value)} className="w-full pl-8 pr-3 py-1.5 bg-surface border border-border rounded text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" />
{/* Models grouped by provider - compact */}
{/* Combos section - always first */} {filteredCombos.length > 0 && (
layers Combos ({filteredCombos.length})
{filteredCombos.map((combo) => { const isSelected = selectedModel === combo.name; return ( ); })}
)} {/* Provider models */} {Object.entries(filteredGroups).map(([providerId, group]) => (
{/* Provider header */}
{group.name} ({group.models.length})
{group.models.map((model) => { const isSelected = selectedModel === model.value; return ( ); })}
))} {Object.keys(filteredGroups).length === 0 && filteredCombos.length === 0 && (
search_off

No models found

)}
); } ModelSelectModal.propTypes = { isOpen: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, onSelect: PropTypes.func.isRequired, selectedModel: PropTypes.string, activeProviders: PropTypes.arrayOf( PropTypes.shape({ provider: PropTypes.string.isRequired, }) ), title: PropTypes.string, modelAliases: PropTypes.object, };