From e3cf2e7708616fca12e6d30cad7dd50d094b7b78 Mon Sep 17 00:00:00 2001 From: Nezumi-2711 Date: Thu, 16 Jul 2026 15:07:32 +0700 Subject: [PATCH] fix: update the permission for viewing models --- .../basic-chat/BasicChatPageClient.js | 137 +++--------------- .../cli-tools/[toolId]/ToolDetailClient.js | 9 +- .../components/ConfigGeneratorCard.js | 25 +--- .../cli-tools/components/DefaultToolCard.js | 25 +--- .../cli-tools/components/MitmToolCard.js | 26 ++-- src/app/(dashboard)/dashboard/combos/page.js | 20 ++- .../dashboard/mitm/MitmPageClient.js | 83 ++++------- src/app/(dashboard)/dashboard/models/page.js | 10 +- .../dashboard/providers/[id]/ModelRow.js | 20 ++- .../dashboard/providers/[id]/page.js | 21 ++- src/app/api/models/connected/route.js | 110 ++++++++------ src/shared/components/ComboFormModal.js | 17 ++- src/shared/components/ModelSelectModal.js | 7 +- tests/unit/connected-models-route.test.js | 33 ++++- 14 files changed, 241 insertions(+), 302 deletions(-) diff --git a/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js b/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js index a97d0a5e..b18a3048 100644 --- a/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js +++ b/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js @@ -2,8 +2,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Badge, Button } from "@/shared/components"; -import { getModelsByProviderId } from "@/shared/constants/models"; -import { isAnthropicCompatibleProvider, isOpenAICompatibleProvider } from "@/shared/constants/providers"; const STORAGE_KEYS = { sessions: "basic-chat.sessions", @@ -109,63 +107,6 @@ function cloneSession(session) { }; } -function getProviderLabel(connection) { - return connection?.name || humanize(connection?.provider || connection?.id || "provider"); -} - -function normalizeStaticModel(model, connection) { - if (!model?.id) return null; - return { - id: `${connection.provider}/${model.id}`, - requestModel: `${connection.provider}/${model.id}`, - name: model.name || model.id, - providerId: connection.provider, - providerName: getProviderLabel(connection), - source: "static", - }; -} - -function normalizeLiveModel(model, connection) { - const rawId = typeof model === "string" ? model : model?.id || model?.name || model?.model || ""; - if (!rawId) return null; - - const displayName = typeof model === "string" - ? model - : model?.name || model?.displayName || rawId; - - let requestModel = rawId; - const isCompatible = isOpenAICompatibleProvider(connection.provider) || isAnthropicCompatibleProvider(connection.provider); - if (isCompatible && !rawId.includes("/")) { - requestModel = `${connection.provider}/${rawId}`; - } - - return { - id: requestModel, - requestModel, - name: displayName, - providerId: connection.provider, - providerName: getProviderLabel(connection), - source: "live", - }; -} - -function parseProviderModelsPayload(data) { - if (Array.isArray(data?.models)) return data.models; - if (Array.isArray(data?.data)) return data.data; - if (Array.isArray(data?.results)) return data.results; - if (Array.isArray(data)) return data; - return []; -} - -function dedupeModels(models) { - const map = new Map(); - for (const model of models) { - if (!model?.id) continue; - if (!map.has(model.id)) map.set(model.id, model); - } - return Array.from(map.values()); -} - export default function BasicChatPageClient() { const [providerGroups, setProviderGroups] = useState([]); const [loadingData, setLoadingData] = useState(true); @@ -218,79 +159,39 @@ export default function BasicChatPageClient() { setLoadError(""); try { - const providersRes = await fetch("/api/providers", { cache: "no-store" }); - const providersData = await providersRes.json().catch(() => ({})); - const connections = Array.isArray(providersData.connections) - ? providersData.connections.filter((connection) => connection?.isActive !== false) - : []; - - if (connections.length === 0) { - if (!cancelled) { - setProviderGroups([]); - setLoadError("No providers connected yet."); - } - return; - } + const modelsRes = await fetch("/api/models/connected", { cache: "no-store" }); + const modelsData = await modelsRes.json().catch(() => ({})); + if (!modelsRes.ok) throw new Error(modelsData.error || "Failed to load added models."); const providerMap = new Map(); - - for (const connection of connections) { - const providerId = connection.provider || connection.id; - const providerName = getProviderLabel(connection); - const providerType = isOpenAICompatibleProvider(providerId) - ? "openai-compatible" - : isAnthropicCompatibleProvider(providerId) - ? "anthropic-compatible" - : providerId; + for (const model of modelsData.models || []) { + if (model.disabled || !model.fullModel) continue; + const providerId = model.provider?.id || model.providerAlias; + const providerName = model.provider?.name || humanize(providerId); if (!providerMap.has(providerId)) { providerMap.set(providerId, { providerId, providerName, - providerType, - connections: [], models: [], }); } const group = providerMap.get(providerId); - group.providerName = group.providerName || providerName; - group.providerType = group.providerType || providerType; - group.connections.push(connection); - - const staticModels = getModelsByProviderId(providerId) - .map((model) => normalizeStaticModel(model, connection)) - .filter(Boolean); - group.models.push(...staticModels); - } - - const liveResults = await Promise.all( - connections.map(async (connection) => { - try { - const response = await fetch(`/api/providers/${connection.id}/models`, { cache: "no-store" }); - const data = await response.json().catch(() => ({})); - if (!response.ok) return { connection, models: [] }; - const models = parseProviderModelsPayload(data) - .map((model) => normalizeLiveModel(model, connection)) - .filter(Boolean); - return { connection, models }; - } catch { - return { connection, models: [] }; - } - }) - ); - - for (const result of liveResults) { - const providerId = result.connection.provider || result.connection.id; - const group = providerMap.get(providerId); - if (!group) continue; - group.models.push(...result.models); + group.models.push({ + id: model.fullModel, + requestModel: model.fullModel, + name: model.name || model.alias || model.model, + providerId, + providerName, + source: "added", + }); } const normalized = Array.from(providerMap.values()) .map((group) => ({ ...group, - models: dedupeModels(group.models).sort((a, b) => a.name.localeCompare(b.name)), + models: group.models.sort((a, b) => a.name.localeCompare(b.name)), })) .filter((group) => group.models.length > 0) .sort((a, b) => a.providerName.localeCompare(b.providerName)); @@ -298,12 +199,12 @@ export default function BasicChatPageClient() { if (!cancelled) { setProviderGroups(normalized); if (normalized.length === 0) { - setLoadError("Providers connected but no models available."); + setLoadError("No added models are available. Add a model from a provider first."); } } } catch (error) { if (!cancelled) { - setLoadError(textValue(error?.message) || "Failed to load providers/models."); + setLoadError(textValue(error?.message) || "Failed to load added models."); setProviderGroups([]); } } finally { @@ -761,7 +662,7 @@ export default function BasicChatPageClient() {

Models

-

Only from connected providers

+

Added models from connected providers

{providerGroups.map((group) => ( diff --git a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js index a3bbadad..73ad91fa 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js @@ -18,16 +18,18 @@ export default function ToolDetailClient({ toolId, machineId }) { const [tailscaleEnabled, setTailscaleEnabled] = useState(false); const [tailscaleUrl, setTailscaleUrl] = useState(""); const [apiKeys, setApiKeys] = useState([]); + const [availableModels, setAvailableModels] = useState([]); useEffect(() => { let mounted = true; (async () => { try { - const [provRes, settingsRes, tunnelRes, keysRes] = await Promise.all([ + const [provRes, settingsRes, tunnelRes, keysRes, modelsRes] = await Promise.all([ fetch("/api/providers"), fetch("/api/settings"), fetch("/api/tunnel/status"), fetch("/api/keys"), + fetch("/api/models/connected", { cache: "no-store" }), ]); if (!mounted) return; if (provRes.ok) { @@ -49,6 +51,10 @@ export default function ToolDetailClient({ toolId, machineId }) { const data = await keysRes.json(); setApiKeys(data.keys || []); } + if (modelsRes.ok) { + const data = await modelsRes.json(); + setAvailableModels((data.models || []).filter((model) => !model.disabled)); + } } catch (error) { console.log("Error loading tool data:", error); } finally { @@ -78,6 +84,7 @@ export default function ToolDetailClient({ toolId, machineId }) { tailscaleEnabled, tailscaleUrl, activeProviders: getActiveProviders(), + availableModels, cloudEnabled, }; diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js index b2a02df0..c0d200ed 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js @@ -165,6 +165,7 @@ export default function ConfigGeneratorCard({ baseUrl, apiKeys, activeProviders, + availableModels = [], cloudEnabled, tunnelEnabled, tunnelPublicUrl, @@ -183,7 +184,7 @@ export default function ConfigGeneratorCard({ const [coworkThinking, setCoworkThinking] = useState({}); const [copilotTokens, setCopilotTokens] = useState({}); const [copilotThinking, setCopilotThinking] = useState({}); - const [connectedModels, setConnectedModels] = useState(null); + const connectedModels = availableModels; const [customBaseUrl, setCustomBaseUrl] = useState(""); const [modelModalOpen, setModelModalOpen] = useState(false); const [configModalOpen, setConfigModalOpen] = useState(false); @@ -226,26 +227,6 @@ export default function ConfigGeneratorCard({ } }; - useEffect(() => { - if (toolId !== "claude" && toolId !== "codex" && toolId !== "opencode" && toolId !== "cowork" && toolId !== "copilot") return; - - let cancelled = false; - const loadConnectedModels = async () => { - try { - const response = await fetch("/api/models/connected", { cache: "no-store" }); - if (!response.ok) throw new Error("Failed to load connected models"); - const data = await response.json(); - if (!cancelled) setConnectedModels(data.models || []); - } catch (error) { - console.log(`Error loading connected models for ${tool.name}:`, error); - if (!cancelled) setConnectedModels([]); - } - }; - - loadConnectedModels(); - return () => { cancelled = true; }; - }, [toolId, tool.name]); - const addModel = (selected) => { if (!selected?.value || selectedModels.includes(selected.value)) return; setSelectedModels((current) => [...current, selected.value]); @@ -593,7 +574,7 @@ export default function ConfigGeneratorCard({ title={toolId === "claude" && claudeModelSlot ? `Select ${claudeModelSlot} model` : toolId === "codex" ? "Select Codex model" : toolId === "opencode" ? "Add OpenCode model" : `Add model for ${tool.name}`} closeOnSelect={toolId === "claude" || toolId === "codex"} addedModelValues={toolId === "claude" ? Object.values(claudeModels).filter(Boolean) : toolId === "codex" ? [codexModel].filter(Boolean) : toolId === "opencode" ? opencodeModels : selectedModels} - availableModels={toolId === "claude" || toolId === "codex" || toolId === "opencode" || toolId === "cowork" || toolId === "copilot" ? connectedModels : null} + availableModels={availableModels} /> setConfigModalOpen(false)} title={`${tool.name} configuration`} configs={configs} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js index e5114301..58b5058e 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js @@ -6,12 +6,11 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import Image from "next/image"; import ApiKeySelect from "./ApiKeySelect"; -export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, activeProviders = [], cloudEnabled = false, tunnelEnabled = false }) { +export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, activeProviders = [], availableModels = [], cloudEnabled = false, tunnelEnabled = false }) { const [copiedField, setCopiedField] = useState(null); const [showModelModal, setShowModelModal] = useState(false); const [modelValue, setModelValue] = useState(""); const [selectedModels, setSelectedModels] = useState([]); - const [connectedModels, setConnectedModels] = useState(null); const [isExpanded, setIsExpanded] = useState(true); // Initialize state directly with computed value - no need for useEffect @@ -19,26 +18,6 @@ export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, active apiKeys?.length > 0 ? apiKeys[0].key : "" ); - useEffect(() => { - if (toolId !== "cursor") return; - - let cancelled = false; - const loadConnectedModels = async () => { - try { - const response = await fetch("/api/models/connected", { cache: "no-store" }); - if (!response.ok) throw new Error("Failed to load connected models"); - const data = await response.json(); - if (!cancelled) setConnectedModels(data.models || []); - } catch (error) { - console.log(`Error loading connected models for ${tool.name}:`, error); - if (!cancelled) setConnectedModels([]); - } - }; - - loadConnectedModels(); - return () => { cancelled = true; }; - }, [toolId, tool.name]); - const replaceVars = (text) => { const keyToUse = (selectedApiKey && selectedApiKey.trim()) ? selectedApiKey @@ -340,7 +319,7 @@ export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, active title={tool.modelSelection === "multiple" ? "Add Cursor custom model" : "Select Model"} closeOnSelect={tool.modelSelection !== "multiple"} addedModelValues={tool.modelSelection === "multiple" ? selectedModels : []} - availableModels={toolId === "cursor" ? connectedModels : null} + availableModels={availableModels} /> ); diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js index 82384292..8175cf2b 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js @@ -22,6 +22,7 @@ export default function MitmToolCard({ isWin, apiKeys, activeProviders, + availableModels = [], hasActiveProviders, modelAliases = {}, cloudEnabled, @@ -41,18 +42,20 @@ export default function MitmToolCard({ const canRunWithoutPassword = isWin || hasCachedPassword || needsSudoPassword === false; useEffect(() => { - if (isExpanded) loadSavedMappings(); - }, [isExpanded]); + if (!isExpanded) return; + let cancelled = false; - const loadSavedMappings = async () => { - try { - const res = await fetch(`/api/cli-tools/antigravity-mitm/alias?tool=${tool.id}`); - if (res.ok) { - const data = await res.json(); - if (Object.keys(data.aliases || {}).length > 0) setModelMappings(data.aliases); - } - } catch { /* ignore */ } - }; + fetch(`/api/cli-tools/antigravity-mitm/alias?tool=${tool.id}`) + .then((res) => res.ok ? res.json() : null) + .then((data) => { + if (!cancelled && Object.keys(data?.aliases || {}).length > 0) { + setModelMappings(data.aliases); + } + }) + .catch(() => {}); + + return () => { cancelled = true; }; + }, [isExpanded, tool.id]); const saveMappings = useCallback(async (mappings) => { try { @@ -311,6 +314,7 @@ export default function MitmToolCard({ selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} activeProviders={activeProviders} modelAliases={modelAliases} + availableModels={availableModels} title={`Select model for ${currentEditingAlias}`} /> diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index 3c6f78cb..bd88a6e8 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -86,6 +86,10 @@ export default function CombosPage() { body: JSON.stringify(data), }); if (res.ok) { + const judgeModel = comboStrategies[id]?.judgeModel; + if (judgeModel && !selectableModels.some((model) => model.fullModel === judgeModel)) { + await handleSetComboStrategy(id, { judgeModel: "" }); + } await fetchData(); setEditingCombo(null); } else { @@ -243,7 +247,8 @@ const STRATEGY_OPTIONS = [ function ComboCard({ combo, modelCaps = {}, availableModels = [], copied, onCopy, onEdit, onDelete, strategy = {}, onSetStrategy }) { const [showJudgeSelect, setShowJudgeSelect] = useState(false); const current = strategy.fallbackStrategy || "fallback"; - const judge = strategy.judgeModel || ""; + const availableModelValues = new Set(availableModels.map((model) => model.fullModel)); + const judge = availableModelValues.has(strategy.judgeModel) ? strategy.judgeModel : ""; const isFusion = current === "fusion"; return ( @@ -303,7 +308,10 @@ function ComboCard({ combo, modelCaps = {}, availableModels = [], copied, onCopy typeof fullModel === "string") + .map(([alias, fullModel]) => [fullModel, alias]), + ); +} + function getProviderLabel(providerAlias) { const provider = getProviderByAlias(providerAlias) || AI_PROVIDERS[providerAlias]; return { @@ -70,7 +81,9 @@ function getForbiddenResponse(error) { return null; } -// GET /api/models/connected - List models from providers with a usable active connection. +// GET /api/models/connected - List administrator-added LLMs from providers with +// a usable active connection. Provider registries and live /models responses are +// discovery sources only; a customModels record is the explicit availability source. export async function GET() { try { const user = await requireUsageDashboardUser(); @@ -84,37 +97,28 @@ export async function GET() { getUsers(), ]); - const connectionCountByAlias = new Map(); + const connectedProviderByAlias = new Map(); for (const connection of connections) { if (!isViableConnection(connection)) continue; + if ( + isOpenAICompatibleProvider(connection.provider) + || isAnthropicCompatibleProvider(connection.provider) + ) { + continue; + } + for (const alias of getConnectionProviderAliases(connection)) { - connectionCountByAlias.set(alias, (connectionCountByAlias.get(alias) || 0) + 1); + if (!connectedProviderByAlias.has(alias)) { + connectedProviderByAlias.set(alias, { + providerId: connection.provider, + providerAlias: getProviderAlias(connection.provider) || connection.provider, + provider: getProviderLabel(connection.provider), + }); + } } } - const staticModels = AI_MODELS - .filter((model) => connectionCountByAlias.has(model.provider)) - .map((model) => { - const providerAlias = getProviderAlias(model.provider) || model.provider; - const disabled = disabledModels[providerAlias] || disabledModels[model.provider] || []; - const caps = getCapabilitiesForModel(model.provider, model.model); - - return { - ...model, - provider: getProviderLabel(model.provider), - providerAlias, - fullModel: `${model.provider}/${model.model}`, - alias: modelAliases[`${model.provider}/${model.model}`] || model.model, - disabled: disabled.includes(model.model), - caps: { - vision: caps.vision, - search: caps.search, - reasoning: caps.reasoning, - }, - }; - }); - // Compatible providers are dynamic and therefore absent from AI_MODELS. // Their catalog is the explicit list maintained by an administrator on the // provider detail page. The provider-node ID is retained as the alias so @@ -140,39 +144,57 @@ export async function GET() { } } - const compatibleModels = []; + const compatibleProviderByAlias = new Map(); for (const [providerId, connection] of viableCompatibleConnections) { const provider = getCompatibleProviderLabel(providerId, nodeById.get(providerId), connection); - const disabled = disabledModels[providerId] || []; + compatibleProviderByAlias.set(providerId, { + providerId, + providerAlias: providerId, + provider, + }); + } - for (const customModel of customModels) { - const kind = customModel.kind || customModel.type || "llm"; - if (customModel.providerAlias !== providerId || kind !== "llm" || !customModel.id) continue; + const aliasByFullModel = getAliasByFullModel(modelAliases); + const seenFullModels = new Set(); + const models = customModels + .filter((customModel) => customModel?.id && getModelType(customModel) === "llm") + .map((customModel) => { + const providerEntry = connectedProviderByAlias.get(customModel.providerAlias) + || compatibleProviderByAlias.get(customModel.providerAlias); + if (!providerEntry) return null; const modelId = String(customModel.id).trim(); - if (!modelId) continue; + if (!modelId) return null; - const fullModel = `${providerId}/${modelId}`; - const caps = getCapabilitiesForModel(providerId, modelId); - compatibleModels.push({ - provider, - providerAlias: providerId, + const storageAlias = customModel.providerAlias; + const fullModel = `${storageAlias}/${modelId}`; + if (seenFullModels.has(fullModel)) return null; + seenFullModels.add(fullModel); + + const disabled = new Set([ + ...(disabledModels[storageAlias] || []), + ...(disabledModels[providerEntry.providerId] || []), + ...(disabledModels[providerEntry.providerAlias] || []), + ]); + const caps = getCapabilitiesForModel(providerEntry.providerId, modelId); + + return { + provider: providerEntry.provider, + providerAlias: storageAlias, model: modelId, name: customModel.name || modelId, fullModel, - alias: modelAliases[fullModel] || modelId, - disabled: disabled.includes(modelId), + alias: aliasByFullModel.get(fullModel) || modelId, + disabled: disabled.has(modelId), isCustom: true, caps: { vision: caps.vision, search: caps.search, reasoning: caps.reasoning, }, - }); - } - } - - const models = [...staticModels, ...compatibleModels] + }; + }) + .filter(Boolean) .filter((model) => user.role === "admin" || !model.disabled) .sort((a, b) => ( a.provider.name.localeCompare(b.provider.name) diff --git a/src/shared/components/ComboFormModal.js b/src/shared/components/ComboFormModal.js index d80732da..ad3c9ce9 100644 --- a/src/shared/components/ComboFormModal.js +++ b/src/shared/components/ComboFormModal.js @@ -50,13 +50,20 @@ function ModelItem({ index, model, isFirst, isLast, onEdit, onMoveUp, onMoveDown } // Reusable Combo create/edit modal. forcePrefix auto-prepends to name. -export default function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindFilter = null, forcePrefix = "", title }) { +export default function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, availableModels = null, kindFilter = null, forcePrefix = "", title }) { // Strip prefix when editing existing combo so user only edits suffix const initialName = combo?.name ? (forcePrefix && combo.name.startsWith(forcePrefix) ? combo.name.slice(forcePrefix.length) : combo.name) : ""; const [name, setName] = useState(initialName); - const [models, setModels] = useState(combo?.models || []); + const availableModelValues = Array.isArray(availableModels) + ? new Set(availableModels.map((model) => model.fullModel)) + : null; + const [models, setModels] = useState(() => ( + availableModelValues + ? (combo?.models || []).filter((model) => availableModelValues.has(model)) + : combo?.models || [] + )); const [showModelSelect, setShowModelSelect] = useState(false); const [saving, setSaving] = useState(false); const [nameError, setNameError] = useState(""); @@ -101,8 +108,11 @@ export default function ComboFormModal({ isOpen, combo, onClose, onSave, activeP const handleSave = async () => { if (!validateName(name)) return; + const eligibleModels = availableModelValues + ? models.filter((model) => availableModelValues.has(model)) + : models; setSaving(true); - await onSave({ name: forcePrefix + name.trim(), models }); + await onSave({ name: forcePrefix + name.trim(), models: eligibleModels }); setSaving(false); }; @@ -169,6 +179,7 @@ export default function ComboFormModal({ isOpen, combo, onClose, onSave, activeP setShowModelSelect(false)} onSelect={handleAddModel} onDeselect={handleDeselectModel} activeProviders={activeProviders} modelAliases={modelAliases} + availableModels={availableModels} title="Add Model to Combo" kindFilter={kindFilter} addedModelValues={models} closeOnSelect={false} /> diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js index 9e01a68d..72ad308e 100644 --- a/src/shared/components/ModelSelectModal.js +++ b/src/shared/components/ModelSelectModal.js @@ -79,8 +79,11 @@ export default function ModelSelectModal({ }; useEffect(() => { - if (isOpen) fetchProviderNodes(); - }, [isOpen]); + // An injected catalog already includes all provider display metadata. Avoid + // the admin-only provider-nodes endpoint for member-facing selectors such + // as the Combos page. + if (isOpen && !Array.isArray(availableModels)) fetchProviderNodes(); + }, [isOpen, availableModels]); const fetchCustomModels = async () => { try { diff --git a/tests/unit/connected-models-route.test.js b/tests/unit/connected-models-route.test.js index ed76e46b..774d3925 100644 --- a/tests/unit/connected-models-route.test.js +++ b/tests/unit/connected-models-route.test.js @@ -58,9 +58,14 @@ describe("GET /api/models/connected", () => { requireUsageDashboardUser.mockReset(); getCapabilitiesForModel.mockReset(); - getModelAliases.mockResolvedValue({ "alpha/enabled": "preferred-alpha" }); + getModelAliases.mockResolvedValue({ "preferred-alpha": "alpha-alias/enabled" }); getDisabledModels.mockResolvedValue({ "alpha-alias": ["disabled"] }); - getCustomModels.mockResolvedValue([]); + getCustomModels.mockResolvedValue([ + { providerAlias: "alpha-alias", id: "enabled", name: "Enabled model", type: "llm" }, + { providerAlias: "alpha-alias", id: "disabled", name: "Disabled model", type: "llm" }, + { providerAlias: "alpha-alias", id: "embedding", name: "Embedding model", type: "embedding" }, + { providerAlias: "beta-alias", id: "inactive", name: "Inactive provider model", type: "llm" }, + ]); getProviderNodes.mockResolvedValue([]); getUsers.mockResolvedValue([{ id: "admin", role: "admin", isActive: true }]); getCapabilitiesForModel.mockReturnValue({ vision: false, search: true, reasoning: true }); @@ -70,7 +75,7 @@ describe("GET /api/models/connected", () => { ]); }); - it("returns every connected-provider model to an administrator, including disabled rows", async () => { + it("returns added models for a connected provider to an administrator, including disabled rows", async () => { requireUsageDashboardUser.mockResolvedValue({ id: "admin", role: "admin" }); const response = await GET(); @@ -79,22 +84,34 @@ describe("GET /api/models/connected", () => { expect(response.status).toBe(200); expect(body.models).toEqual([ expect.objectContaining({ - fullModel: "alpha/disabled", + fullModel: "alpha-alias/disabled", providerAlias: "alpha-alias", disabled: true, }), expect.objectContaining({ - fullModel: "alpha/enabled", + fullModel: "alpha-alias/enabled", alias: "preferred-alpha", disabled: false, caps: { vision: false, search: true, reasoning: true }, }), ]); expect(body.models).not.toEqual(expect.arrayContaining([ - expect.objectContaining({ fullModel: "beta/inactive" }), + expect.objectContaining({ model: "embedding" }), + expect.objectContaining({ fullModel: "beta-alias/inactive" }), ])); }); + it("does not include registry models without an explicit added-model record", async () => { + requireUsageDashboardUser.mockResolvedValue({ id: "admin", role: "admin" }); + getCustomModels.mockResolvedValue([]); + + const response = await GET(); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.models).toEqual([]); + }); + it("excludes disabled models for non-administrators", async () => { requireUsageDashboardUser.mockResolvedValue({ id: "member", role: "user" }); @@ -103,7 +120,7 @@ describe("GET /api/models/connected", () => { expect(response.status).toBe(200); expect(body.models).toEqual([ - expect.objectContaining({ fullModel: "alpha/enabled", disabled: false }), + expect.objectContaining({ fullModel: "alpha-alias/enabled", disabled: false }), ]); }); @@ -126,7 +143,7 @@ describe("GET /api/models/connected", () => { { providerAlias: providerId, id: "gpt-company", name: "Company GPT", type: "llm" }, { providerAlias: providerId, id: "company-embed", name: "Company Embed", type: "embedding" }, ]); - getModelAliases.mockResolvedValue({ [`${providerId}/gpt-company`]: "company-chat" }); + getModelAliases.mockResolvedValue({ "company-chat": `${providerId}/gpt-company` }); const response = await GET(); const body = await response.json();