diff --git a/src/app/(dashboard)/dashboard/models/page.js b/src/app/(dashboard)/dashboard/models/page.js
new file mode 100644
index 00000000..4fd87b7f
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/models/page.js
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+ {expanded ? (
+
+ {group.models.map((model) => {
+ const isPending = pendingIds.has(model.fullModel);
+ return (
+
+
onSetModelsDisabled(group.provider.alias, [model.model], !enabled)}
+ className="shrink-0"
+ />
+
+
+
+ {model.name || model.alias}
+
+ {model.alias !== model.model ? (
+ {model.alias}
+ ) : null}
+
+
+
{model.model}
+
+
+ {model.disabled ? "Disabled" : "Enabled"}
+
+
+ );
+ })}
+
+ ) : null}
+
+ );
+}
+
+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 (
+
Disabled models ({disabledDisplayModels.length}):
@@ -1612,7 +1616,7 @@ export default function ProviderDetailPage() {
)}
- {!isCompatible && (() => {
+ {canManageModelAvailability && !isCompatible && (() => {
const allIds = [
...models,
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)),
diff --git a/src/app/api/models/connected/route.js b/src/app/api/models/connected/route.js
new file mode 100644
index 00000000..a8d24fdc
--- /dev/null
+++ b/src/app/api/models/connected/route.js
@@ -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 });
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/models/disabled/route.js b/src/app/api/models/disabled/route.js
index e9d3e317..1e511489 100644
--- a/src/app/api/models/disabled/route.js
+++ b/src/app/api/models/disabled/route.js
@@ -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 });
}
diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js
index 2732a036..9385ac14 100644
--- a/src/dashboardGuard.js
+++ b/src/dashboardGuard.js
@@ -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 = [
diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js
index b3a9e2f9..23d22b84 100644
--- a/src/shared/components/Sidebar.js
+++ b/src/shared/components/Sidebar.js
@@ -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" },
diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js
index 77f450c1..dfc50cf7 100644
--- a/src/sse/handlers/chat.js
+++ b/src/sse/handlers/chat.js
@@ -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
diff --git a/src/sse/handlers/embeddings.js b/src/sse/handlers/embeddings.js
index cde0d41e..97777eb9 100644
--- a/src/sse/handlers/embeddings.js
+++ b/src/sse/handlers/embeddings.js
@@ -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 {
diff --git a/src/sse/handlers/imageGeneration.js b/src/sse/handlers/imageGeneration.js
index 78e738d0..c6b29fff 100644
--- a/src/sse/handlers/imageGeneration.js
+++ b/src/sse/handlers/imageGeneration.js
@@ -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({
diff --git a/src/sse/handlers/stt.js b/src/sse/handlers/stt.js
index 1840965e..ae314ecb 100644
--- a/src/sse/handlers/stt.js
+++ b/src/sse/handlers/stt.js
@@ -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
diff --git a/src/sse/handlers/tts.js b/src/sse/handlers/tts.js
index 828d45b6..04ed57af 100644
--- a/src/sse/handlers/tts.js
+++ b/src/sse/handlers/tts.js
@@ -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
diff --git a/src/sse/services/disabledModels.js b/src/sse/services/disabledModels.js
new file mode 100644
index 00000000..00529069
--- /dev/null
+++ b/src/sse/services/disabledModels.js
@@ -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",
+ );
+ }
+}
\ No newline at end of file
diff --git a/tests/unit/disabled-models-enforcement.test.js b/tests/unit/disabled-models-enforcement.test.js
new file mode 100644
index 00000000..6134cf71
--- /dev/null
+++ b/tests/unit/disabled-models-enforcement.test.js
@@ -0,0 +1,52 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const getDisabledModels = vi.fn();
+
+vi.mock("@/lib/disabledModelsDb", () => ({ getDisabledModels }));
+vi.mock("@/shared/constants/providers", () => ({
+ getProviderAlias: (provider) => ({ openai: "oa", claude: "claude" })[provider] || provider,
+}));
+
+const { getDisabledModelResponse } = await import("../../src/sse/services/disabledModels.js");
+
+describe("getDisabledModelResponse", () => {
+ beforeEach(() => {
+ getDisabledModels.mockReset();
+ });
+
+ it("allows an enabled model", async () => {
+ getDisabledModels.mockResolvedValue({ oa: ["gpt-disabled"] });
+
+ await expect(getDisabledModelResponse("openai", "gpt-enabled")).resolves.toBeNull();
+ });
+
+ it("blocks a model disabled under the provider alias", async () => {
+ getDisabledModels.mockResolvedValue({ oa: ["gpt-disabled"] });
+
+ const response = await getDisabledModelResponse("openai", "gpt-disabled");
+
+ expect(response.status).toBe(404);
+ await expect(response.json()).resolves.toMatchObject({
+ error: {
+ code: "model_not_found",
+ message: "Model openai/gpt-disabled is disabled by an administrator",
+ },
+ });
+ });
+
+ it("blocks a model disabled under the provider ID", async () => {
+ getDisabledModels.mockResolvedValue({ openai: ["gpt-disabled"] });
+
+ const response = await getDisabledModelResponse("openai", "gpt-disabled");
+
+ expect(response.status).toBe(404);
+ });
+
+ it("fails closed when disabled-model storage cannot be read", async () => {
+ getDisabledModels.mockRejectedValue(new Error("database unavailable"));
+
+ const response = await getDisabledModelResponse("openai", "gpt-enabled");
+
+ expect(response.status).toBe(500);
+ });
+});
diff --git a/tests/unit/disabled-models-route.test.js b/tests/unit/disabled-models-route.test.js
new file mode 100644
index 00000000..83f6022b
--- /dev/null
+++ b/tests/unit/disabled-models-route.test.js
@@ -0,0 +1,69 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const getDisabledModels = vi.fn();
+const disableModels = vi.fn();
+const enableModels = vi.fn();
+const requireAdminUser = vi.fn();
+
+vi.mock("@/lib/disabledModelsDb", () => ({
+ getDisabledModels,
+ disableModels,
+ enableModels,
+}));
+vi.mock("@/lib/auth/currentUser", () => ({ requireAdminUser }));
+
+const { GET, POST, DELETE } = await import("../../src/app/api/models/disabled/route.js");
+
+describe("/api/models/disabled", () => {
+ beforeEach(() => {
+ getDisabledModels.mockReset();
+ disableModels.mockReset();
+ enableModels.mockReset();
+ requireAdminUser.mockReset();
+ });
+
+ it("keeps disabled model reads available for model selectors", async () => {
+ getDisabledModels.mockResolvedValue({ claude: ["claude-disabled"] });
+
+ const response = await GET(new Request("http://localhost/api/models/disabled?providerAlias=claude"));
+
+ expect(response.status).toBe(200);
+ await expect(response.json()).resolves.toEqual({ ids: ["claude-disabled"] });
+ expect(requireAdminUser).not.toHaveBeenCalled();
+ });
+
+ it("rejects a non-admin disable request", async () => {
+ requireAdminUser.mockRejectedValue(new Error("Forbidden"));
+
+ const response = await POST(new Request("http://localhost/api/models/disabled", {
+ method: "POST",
+ body: JSON.stringify({ providerAlias: "claude", ids: ["claude-disabled"] }),
+ }));
+
+ expect(response.status).toBe(403);
+ expect(disableModels).not.toHaveBeenCalled();
+ });
+
+ it("allows an admin to disable models", async () => {
+ requireAdminUser.mockResolvedValue({ role: "admin" });
+
+ const response = await POST(new Request("http://localhost/api/models/disabled", {
+ method: "POST",
+ body: JSON.stringify({ providerAlias: "claude", ids: ["claude-disabled"] }),
+ }));
+
+ expect(response.status).toBe(200);
+ expect(disableModels).toHaveBeenCalledWith("claude", ["claude-disabled"]);
+ });
+
+ it("rejects a non-admin enable request", async () => {
+ requireAdminUser.mockRejectedValue(new Error("Forbidden"));
+
+ const response = await DELETE(new Request("http://localhost/api/models/disabled?providerAlias=claude&id=claude-disabled", {
+ method: "DELETE",
+ }));
+
+ expect(response.status).toBe(403);
+ expect(enableModels).not.toHaveBeenCalled();
+ });
+});