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 ( +
+ + +
+ ); + } + + return ( +
+
+
+
+

Models

+ {models.length} +
+

+ Manage which models are available through the API for connected providers. +

+
+ +
+ + {error ? ( + +
+ error + {error} +
+
+ ) : null} + + {!error && filteredGroups.length === 0 ? ( + + search_off +

+ {models.length === 0 + ? "No models are available. Add and activate a provider connection first." + : "No models match your search."} +

+
+ ) : ( +
+ {filteredGroups.map((group) => ( + + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 845eed85..b87a1ade 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -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 && (

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