fix: update the logic code for combos pages

This commit is contained in:
2026-07-14 17:09:56 +07:00
parent d5bf9d2e2e
commit 76a82bcfc9
11 changed files with 615 additions and 51 deletions
+16 -33
View File
@@ -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}
@@ -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 (
<div className="flex min-w-0 flex-col gap-6 px-1 sm:px-0">
@@ -349,7 +352,8 @@ export default function ProvidersPage() {
</div>
)}
{/* Custom Providers (OpenAI/Anthropic Compatible) — dynamic */}
{/* Custom provider configuration is administered centrally. */}
{isAdmin && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg sm:text-xl font-semibold flex items-center gap-2 leading-tight">
@@ -400,6 +404,7 @@ export default function ProvidersPage() {
</div>
)}
</div>
)}
{/* OAuth Providers */}
{oauthEntries.length > 0 && (
@@ -577,7 +582,7 @@ export default function ProvidersPage() {
</div>
</div> */}
<AddCompatibleModal
{isAdmin && <AddCompatibleModal
variant="openai"
isOpen={showAddCompatibleModal}
onClose={() => setShowAddCompatibleModal(false)}
@@ -585,8 +590,8 @@ export default function ProvidersPage() {
setProviderNodes((prev) => [...prev, node]);
setShowAddCompatibleModal(false);
}}
/>
<AddCompatibleModal
/>}
{isAdmin && <AddCompatibleModal
variant="anthropic"
isOpen={showAddAnthropicCompatibleModal}
onClose={() => setShowAddAnthropicCompatibleModal(false)}
@@ -594,7 +599,7 @@ export default function ProvidersPage() {
setProviderNodes((prev) => [...prev, node]);
setShowAddAnthropicCompatibleModal(false);
}}
/>
/>}
{/* Test Results Modal */}
{testResults && (
+93 -5
View File
@@ -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)
+31 -1
View File
@@ -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 });
}
+19
View File
@@ -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 });
}
+19
View File
@@ -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 });
}
+23 -2
View File
@@ -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) {
+7 -3
View File
@@ -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 });
}
+199
View File
@@ -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` }),
]));
});
});
+78
View File
@@ -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();
});
});
@@ -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: {},
});
});
});