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
onSetStrategy({ fallbackStrategy: e.target.value })}
+ onChange={(e) => onSetStrategy({
+ fallbackStrategy: e.target.value,
+ ...(!judge && strategy.judgeModel ? { judgeModel: "" } : {}),
+ })}
selectClassName="py-1.5 text-xs"
/>
@@ -452,9 +460,12 @@ function ModelItem({ id, index, model, isFirst, isLast, onEdit, onMoveUp, onMove
}
function ComboFormModal({ isOpen, combo, onClose, onSave, availableModels = [], kindFilter = null }) {
+ const availableModelValues = new Set(availableModels.map((model) => model.fullModel));
// Initialize state with combo values - key prop on parent handles reset on remount
const [name, setName] = useState(combo?.name || "");
- const [models, setModels] = useState(combo?.models || []);
+ const [models, setModels] = useState(() => (
+ (combo?.models || []).filter((model) => availableModelValues.has(model))
+ ));
const [showModelSelect, setShowModelSelect] = useState(false);
const [saving, setSaving] = useState(false);
const [nameError, setNameError] = useState("");
@@ -528,8 +539,9 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, availableModels = [],
const handleSave = async () => {
if (!validateName(name)) return;
+ const eligibleModels = models.filter((model) => availableModelValues.has(model));
setSaving(true);
- await onSave({ name: name.trim(), models });
+ await onSave({ name: name.trim(), models: eligibleModels });
setSaving(false);
};
diff --git a/src/app/(dashboard)/dashboard/mitm/MitmPageClient.js b/src/app/(dashboard)/dashboard/mitm/MitmPageClient.js
index 2b0169b3..e3f5afb4 100644
--- a/src/app/(dashboard)/dashboard/mitm/MitmPageClient.js
+++ b/src/app/(dashboard)/dashboard/mitm/MitmPageClient.js
@@ -2,75 +2,49 @@
import { useState, useEffect } from "react";
import { MITM_TOOLS } from "@/shared/constants/cliTools";
-import { getModelsByProviderId } from "@/shared/constants/models";
-import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import { MitmServerCard, MitmToolCard } from "@/app/(dashboard)/dashboard/cli-tools/components";
export default function MitmPageClient() {
const [connections, setConnections] = useState([]);
const [apiKeys, setApiKeys] = useState([]);
const [modelAliases, setModelAliases] = useState({});
+ const [availableModels, setAvailableModels] = useState([]);
const [cloudEnabled, setCloudEnabled] = useState(false);
const [expandedTool, setExpandedTool] = useState(null);
const [mitmStatus, setMitmStatus] = useState({ running: false, certExists: false, dnsStatus: {}, hasCachedPassword: false });
useEffect(() => {
- fetchConnections();
- fetchApiKeys();
- fetchAliases();
- fetchCloudSettings();
+ let cancelled = false;
+
+ Promise.all([
+ fetch("/api/providers"),
+ fetch("/api/keys"),
+ fetch("/api/models/alias"),
+ fetch("/api/models/connected", { cache: "no-store" }),
+ fetch("/api/settings"),
+ ]).then(async ([connectionsRes, keysRes, aliasesRes, modelsRes, settingsRes]) => {
+ const [connectionsData, keysData, aliasesData, modelsData, settingsData] = await Promise.all([
+ connectionsRes.ok ? connectionsRes.json() : {},
+ keysRes.ok ? keysRes.json() : {},
+ aliasesRes.ok ? aliasesRes.json() : {},
+ modelsRes.ok ? modelsRes.json() : {},
+ settingsRes.ok ? settingsRes.json() : {},
+ ]);
+ if (cancelled) return;
+
+ setConnections(connectionsData.connections || []);
+ setApiKeys(keysData.keys || []);
+ setModelAliases(aliasesData.aliases || {});
+ setAvailableModels((modelsData.models || []).filter((model) => !model.disabled));
+ setCloudEnabled(settingsData.cloudEnabled || false);
+ }).catch(() => {});
+
+ return () => { cancelled = true; };
}, []);
- const fetchConnections = async () => {
- try {
- const res = await fetch("/api/providers");
- if (res.ok) {
- const data = await res.json();
- setConnections(data.connections || []);
- }
- } catch { /* ignore */ }
- };
-
- const fetchApiKeys = async () => {
- try {
- const res = await fetch("/api/keys");
- if (res.ok) {
- const data = await res.json();
- setApiKeys(data.keys || []);
- }
- } catch { /* ignore */ }
- };
-
- const fetchAliases = async () => {
- try {
- const res = await fetch("/api/models/alias");
- if (res.ok) {
- const data = await res.json();
- setModelAliases(data.aliases || {});
- }
- } catch { /* ignore */ }
- };
-
- const fetchCloudSettings = async () => {
- try {
- const res = await fetch("/api/settings");
- if (res.ok) {
- const data = await res.json();
- setCloudEnabled(data.cloudEnabled || false);
- }
- } catch { /* ignore */ }
- };
-
const getActiveProviders = () => connections.filter(c => c.isActive !== false);
- const hasActiveProviders = () => {
- const active = getActiveProviders();
- return active.some(conn =>
- getModelsByProviderId(conn.provider).length > 0 ||
- isOpenAICompatibleProvider(conn.provider) ||
- isAnthropicCompatibleProvider(conn.provider)
- );
- };
+ const hasActiveProviders = () => availableModels.length > 0;
const mitmTools = Object.entries(MITM_TOOLS);
@@ -105,6 +79,7 @@ export default function MitmPageClient() {
isWin={mitmStatus.isWin === true}
apiKeys={apiKeys}
activeProviders={getActiveProviders()}
+ availableModels={availableModels}
hasActiveProviders={hasActiveProviders()}
modelAliases={modelAliases}
cloudEnabled={cloudEnabled}
diff --git a/src/app/(dashboard)/dashboard/models/page.js b/src/app/(dashboard)/dashboard/models/page.js
index 1be7db32..4c215342 100644
--- a/src/app/(dashboard)/dashboard/models/page.js
+++ b/src/app/(dashboard)/dashboard/models/page.js
@@ -65,7 +65,7 @@ function ProviderModelsCard({ group, canManage, onSetModelDisabled, onSetModelsD
{group.provider.name}
- {group.models.length} available model{group.models.length === 1 ? "" : "s"}
+ {group.models.length} added model{group.models.length === 1 ? "" : "s"}
@@ -254,8 +254,8 @@ export default function ModelsPage() {
{canManage
- ? "Select which connected-provider models are available through the API."
- : "Browse models currently available through your connected providers."}
+ ? "Manage models explicitly added from connected providers."
+ : "Browse added models currently available through connected providers."}
@@ -276,7 +276,7 @@ export default function ModelsPage() {
{providerCount}
-
Available models
+
Added models
{models.length}
@@ -296,7 +296,7 @@ export default function ModelsPage() {
search_off
{models.length === 0
- ? "No models are available. Add and activate a provider connection first."
+ ? "No models have been added. Open a connected provider and add a model first."
: "No models match your search."}
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js
index b011f58b..6476d349 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js
+++ b/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js
@@ -1,7 +1,7 @@
import PropTypes from "prop-types";
import { CapacityBadges } from "@/shared/components";
-export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting, onDisable, caps, thinkingSuffix }) {
+export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isAdded, isFree, onDeleteAlias, onAdd, onRemove, onTest, isTesting, onDisable, caps, thinkingSuffix }) {
const displayModel = thinkingSuffix ? `${fullModel}(${thinkingSuffix})` : fullModel;
const borderColor = testStatus === "ok"
? "border-green-500/40"
@@ -60,11 +60,20 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test
{copied === `model-${model.id}` ? "Copied!" : "Copy"}
- {isCustom ? (
+ {onAdd && !isAdded ? (
+ add
+ Add
+
+ ) : onRemove || isCustom ? (
+
close
@@ -92,8 +101,11 @@ ModelRow.propTypes = {
onCopy: PropTypes.func.isRequired,
testStatus: PropTypes.oneOf(["ok", "error"]),
isCustom: PropTypes.bool,
+ isAdded: PropTypes.bool,
isFree: PropTypes.bool,
onDeleteAlias: PropTypes.func,
+ onAdd: PropTypes.func,
+ onRemove: PropTypes.func,
onTest: PropTypes.func,
isTesting: PropTypes.bool,
onDisable: PropTypes.func,
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js
index 845eed85..6f96bbe8 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/page.js
+++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js
@@ -1058,14 +1058,24 @@ export default function ProviderDetailPage() {
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)),
].filter((m) => { const k = getModelKind(m); return !k || k === "llm"; });
const disabledSet = new Set(disabledModelIds);
+ const addedModelIds = new Set(
+ customModels
+ .filter((entry) => (
+ entry.providerAlias === providerStorageAlias
+ && (entry.kind || entry.type || "llm") === "llm"
+ && entry.id
+ ))
+ .map((entry) => entry.id),
+ );
const displayModels = allModels.filter((m) => !disabledSet.has(m.id));
const disabledDisplayModels = allModels.filter((m) => disabledSet.has(m.id));
const customModelRows = getProviderCustomModelRows({
customModels,
modelAliases,
providerAlias: providerStorageAlias,
- builtInModels: models,
+ builtInModels: allModels,
type: "llm",
+ includeLegacyAliases: false,
});
return (
@@ -1091,6 +1101,7 @@ export default function ProviderDetailPage() {
onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined}
isTesting={testingModelIds.has(model.id)}
isCustom
+ isAdded
isFree={false}
caps={getCaps(`${providerId}/${model.id}`)}
thinkingSuffix={resolveThinkingSuffix(model.id)}
@@ -1117,7 +1128,11 @@ export default function ProviderDetailPage() {
onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined}
isTesting={testingModelIds.has(model.id)}
isFree={model.isFree}
- onDisable={() => handleDisableModel(model.id)}
+ isAdded={addedModelIds.has(model.id)}
+ onAdd={() => handleAddCustomModel(model.id, "llm", providerStorageAlias)}
+ onRemove={addedModelIds.has(model.id)
+ ? () => handleDeleteCustomModel(model.id, "llm", providerStorageAlias)
+ : undefined}
caps={getCaps(`${providerId}/${model.id}`)}
thinkingSuffix={resolveThinkingSuffix(model.id)}
/>
@@ -1597,7 +1612,7 @@ export default function ProviderDetailPage() {
- {"Available Models"}
+ {"Provider Models"}
{providerThinkingLevels && (
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();