diff --git a/src/app/(dashboard)/dashboard/models/page.js b/src/app/(dashboard)/dashboard/models/page.js index 0a420134..1be7db32 100644 --- a/src/app/(dashboard)/dashboard/models/page.js +++ b/src/app/(dashboard)/dashboard/models/page.js @@ -1,7 +1,6 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { useRouter } from "next/navigation"; import { Badge, Button, @@ -11,6 +10,7 @@ import { Toggle, } from "@/shared/components"; import ProviderIcon from "@/shared/components/ProviderIcon"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import useUserStore from "@/store/userStore"; import { useNotificationStore } from "@/store/notificationStore"; @@ -18,15 +18,9 @@ function groupModelsByProvider(models) { return models.reduce((groups, model) => { const key = model.providerAlias; if (!groups[key]) { - groups[key] = { - provider: model.provider, - models: [], - modelIds: new Set(), - }; + groups[key] = { provider: model.provider, models: [], modelIds: new Set() }; } - // The API normally supplies unique models. Ignore a duplicate defensively - // so a malformed response cannot produce duplicate React keys. if (groups[key].modelIds.has(model.fullModel)) return groups; groups[key].modelIds.add(model.fullModel); groups[key].models.push(model); @@ -34,17 +28,16 @@ function groupModelsByProvider(models) { }, {}); } -function ProviderModelsCard({ group, onSetModelsDisabled, pendingIds }) { +function ProviderModelsCard({ group, canManage, onSetModelDisabled, onSetModelsDisabled, pendingIds }) { const [expanded, setExpanded] = useState(true); + const { copied, copy } = useCopyToClipboard(); + const isUpdatingGroup = group.models.some((model) => pendingIds.has(model.fullModel)); 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); + const modelsToUpdate = group.models.filter((model) => model.disabled !== disabled); + if (modelsToUpdate.length > 0) onSetModelsDisabled(group.provider.alias, modelsToUpdate, disabled); }; return ( @@ -70,75 +63,84 @@ function ProviderModelsCard({ group, onSetModelsDisabled, pendingIds }) { />
-
-

{group.provider.name}

- - {group.models[0].connectionCount} connection{group.models[0].connectionCount === 1 ? "" : "s"} - -
+

{group.provider.name}

- {enabledCount} enabled · {disabledCount} disabled · {group.models.length} models + {group.models.length} available model{group.models.length === 1 ? "" : "s"}

expand_more - -
- - -
+ {canManage ? ( +
+ + +
+ ) : null} {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.name || model.alias} +

+
+

{model.model}

+ +
+
+ +
+
+ {model.alias !== model.model ? {model.alias} : null}
-

{model.model}

+ {canManage ? ( + onSetModelDisabled(group.provider.alias, model, !enabled)} + className="shrink-0" + /> + ) : null}
- - {model.disabled ? "Disabled" : "Enabled"} - -
+ ); })}
@@ -148,7 +150,6 @@ function ProviderModelsCard({ group, onSetModelsDisabled, pendingIds }) { } export default function ModelsPage() { - const router = useRouter(); const user = useUserStore((state) => state.user); const fetchCurrentUser = useUserStore((state) => state.fetchCurrentUser); const notify = useNotificationStore(); @@ -163,12 +164,6 @@ export default function ModelsPage() { }, [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); @@ -185,7 +180,7 @@ export default function ModelsPage() { }; loadModels(); - }, [user?.role]); + }, []); const filteredGroups = useMemo(() => { const normalizedSearch = search.trim().toLowerCase(); @@ -200,16 +195,18 @@ export default function ModelsPage() { .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); + const setModelDisabled = async (providerAlias, model, disabled) => { + await setModelsDisabled(providerAlias, [model], disabled); + }; + + const setModelsDisabled = async (providerAlias, modelsToUpdate, disabled) => { + const ids = modelsToUpdate.map((model) => model.fullModel); + const modelIds = modelsToUpdate.map((model) => model.model); if (ids.length === 0) return; setPendingIds((current) => new Set([...current, ...ids])); - setModels((current) => current.map((model) => ( - ids.includes(model.fullModel) ? { ...model, disabled } : model + setModels((current) => current.map((item) => ( + ids.includes(item.fullModel) ? { ...item, disabled } : item ))); try { @@ -219,11 +216,11 @@ export default function ModelsPage() { body: JSON.stringify({ providerAlias, modelIds, disabled }), }); const data = await response.json(); - if (!response.ok) throw new Error(data.error || "Failed to update models"); + if (!response.ok) throw new Error(data.error || "Failed to update model"); 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 + setModels((current) => current.map((item) => ( + ids.includes(item.fullModel) ? { ...item, disabled: !disabled } : item ))); notify.error(updateError.message); } finally { @@ -235,9 +232,7 @@ export default function ModelsPage() { } }; - if (user && user.role !== "admin") return null; - - if (loading || !user) { + if (loading) { return (
@@ -246,6 +241,9 @@ export default function ModelsPage() { ); } + const providerCount = new Set(models.map((model) => model.providerAlias)).size; + const canManage = user?.role === "admin"; + return (
@@ -255,7 +253,9 @@ export default function ModelsPage() { {models.length}

- Manage which models are available through the API for connected providers. + {canManage + ? "Select which connected-provider models are available through the API." + : "Browse models currently available through your connected providers."}

+ {!error && models.length > 0 ? ( +
+
+

Connected providers

+

{providerCount}

+
+
+

Available models

+

{models.length}

+
+
+ ) : null} + {error ? (
@@ -293,7 +306,9 @@ export default function ModelsPage() { ))} diff --git a/src/app/api/models/connected/route.js b/src/app/api/models/connected/route.js index a8d24fdc..bbaa19f6 100644 --- a/src/app/api/models/connected/route.js +++ b/src/app/api/models/connected/route.js @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { getModelAliases, getProviderConnections } from "@/models"; import { disableModels, enableModels, getDisabledModels } from "@/lib/disabledModelsDb"; -import { requireAdminUser } from "@/lib/auth/currentUser"; +import { requireAdminUser, requireUsageDashboardUser } 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"; @@ -47,7 +47,7 @@ function getForbiddenResponse(error) { // GET /api/models/connected - List models from providers with a usable active connection. export async function GET() { try { - await requireAdminUser(); + const user = await requireUsageDashboardUser(); const [connections, disabledModels, modelAliases] = await Promise.all([ getProviderConnections(), @@ -78,7 +78,6 @@ export async function GET() { 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, @@ -86,6 +85,7 @@ export async function GET() { }, }; }) + .filter((model) => user.role === "admin" || !model.disabled) .sort((a, b) => ( a.provider.name.localeCompare(b.provider.name) || a.name.localeCompare(b.name) diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 9385ac14..8c4d9373 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -49,8 +49,8 @@ const ALWAYS_PROTECTED = [ 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", "/dashboard/models"]; +// may view or change them. The Models page is available read-only to all users. +const ADMIN_ONLY_DASHBOARD_PATHS = ["/dashboard/combos"]; // 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 23d22b84..b438748c 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -21,7 +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/models", label: "Models", icon: "view_list" }, // { 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/services/disabledModels.js b/src/sse/services/disabledModels.js index 00529069..cf631bc9 100644 --- a/src/sse/services/disabledModels.js +++ b/src/sse/services/disabledModels.js @@ -2,6 +2,7 @@ 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"; +import { stripThinkingSuffix } from "open-sse/translator/concerns/thinkingUnified.js"; /** * Return an error response when a resolved provider/model pair has been @@ -15,12 +16,16 @@ export async function getDisabledModelResponse(provider, model) { try { const disabledModels = await getDisabledModels(); const providerAlias = getProviderAlias(provider) || provider; + // Thinking variants use a client-facing suffix, e.g. `gpt-5.6-sol(high)`, + // but dispatch to the base upstream model. Evaluate the disabled policy + // against that base ID as well so a suffix cannot bypass an admin disable. + const baseModel = stripThinkingSuffix(model); const disabledIds = new Set([ ...(disabledModels[providerAlias] || []), ...(disabledModels[provider] || []), ]); - if (!disabledIds.has(model)) return null; + if (!disabledIds.has(model) && !disabledIds.has(baseModel)) return null; return errorResponse( HTTP_STATUS.NOT_FOUND, diff --git a/tests/unit/disabled-models-enforcement.test.js b/tests/unit/disabled-models-enforcement.test.js index 6134cf71..7dab1067 100644 --- a/tests/unit/disabled-models-enforcement.test.js +++ b/tests/unit/disabled-models-enforcement.test.js @@ -42,6 +42,20 @@ describe("getDisabledModelResponse", () => { expect(response.status).toBe(404); }); + it("blocks thinking variants when their base model is disabled", async () => { + getDisabledModels.mockResolvedValue({ codex: ["gpt-5.6-sol"] }); + + const response = await getDisabledModelResponse("codex", "gpt-5.6-sol(high)"); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toMatchObject({ + error: { + code: "model_not_found", + message: "Model codex/gpt-5.6-sol(high) is disabled by an administrator", + }, + }); + }); + it("fails closed when disabled-model storage cannot be read", async () => { getDisabledModels.mockRejectedValue(new Error("database unavailable"));