diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js index 726ca3d..2705c8f 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js @@ -88,22 +88,6 @@ export default function CLIToolsPageClient({ machineId }) { }); }); - if (models.length === 0) { - Object.entries(PROVIDER_MODELS).forEach(([alias, providerModels]) => { - providerModels.forEach(m => { - const modelValue = `${alias}/${m.id}`; - models.push({ - value: modelValue, - label: `${alias}/${m.id}`, - provider: alias, - alias: alias, - connectionName: alias, - modelId: m.id, - }); - }); - }); - } - return models; }; diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index 1e2b950..1e43659 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -1,8 +1,9 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; // Validate combo name: only a-z, A-Z, 0-9, -, _ const VALID_NAME_REGEX = /^[a-zA-Z0-9_-]+$/; @@ -17,7 +18,7 @@ export default function CombosPage() { useEffect(() => { fetchData(); - }, []); + }, []); // eslint-disable-line react-hooks/exhaustive-deps const fetchData = async () => { try { @@ -235,21 +236,32 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { const [saving, setSaving] = useState(false); const [nameError, setNameError] = useState(""); const [modelAliases, setModelAliases] = useState({}); + const [providerNodes, setProviderNodes] = useState([]); - // Fetch model aliases when modal opens - useEffect(() => { - if (isOpen) { - const fetchModelAliases = async () => { - try { - const res = await fetch("/api/models/alias"); - const data = await res.json(); - if (res.ok) setModelAliases(data.aliases || {}); - } catch (error) { - console.log("Error fetching model aliases:", error); - } - }; - fetchModelAliases(); + const fetchModalData = async () => { + try { + const [aliasesRes, nodesRes] = await Promise.all([ + fetch("/api/models/alias"), + fetch("/api/provider-nodes"), + ]); + + if (!aliasesRes.ok || !nodesRes.ok) { + throw new Error(`Failed to fetch data: aliases=${aliasesRes.status}, nodes=${nodesRes.status}`); + } + + const [aliasesData, nodesData] = await Promise.all([ + aliasesRes.json(), + nodesRes.json(), + ]); + setModelAliases(aliasesData.aliases || {}); + setProviderNodes(nodesData.nodes || []); + } catch (error) { + console.error("Error fetching modal data:", error); } + }; + + useEffect(() => { + if (isOpen) fetchModalData(); }, [isOpen]); const validateName = (value) => { @@ -282,11 +294,20 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { setModels(models.filter((_, i) => i !== index)); }; - const handleModelChange = (index, value) => { - const newModels = [...models]; - newModels[index] = value; - setModels(newModels); - }; + // Format model display name with readable provider name + const formatModelDisplay = useCallback((modelValue) => { + const parts = modelValue.split('/'); + if (parts.length !== 2) return modelValue; + + const [providerId, modelId] = parts; + const matchedNode = providerNodes.find(node => node.id === providerId); + + if (matchedNode) { + return `${matchedNode.name}/${modelId}`; + } + + return modelValue; + }, [providerNodes]); const handleMoveUp = (index) => { if (index === 0) return; @@ -352,14 +373,10 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { {/* Index badge */} {index + 1} - {/* Model Input */} - handleModelChange(index, e.target.value)} - placeholder="provider/model" - className="flex-1 min-w-0 px-1.5 py-0.5 text-xs font-mono bg-transparent border-0 focus:outline-none text-text-main placeholder:text-text-muted/50" - /> + {/* Model display - show readable name only */} +
+ {formatModelDisplay(model)} +
{/* Priority arrows - horizontal, always visible */}
diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js index 8df74c0..9e727db 100644 --- a/src/shared/components/ModelSelectModal.js +++ b/src/shared/components/ModelSelectModal.js @@ -4,7 +4,7 @@ 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 } from "@/shared/constants/providers"; +import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; // Provider order: OAuth first, then API Key (matches dashboard/providers) const PROVIDER_ORDER = [ @@ -23,15 +23,38 @@ export default function ModelSelectModal({ }) { const [searchQuery, setSearchQuery] = useState(""); const [combos, setCombos] = useState([]); + const [providerNodes, setProviderNodes] = useState([]); - // Fetch combos when modal opens - useEffect(() => { - if (isOpen) { - fetch("/api/combos") - .then(res => res.json()) - .then(data => setCombos(data.combos || [])) - .catch(() => setCombos([])); + 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 }), []); @@ -40,13 +63,16 @@ export default function ModelSelectModal({ const groupedModels = useMemo(() => { const groups = {}; - // Get active provider IDs - const activeProviderIds = activeProviders.length > 0 - ? activeProviders.map(p => p.provider) - : PROVIDER_ORDER; + // 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 = [...activeProviderIds].sort((a, b) => { + 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); @@ -55,8 +81,8 @@ export default function ModelSelectModal({ 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); - // For passthrough providers, get models from aliases if (providerInfo.passthroughModels) { const aliasModels = Object.entries(modelAliases) .filter(([, fullModel]) => fullModel.startsWith(`${alias}/`)) @@ -67,13 +93,43 @@ export default function ModelSelectModal({ })); 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: providerInfo.name, + 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) { @@ -92,7 +148,7 @@ export default function ModelSelectModal({ }); return groups; - }, [activeProviders, modelAliases, allProviders]); + }, [activeProviders, modelAliases, allProviders, providerNodes]); // Filter combos by search query const filteredCombos = useMemo(() => { @@ -112,11 +168,12 @@ export default function ModelSelectModal({ const matchedModels = group.models.filter( (m) => m.name.toLowerCase().includes(query) || - m.id.toLowerCase().includes(query) || - group.name.toLowerCase().includes(query) + m.id.toLowerCase().includes(query) ); - if (matchedModels.length > 0) { + const providerNameMatches = group.name.toLowerCase().includes(query); + + if (matchedModels.length > 0 || providerNameMatches) { filtered[providerId] = { ...group, models: matchedModels, @@ -210,7 +267,6 @@ export default function ModelSelectModal({
- {/* Models as wrap chips - compact */}
{group.models.map((model) => { const isSelected = selectedModel === model.value;