diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js
index b1bc45df..3c6f78cb 100644
--- a/src/app/(dashboard)/dashboard/combos/page.js
+++ b/src/app/(dashboard)/dashboard/combos/page.js
@@ -7,7 +7,6 @@ import { CSS } from "@dnd-kit/utilities";
import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers";
import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ConfirmModal, CapacityBadges, Select } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
-import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
// Validate combo name: only a-z, A-Z, 0-9, -, _
const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/;
@@ -17,7 +16,7 @@ export default function CombosPage() {
const [loading, setLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingCombo, setEditingCombo] = useState(null);
- const [activeProviders, setActiveProviders] = useState([]);
+ const [connectedModels, setConnectedModels] = useState([]);
const [comboStrategies, setComboStrategies] = useState({});
const [modelCaps, setModelCaps] = useState({});
const [confirmState, setConfirmState] = useState(null);
@@ -29,23 +28,19 @@ export default function CombosPage() {
async function fetchData() {
try {
- const [combosRes, providersRes, settingsRes, modelsRes] = await Promise.all([
+ const [combosRes, settingsRes, modelsRes] = await Promise.all([
fetch("/api/combos"),
- fetch("/api/providers"),
fetch("/api/settings"),
- fetch("/api/models"),
+ fetch("/api/models/connected", { cache: "no-store" }),
]);
const combosData = await combosRes.json();
- const providersData = await providersRes.json();
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
// Only LLM combos here - webSearch/webFetch combos belong to media-providers/web
if (combosRes.ok) setCombos((combosData.combos || []).filter(c => !c.kind || c.kind === "llm"));
- if (providersRes.ok) {
- setActiveProviders(providersData.connections || []);
- }
if (modelsRes.ok) {
const md = await modelsRes.json();
+ setConnectedModels(md.models || []);
// Build fullModel -> caps map for badge lookup
const map = {};
for (const m of md.models || []) if (m.caps) map[m.fullModel] = m.caps;
@@ -59,6 +54,11 @@ export default function CombosPage() {
}
}
+ // The Models page is the source of truth for eligible models. Administrators
+ // can still see disabled rows there to manage them, but disabled models must
+ // not be added to new or edited combos because they cannot serve requests.
+ const selectableModels = connectedModels.filter((model) => !model.disabled);
+
const handleCreate = async (data) => {
try {
const res = await fetch("/api/combos", {
@@ -190,7 +190,7 @@ export default function CombosPage() {
key={combo.id}
combo={combo}
modelCaps={modelCaps}
- activeProviders={activeProviders}
+ availableModels={selectableModels}
copied={copied}
onCopy={copy}
onEdit={() => setEditingCombo(combo)}
@@ -208,7 +208,7 @@ export default function CombosPage() {
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSave={handleCreate}
- activeProviders={activeProviders}
+ availableModels={selectableModels}
/>
{/* Edit Modal - Use key to force remount and reset state */}
@@ -218,7 +218,7 @@ export default function CombosPage() {
combo={editingCombo}
onClose={() => setEditingCombo(null)}
onSave={(data) => handleUpdate(editingCombo.id, data)}
- activeProviders={activeProviders}
+ availableModels={selectableModels}
/>
{/* Confirm Delete Modal */}
@@ -240,7 +240,7 @@ const STRATEGY_OPTIONS = [
{ value: "fusion", label: "Fusion — panel + judge" },
];
-function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, onSetStrategy }) {
+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 || "";
@@ -344,7 +344,7 @@ function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy
isOpen={showJudgeSelect}
onClose={() => setShowJudgeSelect(false)}
onSelect={(m) => { onSetStrategy({ judgeModel: m?.value || "" }); setShowJudgeSelect(false); }}
- activeProviders={activeProviders}
+ availableModels={availableModels}
title="Select Judge Model"
addedModelValues={judge ? [judge] : []}
closeOnSelect={true}
@@ -451,14 +451,13 @@ function ModelItem({ id, index, model, isFirst, isLast, onEdit, onMoveUp, onMove
);
}
-function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindFilter = null }) {
+function ComboFormModal({ isOpen, combo, onClose, onSave, availableModels = [], kindFilter = null }) {
// 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 [showModelSelect, setShowModelSelect] = useState(false);
const [saving, setSaving] = useState(false);
const [nameError, setNameError] = useState("");
- const [modelAliases, setModelAliases] = useState({});
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
@@ -479,21 +478,6 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF
}
};
- const fetchModalData = async () => {
- try {
- const aliasesRes = await fetch("/api/models/alias");
- if (!aliasesRes.ok) return;
- const aliasesData = await aliasesRes.json();
- setModelAliases(aliasesData.aliases || {});
- } catch (error) {
- console.error("Error fetching modal data:", error);
- }
- };
-
- useEffect(() => {
- if (isOpen) fetchModalData();
- }, [isOpen]);
-
const validateName = (value) => {
if (!value.trim()) {
setNameError("Name is required");
@@ -642,8 +626,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF
onClose={() => setShowModelSelect(false)}
onSelect={handleAddModel}
onDeselect={handleDeselectModel}
- activeProviders={activeProviders}
- modelAliases={modelAliases}
+ availableModels={availableModels}
title="Add Model to Combo"
kindFilter={kindFilter}
addedModelValues={models}
diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js
index 958c4f10..6db6c9e8 100644
--- a/src/app/(dashboard)/dashboard/providers/page.js
+++ b/src/app/(dashboard)/dashboard/providers/page.js
@@ -110,6 +110,7 @@ export default function ProvidersPage() {
const searchQuery = useHeaderSearchStore((s) => s.query);
const registerSearch = useHeaderSearchStore((s) => s.register);
const unregisterSearch = useHeaderSearchStore((s) => s.unregister);
+ const isAdmin = user?.role === "admin";
useEffect(() => {
registerSearch("Search providers...");
@@ -335,8 +336,10 @@ export default function ProvidersPage() {
freeEntries.length > 0 ||
freeTierEntries.length > 0 ||
apikeyEntries.length > 0 ||
- compatibleProviders.length > 0 ||
- anthropicCompatibleProviders.length > 0;
+ (isAdmin && (
+ compatibleProviders.length > 0 ||
+ anthropicCompatibleProviders.length > 0
+ ));
return (
@@ -349,7 +352,8 @@ export default function ProvidersPage() {
)}
- {/* Custom Providers (OpenAI/Anthropic Compatible) — dynamic */}
+ {/* Custom provider configuration is administered centrally. */}
+ {isAdmin && (
@@ -400,6 +404,7 @@ export default function ProvidersPage() {
)}
+ )}
{/* OAuth Providers */}
{oauthEntries.length > 0 && (
@@ -577,7 +582,7 @@ export default function ProvidersPage() {
*/}
- setShowAddCompatibleModal(false)}
@@ -585,8 +590,8 @@ export default function ProvidersPage() {
setProviderNodes((prev) => [...prev, node]);
setShowAddCompatibleModal(false);
}}
- />
- }
+ {isAdmin && setShowAddAnthropicCompatibleModal(false)}
@@ -594,7 +599,7 @@ export default function ProvidersPage() {
setProviderNodes((prev) => [...prev, node]);
setShowAddAnthropicCompatibleModal(false);
}}
- />
+ />}
{/* Test Results Modal */}
{testResults && (
diff --git a/src/app/api/models/connected/route.js b/src/app/api/models/connected/route.js
index bbaa19f6..703b8c7f 100644
--- a/src/app/api/models/connected/route.js
+++ b/src/app/api/models/connected/route.js
@@ -1,9 +1,21 @@
import { NextResponse } from "next/server";
-import { getModelAliases, getProviderConnections } from "@/models";
+import {
+ getCustomModels,
+ getModelAliases,
+ getProviderConnections,
+ getProviderNodes,
+} from "@/models";
+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, getProviderByAlias } from "@/shared/constants/providers";
+import {
+ AI_PROVIDERS,
+ getProviderAlias,
+ getProviderByAlias,
+ isAnthropicCompatibleProvider,
+ isOpenAICompatibleProvider,
+} from "@/shared/constants/providers";
import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
export const dynamic = "force-dynamic";
@@ -34,6 +46,20 @@ function getProviderLabel(providerAlias) {
};
}
+function getCompatibleProviderLabel(providerId, node, connection) {
+ const isAnthropic = node?.type === "anthropic-compatible"
+ || isAnthropicCompatibleProvider(providerId);
+
+ return {
+ id: providerId,
+ alias: providerId,
+ name: node?.name || connection?.providerSpecificData?.nodeName
+ || (isAnthropic ? "Anthropic Compatible" : "OpenAI Compatible"),
+ color: isAnthropic ? "#D97757" : "#10A37F",
+ textIcon: isAnthropic ? "AC" : "OC",
+ };
+}
+
function getForbiddenResponse(error) {
if (error.message === "Unauthorized") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -49,10 +75,13 @@ export async function GET() {
try {
const user = await requireUsageDashboardUser();
- const [connections, disabledModels, modelAliases] = await Promise.all([
+ const [connections, customModels, disabledModels, modelAliases, providerNodes, users] = await Promise.all([
getProviderConnections(),
+ getCustomModels(),
getDisabledModels(),
getModelAliases(),
+ getProviderNodes(),
+ getUsers(),
]);
const connectionCountByAlias = new Map();
@@ -64,7 +93,7 @@ export async function GET() {
}
}
- const models = AI_MODELS
+ const staticModels = AI_MODELS
.filter((model) => connectionCountByAlias.has(model.provider))
.map((model) => {
const providerAlias = getProviderAlias(model.provider) || model.provider;
@@ -84,7 +113,66 @@ export async function GET() {
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
+ // combo model values route directly to the correct credential pool.
+ const adminOwnerIds = new Set(
+ users
+ .filter((entry) => entry.role === "admin" && entry.isActive !== false)
+ .map((entry) => entry.id),
+ );
+ const nodeById = new Map(providerNodes.map((node) => [node.id, node]));
+ const viableCompatibleConnections = new Map();
+ for (const connection of connections) {
+ if (!isViableConnection(connection)) continue;
+ if (!adminOwnerIds.has(connection.ownerId)) continue;
+ if (
+ !isOpenAICompatibleProvider(connection.provider)
+ && !isAnthropicCompatibleProvider(connection.provider)
+ ) {
+ continue;
+ }
+ if (!viableCompatibleConnections.has(connection.provider)) {
+ viableCompatibleConnections.set(connection.provider, connection);
+ }
+ }
+
+ const compatibleModels = [];
+ for (const [providerId, connection] of viableCompatibleConnections) {
+ const provider = getCompatibleProviderLabel(providerId, nodeById.get(providerId), connection);
+ const disabled = disabledModels[providerId] || [];
+
+ for (const customModel of customModels) {
+ const kind = customModel.kind || customModel.type || "llm";
+ if (customModel.providerAlias !== providerId || kind !== "llm" || !customModel.id) continue;
+
+ const modelId = String(customModel.id).trim();
+ if (!modelId) continue;
+
+ const fullModel = `${providerId}/${modelId}`;
+ const caps = getCapabilitiesForModel(providerId, modelId);
+ compatibleModels.push({
+ provider,
+ providerAlias: providerId,
+ model: modelId,
+ name: customModel.name || modelId,
+ fullModel,
+ alias: modelAliases[fullModel] || modelId,
+ disabled: disabled.includes(modelId),
+ isCustom: true,
+ caps: {
+ vision: caps.vision,
+ search: caps.search,
+ reasoning: caps.reasoning,
+ },
+ });
+ }
+ }
+
+ const models = [...staticModels, ...compatibleModels]
.filter((model) => user.role === "admin" || !model.disabled)
.sort((a, b) => (
a.provider.name.localeCompare(b.provider.name)
diff --git a/src/app/api/models/custom/route.js b/src/app/api/models/custom/route.js
index 76e14c96..c10dd2e2 100644
--- a/src/app/api/models/custom/route.js
+++ b/src/app/api/models/custom/route.js
@@ -1,8 +1,30 @@
import { NextResponse } from "next/server";
-import { getCustomModels, addCustomModel, deleteCustomModel } from "@/models";
+import {
+ getCustomModels,
+ addCustomModel,
+ deleteCustomModel,
+} from "@/models";
+import { requireAdminUser } from "@/lib/auth/currentUser";
export const dynamic = "force-dynamic";
+function getAccessErrorResponse(error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ if (error.message === "Forbidden") {
+ return NextResponse.json({ error: "Administrator access required" }, { status: 403 });
+ }
+ return null;
+}
+
+async function requireCustomModelCatalogAdmin() {
+ // Custom-model records are a shared catalog, not connection-owned data.
+ // Restrict mutations to administrators for every provider so one dashboard
+ // user cannot alter models available to other users.
+ await requireAdminUser();
+}
+
// GET /api/models/custom - List all custom models
export async function GET() {
try {
@@ -21,9 +43,13 @@ export async function POST(request) {
if (!providerAlias || !id) {
return NextResponse.json({ error: "providerAlias and id required" }, { status: 400 });
}
+ await requireCustomModelCatalogAdmin();
const added = await addCustomModel({ providerAlias, id, type: type || "llm", name });
return NextResponse.json({ success: true, added });
} catch (error) {
+ const accessError = getAccessErrorResponse(error);
+ if (accessError) return accessError;
+
console.log("Error adding custom model:", error);
return NextResponse.json({ error: "Failed to add custom model" }, { status: 500 });
}
@@ -39,9 +65,13 @@ export async function DELETE(request) {
if (!providerAlias || !id) {
return NextResponse.json({ error: "providerAlias and id required" }, { status: 400 });
}
+ await requireCustomModelCatalogAdmin();
await deleteCustomModel({ providerAlias, id, type });
return NextResponse.json({ success: true });
} catch (error) {
+ const accessError = getAccessErrorResponse(error);
+ if (accessError) return accessError;
+
console.log("Error deleting custom model:", error);
return NextResponse.json({ error: "Failed to delete custom model" }, { status: 500 });
}
diff --git a/src/app/api/provider-nodes/[id]/route.js b/src/app/api/provider-nodes/[id]/route.js
index a758d0ab..ab8411fb 100644
--- a/src/app/api/provider-nodes/[id]/route.js
+++ b/src/app/api/provider-nodes/[id]/route.js
@@ -1,9 +1,21 @@
import { NextResponse } from "next/server";
import { deleteProviderConnectionsByProvider, deleteProviderNode, getProviderConnections, getProviderNodeById, updateProviderConnection, updateProviderNode } from "@/models";
+import { requireAdminUser } from "@/lib/auth/currentUser";
+
+function getAccessErrorResponse(error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ if (error.message === "Forbidden") {
+ return NextResponse.json({ error: "Administrator access required" }, { status: 403 });
+ }
+ return null;
+}
// PUT /api/provider-nodes/[id] - Update provider node
export async function PUT(request, { params }) {
try {
+ await requireAdminUser();
const { id } = await params;
const body = await request.json();
const { name, prefix, apiType, baseUrl } = body;
@@ -75,6 +87,9 @@ export async function PUT(request, { params }) {
return NextResponse.json({ node: updated });
} catch (error) {
+ const accessError = getAccessErrorResponse(error);
+ if (accessError) return accessError;
+
console.log("Error updating provider node:", error);
return NextResponse.json({ error: "Failed to update provider node" }, { status: 500 });
}
@@ -83,6 +98,7 @@ export async function PUT(request, { params }) {
// DELETE /api/provider-nodes/[id] - Delete provider node and its connections
export async function DELETE(request, { params }) {
try {
+ await requireAdminUser();
const { id } = await params;
const node = await getProviderNodeById(id);
@@ -95,6 +111,9 @@ export async function DELETE(request, { params }) {
return NextResponse.json({ success: true });
} catch (error) {
+ const accessError = getAccessErrorResponse(error);
+ if (accessError) return accessError;
+
console.log("Error deleting provider node:", error);
return NextResponse.json({ error: "Failed to delete provider node" }, { status: 500 });
}
diff --git a/src/app/api/provider-nodes/route.js b/src/app/api/provider-nodes/route.js
index 92c89196..32cb2fc1 100644
--- a/src/app/api/provider-nodes/route.js
+++ b/src/app/api/provider-nodes/route.js
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { createProviderNode, getProviderNodes } from "@/models";
+import { requireAdminUser } from "@/lib/auth/currentUser";
import { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX, CUSTOM_EMBEDDING_PREFIX } from "@/shared/constants/providers";
import { generateId } from "@/shared/utils";
@@ -17,12 +18,26 @@ const CUSTOM_EMBEDDING_DEFAULTS = {
baseUrl: "https://api.openai.com/v1",
};
+function getAccessErrorResponse(error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ if (error.message === "Forbidden") {
+ return NextResponse.json({ error: "Administrator access required" }, { status: 403 });
+ }
+ return null;
+}
+
// GET /api/provider-nodes - List all provider nodes
export async function GET() {
try {
+ await requireAdminUser();
const nodes = await getProviderNodes();
return NextResponse.json({ nodes });
} catch (error) {
+ const accessError = getAccessErrorResponse(error);
+ if (accessError) return accessError;
+
console.log("Error fetching provider nodes:", error);
return NextResponse.json({ error: "Failed to fetch provider nodes" }, { status: 500 });
}
@@ -31,6 +46,7 @@ export async function GET() {
// POST /api/provider-nodes - Create provider node
export async function POST(request) {
try {
+ await requireAdminUser();
const body = await request.json();
const { name, prefix, apiType, baseUrl, type } = body;
@@ -98,6 +114,9 @@ export async function POST(request) {
return NextResponse.json({ error: "Invalid provider node type" }, { status: 400 });
} catch (error) {
+ const accessError = getAccessErrorResponse(error);
+ if (accessError) return accessError;
+
console.log("Error creating provider node:", error);
return NextResponse.json({ error: "Failed to create provider node" }, { status: 500 });
}
diff --git a/src/app/api/providers/[id]/route.js b/src/app/api/providers/[id]/route.js
index 0d980f22..93a958ea 100644
--- a/src/app/api/providers/[id]/route.js
+++ b/src/app/api/providers/[id]/route.js
@@ -6,6 +6,21 @@ import {
deleteProviderConnection,
} from "@/models";
import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
+import {
+ isAnthropicCompatibleProvider,
+ isCustomEmbeddingProvider,
+ isOpenAICompatibleProvider,
+} from "@/shared/constants/providers";
+
+function isAdministratorManagedProvider(provider) {
+ return isOpenAICompatibleProvider(provider)
+ || isAnthropicCompatibleProvider(provider)
+ || isCustomEmbeddingProvider(provider);
+}
+
+function canMutateConnection(user, connection) {
+ return !isAdministratorManagedProvider(connection.provider) || user.role === "admin";
+}
function normalizeProxyConfig(body = {}) {
const hasAnyProxyField =
@@ -92,7 +107,7 @@ export async function GET(request, { params }) {
export async function PUT(request, { params }) {
try {
const { id } = await params;
- const { ownerId } = await getProviderConnectionAccess();
+ const { user, ownerId } = await getProviderConnectionAccess();
const body = await request.json();
const {
name,
@@ -111,6 +126,9 @@ export async function PUT(request, { params }) {
if (!existing) {
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
}
+ if (!canMutateConnection(user, existing)) {
+ return NextResponse.json({ error: "Administrator access required" }, { status: 403 });
+ }
const proxyConfig = normalizeProxyConfig(body);
if (proxyConfig.error) {
@@ -184,12 +202,15 @@ export async function PUT(request, { params }) {
export async function DELETE(request, { params }) {
try {
const { id } = await params;
- const { ownerId } = await getProviderConnectionAccess();
+ const { user, ownerId } = await getProviderConnectionAccess();
const existing = await getProviderConnectionById(id, ownerId);
if (!existing) {
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
}
+ if (!canMutateConnection(user, existing)) {
+ return NextResponse.json({ error: "Administrator access required" }, { status: 403 });
+ }
const deleted = await deleteProviderConnection(id);
if (!deleted) {
diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js
index a1b5b054..045bec41 100644
--- a/src/app/api/providers/route.js
+++ b/src/app/api/providers/route.js
@@ -111,17 +111,21 @@ export async function POST(request) {
// Dual-auth providers (e.g. codebuddy-cn, xai) live under category "oauth" but also
// accept an API key via authModes — they aren't in APIKEY_PROVIDERS, so allow them here.
const supportsApiKeyMode = !!AI_PROVIDERS[provider]?.authModes?.includes("apikey");
+ const isCompatibleProvider = isOpenAICompatibleProvider(provider)
+ || isAnthropicCompatibleProvider(provider)
+ || isCustomEmbeddingProvider(provider);
const isValidProvider = APIKEY_PROVIDERS[provider] ||
FREE_TIER_PROVIDERS[provider] ||
supportsApiKeyMode ||
isWebCookieProvider ||
- isOpenAICompatibleProvider(provider) ||
- isAnthropicCompatibleProvider(provider) ||
- isCustomEmbeddingProvider(provider);
+ isCompatibleProvider;
if (!provider || !isValidProvider) {
return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
}
+ if (isCompatibleProvider && user.role !== "admin") {
+ return NextResponse.json({ error: "Administrator access required" }, { status: 403 });
+ }
if (!apiKey && provider !== "ollama-local") {
return NextResponse.json({ error: `${isWebCookieProvider ? "Cookie value" : "API Key"} is required` }, { status: 400 });
}
diff --git a/tests/unit/connected-models-route.test.js b/tests/unit/connected-models-route.test.js
new file mode 100644
index 00000000..ed76e46b
--- /dev/null
+++ b/tests/unit/connected-models-route.test.js
@@ -0,0 +1,199 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const getModelAliases = vi.fn();
+const getProviderConnections = vi.fn();
+const getCustomModels = vi.fn();
+const getProviderNodes = vi.fn();
+const getUsers = vi.fn();
+const getDisabledModels = vi.fn();
+const requireUsageDashboardUser = vi.fn();
+const getCapabilitiesForModel = vi.fn();
+
+vi.mock("@/models", () => ({
+ getCustomModels,
+ getModelAliases,
+ getProviderConnections,
+ getProviderNodes,
+}));
+
+vi.mock("@/lib/db", () => ({ getUsers }));
+
+vi.mock("@/lib/disabledModelsDb", () => ({ getDisabledModels }));
+vi.mock("@/lib/auth/currentUser", () => ({
+ requireUsageDashboardUser,
+}));
+vi.mock("@/shared/constants/models", () => ({
+ AI_MODELS: [
+ { provider: "alpha", model: "enabled", name: "Enabled model" },
+ { provider: "alpha", model: "disabled", name: "Disabled model" },
+ { provider: "beta", model: "inactive", name: "Inactive provider model" },
+ ],
+}));
+vi.mock("@/shared/constants/providers", () => {
+ const providers = {
+ alpha: { id: "alpha", alias: "alpha-alias", name: "Alpha", color: "#111111" },
+ beta: { id: "beta", alias: "beta-alias", name: "Beta", color: "#222222" },
+ };
+
+ return {
+ AI_PROVIDERS: providers,
+ getProviderAlias: (providerId) => providers[providerId]?.alias || providerId,
+ getProviderByAlias: (providerId) => providers[providerId],
+ isOpenAICompatibleProvider: (providerId) => providerId.startsWith("openai-compatible-"),
+ isAnthropicCompatibleProvider: (providerId) => providerId.startsWith("anthropic-compatible-"),
+ };
+});
+vi.mock("open-sse/providers/capabilities.js", () => ({ getCapabilitiesForModel }));
+
+const { GET } = await import("../../src/app/api/models/connected/route.js");
+
+describe("GET /api/models/connected", () => {
+ beforeEach(() => {
+ getModelAliases.mockReset();
+ getProviderConnections.mockReset();
+ getCustomModels.mockReset();
+ getProviderNodes.mockReset();
+ getUsers.mockReset();
+ getDisabledModels.mockReset();
+ requireUsageDashboardUser.mockReset();
+ getCapabilitiesForModel.mockReset();
+
+ getModelAliases.mockResolvedValue({ "alpha/enabled": "preferred-alpha" });
+ getDisabledModels.mockResolvedValue({ "alpha-alias": ["disabled"] });
+ getCustomModels.mockResolvedValue([]);
+ getProviderNodes.mockResolvedValue([]);
+ getUsers.mockResolvedValue([{ id: "admin", role: "admin", isActive: true }]);
+ getCapabilitiesForModel.mockReturnValue({ vision: false, search: true, reasoning: true });
+ getProviderConnections.mockResolvedValue([
+ { provider: "alpha", isActive: true, apiKey: "secret" },
+ { provider: "beta", isActive: false, apiKey: "secret" },
+ ]);
+ });
+
+ it("returns every connected-provider model to an administrator, including disabled rows", async () => {
+ requireUsageDashboardUser.mockResolvedValue({ id: "admin", role: "admin" });
+
+ const response = await GET();
+ const body = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(body.models).toEqual([
+ expect.objectContaining({
+ fullModel: "alpha/disabled",
+ providerAlias: "alpha-alias",
+ disabled: true,
+ }),
+ expect.objectContaining({
+ fullModel: "alpha/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" }),
+ ]));
+ });
+
+ it("excludes disabled models for non-administrators", async () => {
+ requireUsageDashboardUser.mockResolvedValue({ id: "member", role: "user" });
+
+ const response = await GET();
+ const body = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(body.models).toEqual([
+ expect.objectContaining({ fullModel: "alpha/enabled", disabled: false }),
+ ]);
+ });
+
+ it("includes administrator-managed compatible-provider models for non-administrators", async () => {
+ const providerId = "openai-compatible-test-node";
+ requireUsageDashboardUser.mockResolvedValue({ id: "member", role: "user" });
+ getProviderConnections.mockResolvedValue([
+ {
+ provider: providerId,
+ isActive: true,
+ apiKey: "admin-secret",
+ ownerId: "admin",
+ providerSpecificData: { nodeName: "Company Gateway" },
+ },
+ ]);
+ getProviderNodes.mockResolvedValue([
+ { id: providerId, type: "openai-compatible", name: "Company Gateway" },
+ ]);
+ getCustomModels.mockResolvedValue([
+ { 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" });
+
+ const response = await GET();
+ const body = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(body.models).toEqual([
+ expect.objectContaining({
+ provider: expect.objectContaining({ id: providerId, name: "Company Gateway" }),
+ providerAlias: providerId,
+ model: "gpt-company",
+ name: "Company GPT",
+ fullModel: `${providerId}/gpt-company`,
+ alias: "company-chat",
+ isCustom: true,
+ }),
+ ]);
+ expect(body.models).not.toEqual(expect.arrayContaining([
+ expect.objectContaining({ model: "company-embed" }),
+ ]));
+ });
+
+ it("does not expose disabled compatible-provider models to non-administrators", async () => {
+ const providerId = "anthropic-compatible-test-node";
+ requireUsageDashboardUser.mockResolvedValue({ id: "member", role: "user" });
+ getProviderConnections.mockResolvedValue([
+ { provider: providerId, isActive: true, apiKey: "admin-secret", ownerId: "admin" },
+ ]);
+ getProviderNodes.mockResolvedValue([
+ { id: providerId, type: "anthropic-compatible", name: "Company Anthropic" },
+ ]);
+ getCustomModels.mockResolvedValue([
+ { providerAlias: providerId, id: "claude-company", type: "llm" },
+ ]);
+ getDisabledModels.mockResolvedValue({ [providerId]: ["claude-company"] });
+
+ const response = await GET();
+ const body = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(body.models).not.toEqual(expect.arrayContaining([
+ expect.objectContaining({ fullModel: `${providerId}/claude-company` }),
+ ]));
+ });
+
+ it("does not treat a non-admin compatible-provider connection as shared", async () => {
+ const providerId = "openai-compatible-user-node";
+ requireUsageDashboardUser.mockResolvedValue({ id: "member-b", role: "user" });
+ getUsers.mockResolvedValue([
+ { id: "admin", role: "admin", isActive: true },
+ { id: "member-a", role: "user", isActive: true },
+ ]);
+ getProviderConnections.mockResolvedValue([
+ { provider: providerId, isActive: true, apiKey: "member-secret", ownerId: "member-a" },
+ ]);
+ getProviderNodes.mockResolvedValue([
+ { id: providerId, type: "openai-compatible", name: "Member Gateway" },
+ ]);
+ getCustomModels.mockResolvedValue([
+ { providerAlias: providerId, id: "member-only-model", type: "llm" },
+ ]);
+
+ const response = await GET();
+ const body = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(body.models).not.toEqual(expect.arrayContaining([
+ expect.objectContaining({ fullModel: `${providerId}/member-only-model` }),
+ ]));
+ });
+});
\ No newline at end of file
diff --git a/tests/unit/custom-models-route.test.js b/tests/unit/custom-models-route.test.js
new file mode 100644
index 00000000..02fd9e18
--- /dev/null
+++ b/tests/unit/custom-models-route.test.js
@@ -0,0 +1,78 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const getCustomModels = vi.fn();
+const addCustomModel = vi.fn();
+const deleteCustomModel = vi.fn();
+const requireAdminUser = vi.fn();
+
+vi.mock("@/models", () => ({
+ getCustomModels,
+ addCustomModel,
+ deleteCustomModel,
+}));
+vi.mock("@/lib/auth/currentUser", () => ({ requireAdminUser }));
+
+const { GET, POST, DELETE } = await import("../../src/app/api/models/custom/route.js");
+
+describe("/api/models/custom", () => {
+ beforeEach(() => {
+ getCustomModels.mockReset();
+ addCustomModel.mockReset();
+ deleteCustomModel.mockReset();
+ requireAdminUser.mockReset();
+ });
+
+ it("keeps the shared catalog readable to authenticated model selectors", async () => {
+ getCustomModels.mockResolvedValue([{ providerAlias: "openai", id: "gpt-test", type: "llm" }]);
+
+ const response = await GET();
+
+ expect(response.status).toBe(200);
+ await expect(response.json()).resolves.toEqual({
+ models: [{ providerAlias: "openai", id: "gpt-test", type: "llm" }],
+ });
+ expect(requireAdminUser).not.toHaveBeenCalled();
+ });
+
+ it("rejects a non-admin adding a shared custom model", async () => {
+ requireAdminUser.mockRejectedValue(new Error("Forbidden"));
+
+ const response = await POST(new Request("http://localhost/api/models/custom", {
+ method: "POST",
+ body: JSON.stringify({ providerAlias: "openai", id: "gpt-test", type: "llm" }),
+ }));
+
+ expect(response.status).toBe(403);
+ expect(addCustomModel).not.toHaveBeenCalled();
+ });
+
+ it("allows an admin to add a shared custom model", async () => {
+ requireAdminUser.mockResolvedValue({ id: "admin", role: "admin" });
+ addCustomModel.mockResolvedValue(true);
+
+ const response = await POST(new Request("http://localhost/api/models/custom", {
+ method: "POST",
+ body: JSON.stringify({ providerAlias: "openai", id: "gpt-test", type: "llm" }),
+ }));
+
+ expect(response.status).toBe(200);
+ expect(addCustomModel).toHaveBeenCalledWith({
+ providerAlias: "openai",
+ id: "gpt-test",
+ type: "llm",
+ name: undefined,
+ });
+ });
+
+ it("rejects a non-admin deleting a shared custom model", async () => {
+ requireAdminUser.mockRejectedValue(new Error("Forbidden"));
+
+ const response = await DELETE(new Request(
+ "http://localhost/api/models/custom?providerAlias=openai&id=gpt-test&type=llm",
+ { method: "DELETE" },
+ ));
+
+ expect(response.status).toBe(403);
+ expect(deleteCustomModel).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/provider-connection-admin-access.test.js b/tests/unit/provider-connection-admin-access.test.js
new file mode 100644
index 00000000..309cb80e
--- /dev/null
+++ b/tests/unit/provider-connection-admin-access.test.js
@@ -0,0 +1,118 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const getProviderConnectionById = vi.fn();
+const getProxyPoolById = vi.fn();
+const updateProviderConnection = vi.fn();
+const deleteProviderConnection = vi.fn();
+const getProviderConnectionAccess = vi.fn();
+
+vi.mock("@/models", () => ({
+ getProviderConnectionById,
+ getProxyPoolById,
+ updateProviderConnection,
+ deleteProviderConnection,
+}));
+vi.mock("@/lib/providers/connectionAccess", () => ({ getProviderConnectionAccess }));
+vi.mock("@/shared/constants/providers", () => ({
+ isOpenAICompatibleProvider: (provider) => provider.startsWith("openai-compatible-"),
+ isAnthropicCompatibleProvider: (provider) => provider.startsWith("anthropic-compatible-"),
+ isCustomEmbeddingProvider: (provider) => provider.startsWith("custom-embedding-"),
+}));
+
+const { PUT, DELETE } = await import("../../src/app/api/providers/[id]/route.js");
+
+const memberAccess = {
+ user: { id: "member", role: "user" },
+ ownerId: "member",
+};
+const adminAccess = {
+ user: { id: "admin", role: "admin" },
+ ownerId: null,
+};
+
+describe("provider connection administrator-managed access", () => {
+ beforeEach(() => {
+ getProviderConnectionById.mockReset();
+ getProxyPoolById.mockReset();
+ updateProviderConnection.mockReset();
+ deleteProviderConnection.mockReset();
+ getProviderConnectionAccess.mockReset();
+ });
+
+ it("prevents a member from updating their legacy compatible connection", async () => {
+ getProviderConnectionAccess.mockResolvedValue(memberAccess);
+ getProviderConnectionById.mockResolvedValue({
+ id: "legacy-compatible",
+ provider: "openai-compatible-chat-node",
+ ownerId: "member",
+ });
+
+ const response = await PUT(new Request("http://localhost/api/providers/legacy-compatible", {
+ method: "PUT",
+ body: JSON.stringify({ name: "Changed" }),
+ }), { params: Promise.resolve({ id: "legacy-compatible" }) });
+
+ expect(response.status).toBe(403);
+ expect(updateProviderConnection).not.toHaveBeenCalled();
+ });
+
+ it("prevents a member from deleting their legacy compatible connection", async () => {
+ getProviderConnectionAccess.mockResolvedValue(memberAccess);
+ getProviderConnectionById.mockResolvedValue({
+ id: "legacy-compatible",
+ provider: "anthropic-compatible-node",
+ ownerId: "member",
+ });
+
+ const response = await DELETE(new Request("http://localhost/api/providers/legacy-compatible", {
+ method: "DELETE",
+ }), { params: Promise.resolve({ id: "legacy-compatible" }) });
+
+ expect(response.status).toBe(403);
+ expect(deleteProviderConnection).not.toHaveBeenCalled();
+ });
+
+ it("allows an administrator to delete a compatible connection", async () => {
+ getProviderConnectionAccess.mockResolvedValue(adminAccess);
+ getProviderConnectionById.mockResolvedValue({
+ id: "compatible",
+ provider: "custom-embedding-node",
+ ownerId: "admin",
+ });
+ deleteProviderConnection.mockResolvedValue(true);
+
+ const response = await DELETE(new Request("http://localhost/api/providers/compatible", {
+ method: "DELETE",
+ }), { params: Promise.resolve({ id: "compatible" }) });
+
+ expect(response.status).toBe(200);
+ expect(deleteProviderConnection).toHaveBeenCalledWith("compatible");
+ });
+
+ it("preserves member control over their non-compatible connection", async () => {
+ getProviderConnectionAccess.mockResolvedValue(memberAccess);
+ getProviderConnectionById.mockResolvedValue({
+ id: "openai-connection",
+ provider: "openai",
+ ownerId: "member",
+ providerSpecificData: {},
+ authType: "apikey",
+ });
+ updateProviderConnection.mockResolvedValue({
+ id: "openai-connection",
+ provider: "openai",
+ name: "Changed",
+ });
+
+ const response = await PUT(new Request("http://localhost/api/providers/openai-connection", {
+ method: "PUT",
+ body: JSON.stringify({ name: "Changed" }),
+ }), { params: Promise.resolve({ id: "openai-connection" }) });
+
+ expect(response.status).toBe(200);
+ expect(updateProviderConnection).toHaveBeenCalledWith("openai-connection", {
+ name: "Changed",
+ providerSpecificData: {},
+ });
+ });
+});