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 });
}