mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
fix: update the logic code for combos pages
This commit is contained in:
@@ -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 && (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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` }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user