fix: update the permission for viewing models

This commit is contained in:
2026-07-16 15:07:32 +07:00
parent 5c8d9f80b0
commit e3cf2e7708
14 changed files with 241 additions and 302 deletions
@@ -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() {
<div className="absolute left-0 top-[calc(100%+10px)] z-30 w-[min(520px,calc(100vw-2rem))] overflow-hidden rounded-[20px] border border-white/10 bg-[#262626] shadow-2xl shadow-black/50">
<div className="border-b border-white/10 px-4 py-3">
<p className="text-xs uppercase tracking-[0.22em] text-white/45">Models</p>
<p className="text-sm text-white/75">Only from connected providers</p>
<p className="text-sm text-white/75">Added models from connected providers</p>
</div>
<div className="max-h-[60vh] overflow-y-auto p-2 custom-scrollbar">
{providerGroups.map((group) => (
@@ -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,
};
@@ -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}
/>
<ManualConfigModal isOpen={configModalOpen} onClose={() => setConfigModalOpen(false)} title={`${tool.name} configuration`} configs={configs} />
</Card>
@@ -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}
/>
</Card>
);
@@ -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}`}
/>
</>
+16 -4
View File
@@ -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
<Select
options={STRATEGY_OPTIONS}
value={current}
onChange={(e) => onSetStrategy({ fallbackStrategy: e.target.value })}
onChange={(e) => onSetStrategy({
fallbackStrategy: e.target.value,
...(!judge && strategy.judgeModel ? { judgeModel: "" } : {}),
})}
selectClassName="py-1.5 text-xs"
/>
</div>
@@ -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);
};
@@ -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}
+5 -5
View File
@@ -65,7 +65,7 @@ function ProviderModelsCard({ group, canManage, onSetModelDisabled, onSetModelsD
<div className="min-w-0">
<h2 className="truncate font-semibold text-text-main">{group.provider.name}</h2>
<p className="mt-0.5 text-xs text-text-muted">
{group.models.length} available model{group.models.length === 1 ? "" : "s"}
{group.models.length} added model{group.models.length === 1 ? "" : "s"}
</p>
</div>
<span className={`material-symbols-outlined ml-auto text-[18px] text-text-muted transition-transform ${expanded ? "rotate-180" : ""}`}>
@@ -254,8 +254,8 @@ export default function ModelsPage() {
</div>
<p className="mt-1 text-sm text-text-muted">
{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."}
</p>
</div>
<label className="relative block w-full sm:w-80">
@@ -276,7 +276,7 @@ export default function ModelsPage() {
<p className="mt-1 text-lg font-semibold tabular-nums text-text-main">{providerCount}</p>
</div>
<div className="bg-surface px-4 py-3">
<p className="text-xs font-medium text-text-muted">Available models</p>
<p className="text-xs font-medium text-text-muted">Added models</p>
<p className="mt-1 text-lg font-semibold tabular-nums text-text-main">{models.length}</p>
</div>
</section>
@@ -296,7 +296,7 @@ export default function ModelsPage() {
<span className="material-symbols-outlined text-[32px] text-text-muted">search_off</span>
<p className="mt-2 text-sm text-text-muted">
{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."}
</p>
</Card>
@@ -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"}
</span>
</div>
{isCustom ? (
{onAdd && !isAdded ? (
<button
onClick={onDeleteAlias}
onClick={onAdd}
className="ml-auto inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium text-primary transition-colors hover:bg-primary/10"
title="Add model to the dashboard catalog"
>
<span className="material-symbols-outlined text-sm">add</span>
Add
</button>
) : onRemove || isCustom ? (
<button
onClick={onRemove || onDeleteAlias}
className="ml-auto rounded p-0.5 text-text-muted opacity-100 transition-opacity hover:bg-red-500/10 hover:text-red-500 sm:opacity-0 sm:group-hover:opacity-100"
title="Remove custom model"
title="Remove model from the dashboard catalog"
>
<span className="material-symbols-outlined text-sm">close</span>
</button>
@@ -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,
@@ -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() {
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold">
{"Available Models"}
{"Provider Models"}
</h2>
{providerThinkingLevels && (
<select
+66 -44
View File
@@ -8,7 +8,6 @@ import {
import { getUsers } from "@/lib/db";
import { disableModels, enableModels, getDisabledModels } from "@/lib/disabledModelsDb";
import { requireAdminUser, requireUsageDashboardUser } from "@/lib/auth/currentUser";
import { AI_MODELS } from "@/shared/constants/models";
import {
AI_PROVIDERS,
getProviderAlias,
@@ -35,6 +34,18 @@ function getConnectionProviderAliases(connection) {
return [...new Set([connection.provider, alias])];
}
function getModelType(model) {
return model?.kind || model?.type || "llm";
}
function getAliasByFullModel(modelAliases) {
return new Map(
Object.entries(modelAliases)
.filter(([, fullModel]) => 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)
+14 -3
View File
@@ -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
<ModelSelectModal isOpen={showModelSelect} onClose={() => setShowModelSelect(false)}
onSelect={handleAddModel} onDeselect={handleDeselectModel}
activeProviders={activeProviders} modelAliases={modelAliases}
availableModels={availableModels}
title="Add Model to Combo" kindFilter={kindFilter}
addedModelValues={models} closeOnSelect={false} />
</>
+5 -2
View File
@@ -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 {
+25 -8
View File
@@ -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();