mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
feat: add models page for the web application
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardSkeleton,
|
||||
CapacityBadges,
|
||||
Toggle,
|
||||
} from "@/shared/components";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import useUserStore from "@/store/userStore";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
function groupModelsByProvider(models) {
|
||||
return models.reduce((groups, model) => {
|
||||
const key = model.providerAlias;
|
||||
if (!groups[key]) {
|
||||
groups[key] = {
|
||||
provider: model.provider,
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
groups[key].models.push(model);
|
||||
return groups;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function ProviderModelsCard({ group, onSetModelsDisabled, pendingIds }) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const enabledCount = group.models.filter((model) => !model.disabled).length;
|
||||
const disabledCount = group.models.length - enabledCount;
|
||||
const isUpdatingGroup = group.models.some((model) => pendingIds.has(model.fullModel));
|
||||
|
||||
const setAllModelsDisabled = (disabled) => {
|
||||
const modelIds = group.models
|
||||
.filter((model) => model.disabled !== disabled)
|
||||
.map((model) => model.model);
|
||||
if (modelIds.length > 0) onSetModelsDisabled(group.provider.alias, modelIds, disabled);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="none" className="overflow-hidden">
|
||||
<div className="flex flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
className="flex min-w-0 items-center gap-3 text-left"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<div
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: `${group.provider.color?.length > 7 ? group.provider.color : `${group.provider.color || "#6b7280"}15`}` }}
|
||||
>
|
||||
<ProviderIcon
|
||||
src={`/providers/${group.provider.id}.png`}
|
||||
alt={group.provider.name}
|
||||
size={30}
|
||||
className="max-h-7.5 max-w-7.5 rounded-lg object-contain"
|
||||
fallbackText={group.provider.textIcon || group.provider.name.slice(0, 2).toUpperCase()}
|
||||
fallbackColor={group.provider.color}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="truncate font-semibold text-text-main">{group.provider.name}</h2>
|
||||
<Badge variant="success" size="sm" dot>
|
||||
{group.models[0].connectionCount} connection{group.models[0].connectionCount === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-text-muted">
|
||||
{enabledCount} enabled · {disabledCount} disabled · {group.models.length} models
|
||||
</p>
|
||||
</div>
|
||||
<span className={`material-symbols-outlined ml-auto text-[18px] text-text-muted transition-transform ${expanded ? "rotate-180" : ""}`}>
|
||||
expand_more
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="check_circle"
|
||||
disabled={enabledCount === group.models.length || isUpdatingGroup}
|
||||
onClick={() => setAllModelsDisabled(false)}
|
||||
>
|
||||
Enable all
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="pause_circle"
|
||||
disabled={disabledCount === group.models.length || isUpdatingGroup}
|
||||
onClick={() => setAllModelsDisabled(true)}
|
||||
>
|
||||
Disable all
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
<div className="border-t border-border">
|
||||
{group.models.map((model) => {
|
||||
const isPending = pendingIds.has(model.fullModel);
|
||||
return (
|
||||
<div
|
||||
key={model.fullModel}
|
||||
className="flex min-w-0 items-center gap-3 border-b border-border px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={!model.disabled}
|
||||
disabled={isPending}
|
||||
onChange={(enabled) => onSetModelsDisabled(group.provider.alias, [model.model], !enabled)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className={`truncate text-sm font-medium ${model.disabled ? "text-text-muted line-through" : "text-text-main"}`}>
|
||||
{model.name || model.alias}
|
||||
</span>
|
||||
{model.alias !== model.model ? (
|
||||
<Badge variant="default" size="sm">{model.alias}</Badge>
|
||||
) : null}
|
||||
<CapacityBadges caps={model.caps} />
|
||||
</div>
|
||||
<p className="mt-0.5 truncate font-mono text-xs text-text-muted">{model.model}</p>
|
||||
</div>
|
||||
<Badge variant={model.disabled ? "default" : "success"} size="sm">
|
||||
{model.disabled ? "Disabled" : "Enabled"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ModelsPage() {
|
||||
const router = useRouter();
|
||||
const user = useUserStore((state) => state.user);
|
||||
const fetchCurrentUser = useUserStore((state) => state.fetchCurrentUser);
|
||||
const notify = useNotificationStore();
|
||||
const [models, setModels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [pendingIds, setPendingIds] = useState(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) fetchCurrentUser();
|
||||
}, [fetchCurrentUser, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && user.role !== "admin") router.replace("/dashboard");
|
||||
}, [router, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role !== "admin") return;
|
||||
|
||||
const loadModels = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/models/connected", { cache: "no-store" });
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Failed to load models");
|
||||
setModels(data.models || []);
|
||||
} catch (loadError) {
|
||||
setError(loadError.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadModels();
|
||||
}, [user?.role]);
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
const filteredModels = normalizedSearch
|
||||
? models.filter((model) => (
|
||||
[model.provider.name, model.name, model.alias, model.model]
|
||||
.some((value) => value?.toLowerCase().includes(normalizedSearch))
|
||||
))
|
||||
: models;
|
||||
|
||||
return Object.values(groupModelsByProvider(filteredModels))
|
||||
.sort((a, b) => a.provider.name.localeCompare(b.provider.name));
|
||||
}, [models, search]);
|
||||
|
||||
const setModelsDisabled = async (providerAlias, modelIds, disabled) => {
|
||||
const matchingModels = models.filter((model) => (
|
||||
model.provider.alias === providerAlias && modelIds.includes(model.model)
|
||||
));
|
||||
const ids = matchingModels.map((model) => model.fullModel);
|
||||
if (ids.length === 0) return;
|
||||
|
||||
setPendingIds((current) => new Set([...current, ...ids]));
|
||||
setModels((current) => current.map((model) => (
|
||||
ids.includes(model.fullModel) ? { ...model, disabled } : model
|
||||
)));
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/models/connected", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ providerAlias, modelIds, disabled }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Failed to update models");
|
||||
notify.success(`${modelIds.length} model${modelIds.length === 1 ? "" : "s"} ${disabled ? "disabled" : "enabled"}.`);
|
||||
} catch (updateError) {
|
||||
setModels((current) => current.map((model) => (
|
||||
ids.includes(model.fullModel) ? { ...model, disabled: !disabled } : model
|
||||
)));
|
||||
notify.error(updateError.message);
|
||||
} finally {
|
||||
setPendingIds((current) => {
|
||||
const next = new Set(current);
|
||||
ids.forEach((id) => next.delete(id));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (user && user.role !== "admin") return null;
|
||||
|
||||
if (loading || !user) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-6 px-1 sm:px-0">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-text-main">Models</h1>
|
||||
<Badge variant="default" size="sm">{models.length}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
Manage which models are available through the API for connected providers.
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative block w-full sm:w-80">
|
||||
<span className="material-symbols-outlined pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[18px] text-text-muted">search</span>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search models or providers"
|
||||
className="w-full rounded-lg border border-border bg-surface py-2 pl-9 pr-3 text-sm text-text-main outline-none transition-colors placeholder:text-text-muted focus:border-primary"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Card className="border-red-500/30 bg-red-500/5 text-sm text-red-600 dark:text-red-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px]">error</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{!error && filteredGroups.length === 0 ? (
|
||||
<Card className="border-dashed text-center">
|
||||
<span className="material-symbols-outlined text-[32px] text-text-muted">search_off</span>
|
||||
<p className="mt-2 text-sm text-text-muted">
|
||||
{models.length === 0
|
||||
? "No models are available. Add and activate a provider connection first."
|
||||
: "No models match your search."}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{filteredGroups.map((group) => (
|
||||
<ProviderModelsCard
|
||||
key={group.provider.alias}
|
||||
group={group}
|
||||
pendingIds={pendingIds}
|
||||
onSetModelsDisabled={setModelsDisabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
|
||||
import { getThinkingLevels } from "open-sse/providers/thinkingLevels.js";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { useModelCaps } from "@/shared/hooks/useModelCaps";
|
||||
import useUserStore from "@/store/userStore";
|
||||
import { translate } from "@/i18n/runtime";
|
||||
import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher";
|
||||
import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels";
|
||||
@@ -38,6 +39,7 @@ export default function ProviderDetailPage() {
|
||||
const router = useRouter();
|
||||
const providerId = params.id;
|
||||
const { getCaps } = useModelCaps();
|
||||
const user = useUserStore((state) => state.user);
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [providerNode, setProviderNode] = useState(null);
|
||||
@@ -79,6 +81,8 @@ export default function ProviderDetailPage() {
|
||||
const [importingQoderModels, setImportingQoderModels] = useState(false);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
const canManageModelAvailability = user?.role === "admin";
|
||||
|
||||
const AG_RISK_STORAGE_KEY = "ag_risk_confirmed";
|
||||
|
||||
const openOAuthConnection = () => {
|
||||
@@ -1117,7 +1121,7 @@ export default function ProviderDetailPage() {
|
||||
onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined}
|
||||
isTesting={testingModelIds.has(model.id)}
|
||||
isFree={model.isFree}
|
||||
onDisable={() => handleDisableModel(model.id)}
|
||||
onDisable={canManageModelAvailability ? () => handleDisableModel(model.id) : undefined}
|
||||
caps={getCaps(`${providerId}/${model.id}`)}
|
||||
thinkingSuffix={resolveThinkingSuffix(model.id)}
|
||||
/>
|
||||
@@ -1181,7 +1185,7 @@ export default function ProviderDetailPage() {
|
||||
})()}
|
||||
|
||||
{/* Disabled models — restorable */}
|
||||
{disabledDisplayModels.length > 0 && (
|
||||
{canManageModelAvailability && disabledDisplayModels.length > 0 && (
|
||||
<div className="w-full mt-2">
|
||||
<p className="text-xs text-text-muted mb-2">Disabled models ({disabledDisplayModels.length}):</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -1612,7 +1616,7 @@ export default function ProviderDetailPage() {
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{!isCompatible && (() => {
|
||||
{canManageModelAvailability && !isCompatible && (() => {
|
||||
const allIds = [
|
||||
...models,
|
||||
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)),
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getModelAliases, getProviderConnections } from "@/models";
|
||||
import { disableModels, enableModels, getDisabledModels } from "@/lib/disabledModelsDb";
|
||||
import { requireAdminUser } from "@/lib/auth/currentUser";
|
||||
import { AI_MODELS } from "@/shared/constants/models";
|
||||
import { AI_PROVIDERS, getProviderAlias, getProviderByAlias } from "@/shared/constants/providers";
|
||||
import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function isViableConnection(connection) {
|
||||
if (!connection.isActive) return false;
|
||||
|
||||
return connection.testStatus === "active"
|
||||
|| connection.testStatus === "success"
|
||||
|| connection.testStatus === "ready"
|
||||
|| Boolean(connection.apiKey)
|
||||
|| Boolean(connection.accessToken);
|
||||
}
|
||||
|
||||
function getConnectionProviderAliases(connection) {
|
||||
const alias = getProviderAlias(connection.provider) || connection.provider;
|
||||
return [...new Set([connection.provider, alias])];
|
||||
}
|
||||
|
||||
function getProviderLabel(providerAlias) {
|
||||
const provider = getProviderByAlias(providerAlias) || AI_PROVIDERS[providerAlias];
|
||||
return {
|
||||
id: provider?.id || providerAlias,
|
||||
alias: provider?.alias || providerAlias,
|
||||
name: provider?.name || providerAlias,
|
||||
color: provider?.color,
|
||||
textIcon: provider?.textIcon,
|
||||
};
|
||||
}
|
||||
|
||||
function getForbiddenResponse(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/models/connected - List models from providers with a usable active connection.
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAdminUser();
|
||||
|
||||
const [connections, disabledModels, modelAliases] = await Promise.all([
|
||||
getProviderConnections(),
|
||||
getDisabledModels(),
|
||||
getModelAliases(),
|
||||
]);
|
||||
|
||||
const connectionCountByAlias = new Map();
|
||||
for (const connection of connections) {
|
||||
if (!isViableConnection(connection)) continue;
|
||||
|
||||
for (const alias of getConnectionProviderAliases(connection)) {
|
||||
connectionCountByAlias.set(alias, (connectionCountByAlias.get(alias) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const models = AI_MODELS
|
||||
.filter((model) => connectionCountByAlias.has(model.provider))
|
||||
.map((model) => {
|
||||
const providerAlias = getProviderAlias(model.provider) || model.provider;
|
||||
const disabled = disabledModels[providerAlias] || disabledModels[model.provider] || [];
|
||||
const caps = getCapabilitiesForModel(model.provider, model.model);
|
||||
|
||||
return {
|
||||
...model,
|
||||
provider: getProviderLabel(model.provider),
|
||||
providerAlias,
|
||||
fullModel: `${model.provider}/${model.model}`,
|
||||
alias: modelAliases[`${model.provider}/${model.model}`] || model.model,
|
||||
disabled: disabled.includes(model.model),
|
||||
connectionCount: connectionCountByAlias.get(model.provider) || 0,
|
||||
caps: {
|
||||
vision: caps.vision,
|
||||
search: caps.search,
|
||||
reasoning: caps.reasoning,
|
||||
},
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (
|
||||
a.provider.name.localeCompare(b.provider.name)
|
||||
|| a.name.localeCompare(b.name)
|
||||
|| a.model.localeCompare(b.model)
|
||||
));
|
||||
|
||||
return NextResponse.json({ models });
|
||||
} catch (error) {
|
||||
const accessError = getForbiddenResponse(error);
|
||||
if (accessError) return accessError;
|
||||
|
||||
console.log("Error fetching connected models:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch connected models" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/models/connected - Enable or disable one or more models for a provider.
|
||||
export async function PUT(request) {
|
||||
try {
|
||||
await requireAdminUser();
|
||||
|
||||
const { providerAlias, modelId, modelIds, disabled } = await request.json();
|
||||
const ids = Array.isArray(modelIds)
|
||||
? modelIds
|
||||
: modelId
|
||||
? [modelId]
|
||||
: [];
|
||||
|
||||
if (!providerAlias || typeof disabled !== "boolean" || ids.length === 0 || ids.some((id) => typeof id !== "string" || !id)) {
|
||||
return NextResponse.json(
|
||||
{ error: "providerAlias, disabled, and modelId or modelIds[] are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
await disableModels(providerAlias, ids);
|
||||
} else {
|
||||
await enableModels(providerAlias, ids);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, providerAlias, ids, disabled });
|
||||
} catch (error) {
|
||||
const accessError = getForbiddenResponse(error);
|
||||
if (accessError) return accessError;
|
||||
|
||||
console.log("Error updating connected models:", error);
|
||||
return NextResponse.json({ error: "Failed to update connected models" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDisabledModels, disableModels, enableModels } from "@/lib/disabledModelsDb";
|
||||
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;
|
||||
}
|
||||
|
||||
// GET /api/models/disabled?providerAlias=xxx
|
||||
export async function GET(request) {
|
||||
try {
|
||||
@@ -20,6 +31,8 @@ export async function GET(request) {
|
||||
// POST /api/models/disabled body: { providerAlias, ids: [...] }
|
||||
export async function POST(request) {
|
||||
try {
|
||||
await requireAdminUser();
|
||||
|
||||
const { providerAlias, ids } = await request.json();
|
||||
if (!providerAlias || !Array.isArray(ids)) {
|
||||
return NextResponse.json({ error: "providerAlias and ids[] required" }, { status: 400 });
|
||||
@@ -27,6 +40,9 @@ export async function POST(request) {
|
||||
await disableModels(providerAlias, ids);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
const accessError = getAccessErrorResponse(error);
|
||||
if (accessError) return accessError;
|
||||
|
||||
console.log("Error disabling models:", error);
|
||||
return NextResponse.json({ error: "Failed to disable models" }, { status: 500 });
|
||||
}
|
||||
@@ -35,6 +51,8 @@ export async function POST(request) {
|
||||
// DELETE /api/models/disabled?providerAlias=xxx[&id=yyy]
|
||||
export async function DELETE(request) {
|
||||
try {
|
||||
await requireAdminUser();
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const providerAlias = searchParams.get("providerAlias");
|
||||
const id = searchParams.get("id");
|
||||
@@ -44,6 +62,9 @@ export async function DELETE(request) {
|
||||
await enableModels(providerAlias, id ? [id] : []);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
const accessError = getAccessErrorResponse(error);
|
||||
if (accessError) return accessError;
|
||||
|
||||
console.log("Error enabling models:", error);
|
||||
return NextResponse.json({ error: "Failed to enable models" }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ const ADMIN_ONLY_PATHS = ["/api/users", "/api/tunnel", "/api/combos"];
|
||||
|
||||
// Combo definitions affect routing and fallback behavior, so only administrators
|
||||
// may view or change them.
|
||||
const ADMIN_ONLY_DASHBOARD_PATHS = ["/dashboard/combos"];
|
||||
const ADMIN_ONLY_DASHBOARD_PATHS = ["/dashboard/combos", "/dashboard/models"];
|
||||
|
||||
// Require auth, but allow through if requireLogin is disabled
|
||||
const PROTECTED_API_PATHS = [
|
||||
|
||||
@@ -21,6 +21,7 @@ const COMBINED_WEB_ITEM = { id: "web", label: "Web Fetch & Search", icon: "trave
|
||||
const navItems = [
|
||||
{ href: "/dashboard/endpoint", label: "Endpoint & Key", icon: "api" },
|
||||
{ href: "/dashboard/providers", label: "Providers", icon: "dns" },
|
||||
{ href: "/dashboard/models", label: "Models", icon: "view_list", adminOnly: true },
|
||||
// { href: "/dashboard/basic-chat", label: "Basic Chat", icon: "chat" }, // Hidden
|
||||
{ href: "/dashboard/combos", label: "Combos", icon: "layers", adminOnly: true },
|
||||
{ href: "/dashboard/usage", label: "Usage", icon: "bar_chart" },
|
||||
|
||||
@@ -22,6 +22,7 @@ import { detectFormatByEndpoint } from "open-sse/translator/formats.js";
|
||||
import * as log from "../utils/logger.js";
|
||||
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
|
||||
import { getProjectIdForConnection } from "open-sse/services/projectId.js";
|
||||
import { getDisabledModelResponse } from "../services/disabledModels.js";
|
||||
|
||||
/**
|
||||
* Handle chat completion request
|
||||
@@ -185,6 +186,9 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
||||
|
||||
const { provider, model } = modelInfo;
|
||||
|
||||
const disabledModelResponse = await getDisabledModelResponse(provider, model);
|
||||
if (disabledModelResponse) return disabledModelResponse;
|
||||
|
||||
// Routing shown in the unified "▶" line (client model → provider/model)
|
||||
|
||||
// Extract userAgent from request
|
||||
|
||||
@@ -12,6 +12,7 @@ import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
|
||||
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
||||
import * as log from "../utils/logger.js";
|
||||
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
|
||||
import { getDisabledModelResponse } from "../services/disabledModels.js";
|
||||
|
||||
/**
|
||||
* Handle embeddings request for the SSE/Next.js server.
|
||||
@@ -73,6 +74,9 @@ export async function handleEmbeddings(request) {
|
||||
|
||||
const { provider, model } = modelInfo;
|
||||
|
||||
const disabledModelResponse = await getDisabledModelResponse(provider, model);
|
||||
if (disabledModelResponse) return disabledModelResponse;
|
||||
|
||||
if (modelStr !== `${provider}/${model}`) {
|
||||
log.info("ROUTING", `${modelStr} → ${provider}/${model}`);
|
||||
} else {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
||||
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
|
||||
import { handleComboChat } from "open-sse/services/combo.js";
|
||||
import * as log from "../utils/logger.js";
|
||||
import { getDisabledModelResponse } from "../services/disabledModels.js";
|
||||
|
||||
// Providers that don't require credentials (noAuth)
|
||||
const NO_AUTH_PROVIDERS = new Set(["sdwebui", "comfyui"]);
|
||||
@@ -73,6 +74,9 @@ async function handleSingleModelImage(body, modelStr, { wantsStream, binaryOutpu
|
||||
|
||||
const { provider, model } = modelInfo;
|
||||
|
||||
const disabledModelResponse = await getDisabledModelResponse(provider, model);
|
||||
if (disabledModelResponse) return disabledModelResponse;
|
||||
|
||||
// noAuth providers — no credential needed
|
||||
if (NO_AUTH_PROVIDERS.has(provider)) {
|
||||
const result = await handleImageGenerationCore({
|
||||
|
||||
@@ -9,6 +9,7 @@ import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
|
||||
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import * as log from "../utils/logger.js";
|
||||
import { getDisabledModelResponse } from "../services/disabledModels.js";
|
||||
|
||||
// Providers requiring credentials for STT
|
||||
const CREDENTIALED_PROVIDERS = new Set(
|
||||
@@ -43,6 +44,10 @@ export async function handleStt(request) {
|
||||
if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
|
||||
|
||||
const { provider, model } = modelInfo;
|
||||
|
||||
const disabledModelResponse = await getDisabledModelResponse(provider, model);
|
||||
if (disabledModelResponse) return disabledModelResponse;
|
||||
|
||||
log.info("ROUTING", `Provider: ${provider}, Model: ${model}`);
|
||||
|
||||
// noAuth providers
|
||||
|
||||
@@ -10,6 +10,7 @@ import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { handleComboChat } from "open-sse/services/combo.js";
|
||||
import * as log from "../utils/logger.js";
|
||||
import { getDisabledModelResponse } from "../services/disabledModels.js";
|
||||
|
||||
// Derived from providers.js: any TTS provider not noAuth requires stored credentials
|
||||
const CREDENTIALED_PROVIDERS = new Set(
|
||||
@@ -69,6 +70,10 @@ async function handleSingleModelTts(body, modelStr, responseFormat, language) {
|
||||
if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
|
||||
|
||||
const { provider, model } = modelInfo;
|
||||
|
||||
const disabledModelResponse = await getDisabledModelResponse(provider, model);
|
||||
if (disabledModelResponse) return disabledModelResponse;
|
||||
|
||||
log.info("ROUTING", `Provider: ${provider}, Voice: ${model}`);
|
||||
|
||||
// noAuth providers — no credential needed
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getDisabledModels } from "@/lib/disabledModelsDb";
|
||||
import { getProviderAlias } from "@/shared/constants/providers";
|
||||
import { errorResponse } from "open-sse/utils/error.js";
|
||||
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
||||
|
||||
/**
|
||||
* Return an error response when a resolved provider/model pair has been
|
||||
* disabled by an administrator. The check uses both the provider's persisted
|
||||
* alias and ID to preserve compatibility with existing disabled-model data.
|
||||
*
|
||||
* A storage read failure blocks execution rather than risking an accidental
|
||||
* bypass of an administrator's disabled-model policy.
|
||||
*/
|
||||
export async function getDisabledModelResponse(provider, model) {
|
||||
try {
|
||||
const disabledModels = await getDisabledModels();
|
||||
const providerAlias = getProviderAlias(provider) || provider;
|
||||
const disabledIds = new Set([
|
||||
...(disabledModels[providerAlias] || []),
|
||||
...(disabledModels[provider] || []),
|
||||
]);
|
||||
|
||||
if (!disabledIds.has(model)) return null;
|
||||
|
||||
return errorResponse(
|
||||
HTTP_STATUS.NOT_FOUND,
|
||||
`Model ${provider}/${model} is disabled by an administrator`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("Error checking disabled model status:", error);
|
||||
return errorResponse(
|
||||
HTTP_STATUS.SERVER_ERROR,
|
||||
"Unable to verify whether the requested model is enabled",
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user