fix: remove the suggest model in provider page

This commit is contained in:
2026-07-19 20:09:43 +07:00
parent c90f620694
commit 16eabec743
36 changed files with 146 additions and 771 deletions
@@ -59,7 +59,6 @@ applyTo: '**/*'
│ │ │ └── helpers/ # JSON column helpers, backups │ │ │ └── helpers/ # JSON column helpers, backups
│ │ ├── localDb.js # Backward-compat shim → re-exports @/lib/db/index.js │ │ ├── localDb.js # Backward-compat shim → re-exports @/lib/db/index.js
│ │ ├── usageDb.js # Usage + log persistence (~/.9router/usage.json, log.txt) │ │ ├── usageDb.js # Usage + log persistence (~/.9router/usage.json, log.txt)
│ │ ├── disabledModelsDb.js # Disabled models DB
│ │ ├── requestDetailsDb.js # Request detail logging DB │ │ ├── requestDetailsDb.js # Request detail logging DB
│ │ ├── oauth/ # OAuth flow helpers │ │ ├── oauth/ # OAuth flow helpers
│ │ ├── headroom/ # Headroom token compression │ │ ├── headroom/ # Headroom token compression
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@@ -165,7 +165,7 @@ export default function BasicChatPageClient() {
const providerMap = new Map(); const providerMap = new Map();
for (const model of modelsData.models || []) { for (const model of modelsData.models || []) {
if (model.disabled || !model.fullModel) continue; if (!model.fullModel) continue;
const providerId = model.provider?.id || model.providerAlias; const providerId = model.provider?.id || model.providerAlias;
const providerName = model.provider?.name || humanize(providerId); const providerName = model.provider?.name || humanize(providerId);
@@ -45,7 +45,7 @@ export default function ToolDetailClient({ toolId, machineId }) {
} }
if (modelsRes.ok) { if (modelsRes.ok) {
const data = await modelsRes.json(); const data = await modelsRes.json();
setAvailableModels((data.models || []).filter((model) => !model.disabled)); setAvailableModels(data.models || []);
} }
if (configRes.ok) { if (configRes.ok) {
const data = await configRes.json(); const data = await configRes.json();
+2 -4
View File
@@ -54,10 +54,8 @@ export default function CombosPage() {
} }
} }
// The Models page is the source of truth for eligible models. Administrators // The Models page is the source of truth for eligible models.
// can still see disabled rows there to manage them, but disabled models must const selectableModels = connectedModels;
// not be added to new or edited combos because they cannot serve requests.
const selectableModels = connectedModels.filter((model) => !model.disabled);
const handleCreate = async (data) => { const handleCreate = async (data) => {
try { try {
@@ -35,7 +35,7 @@ export default function MitmPageClient() {
setConnections(connectionsData.connections || []); setConnections(connectionsData.connections || []);
setApiKeys(keysData.keys || []); setApiKeys(keysData.keys || []);
setModelAliases(aliasesData.aliases || {}); setModelAliases(aliasesData.aliases || {});
setAvailableModels((modelsData.models || []).filter((model) => !model.disabled)); setAvailableModels(modelsData.models || []);
setCloudEnabled(settingsData.cloudEnabled || false); setCloudEnabled(settingsData.cloudEnabled || false);
}).catch(() => {}); }).catch(() => {});
+5 -103
View File
@@ -3,16 +3,12 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
Badge, Badge,
Button,
Card, Card,
CardSkeleton, CardSkeleton,
CapacityBadges, CapacityBadges,
Toggle,
} from "@/shared/components"; } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon"; import ProviderIcon from "@/shared/components/ProviderIcon";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import useUserStore from "@/store/userStore";
import { useNotificationStore } from "@/store/notificationStore";
function groupModelsByProvider(models) { function groupModelsByProvider(models) {
return models.reduce((groups, model) => { return models.reduce((groups, model) => {
@@ -28,17 +24,9 @@ function groupModelsByProvider(models) {
}, {}); }, {});
} }
function ProviderModelsCard({ group, canManage, onSetModelDisabled, onSetModelsDisabled, pendingIds }) { function ProviderModelsCard({ group }) {
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
const { copied, copy } = useCopyToClipboard(); 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 setAllModelsDisabled = (disabled) => {
const modelsToUpdate = group.models.filter((model) => model.disabled !== disabled);
if (modelsToUpdate.length > 0) onSetModelsDisabled(group.provider.alias, modelsToUpdate, disabled);
};
return ( return (
<Card padding="none" className="overflow-hidden"> <Card padding="none" className="overflow-hidden">
@@ -72,38 +60,14 @@ function ProviderModelsCard({ group, canManage, onSetModelDisabled, onSetModelsD
expand_more expand_more
</span> </span>
</button> </button>
{canManage ? (
<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>
) : null}
</div> </div>
{expanded ? ( {expanded ? (
<div className="grid grid-cols-1 gap-3 border-t border-border bg-surface-2/30 p-3 sm:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-3 border-t border-border bg-surface-2/30 p-3 sm:grid-cols-2 xl:grid-cols-3">
{group.models.map((model) => { {group.models.map((model) => (
const isPending = pendingIds.has(model.fullModel);
return (
<article <article
key={model.fullModel} key={model.fullModel}
className={`flex min-w-0 flex-col gap-4 rounded-xl border bg-surface p-4 transition-colors ${model.disabled ? "border-border-subtle opacity-60" : "border-border hover:border-primary/30"}`} className="flex min-w-0 flex-col gap-4 rounded-xl border border-border bg-surface p-4 transition-colors hover:border-primary/30"
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-semibold text-text-main" title={model.name || model.alias}> <h3 className="truncate text-sm font-semibold text-text-main" title={model.name || model.alias}>
@@ -130,19 +94,9 @@ function ProviderModelsCard({ group, canManage, onSetModelDisabled, onSetModelsD
{model.alias !== model.model ? <Badge variant="default" size="sm">{model.alias}</Badge> : null} {model.alias !== model.model ? <Badge variant="default" size="sm">{model.alias}</Badge> : null}
<CapacityBadges caps={model.caps} /> <CapacityBadges caps={model.caps} />
</div> </div>
{canManage ? (
<Toggle
size="sm"
checked={!model.disabled}
disabled={isPending}
onChange={(enabled) => onSetModelDisabled(group.provider.alias, model, !enabled)}
className="shrink-0"
/>
) : null}
</div> </div>
</article> </article>
); ))}
})}
</div> </div>
) : null} ) : null}
</Card> </Card>
@@ -150,18 +104,10 @@ function ProviderModelsCard({ group, canManage, onSetModelDisabled, onSetModelsD
} }
export default function ModelsPage() { export default function ModelsPage() {
const user = useUserStore((state) => state.user);
const fetchCurrentUser = useUserStore((state) => state.fetchCurrentUser);
const notify = useNotificationStore();
const [models, setModels] = useState([]); const [models, setModels] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [pendingIds, setPendingIds] = useState(new Set());
useEffect(() => {
if (!user) fetchCurrentUser();
}, [fetchCurrentUser, user]);
useEffect(() => { useEffect(() => {
const loadModels = async () => { const loadModels = async () => {
@@ -195,43 +141,6 @@ export default function ModelsPage() {
.sort((a, b) => a.provider.name.localeCompare(b.provider.name)); .sort((a, b) => a.provider.name.localeCompare(b.provider.name));
}, [models, search]); }, [models, search]);
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((item) => (
ids.includes(item.fullModel) ? { ...item, disabled } : item
)));
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 model");
notify.success(`${modelIds.length} model${modelIds.length === 1 ? "" : "s"} ${disabled ? "disabled" : "enabled"}.`);
} catch (updateError) {
setModels((current) => current.map((item) => (
ids.includes(item.fullModel) ? { ...item, disabled: !disabled } : item
)));
notify.error(updateError.message);
} finally {
setPendingIds((current) => {
const next = new Set(current);
ids.forEach((id) => next.delete(id));
return next;
});
}
};
if (loading) { if (loading) {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -242,7 +151,6 @@ export default function ModelsPage() {
} }
const providerCount = new Set(models.map((model) => model.providerAlias)).size; const providerCount = new Set(models.map((model) => model.providerAlias)).size;
const canManage = user?.role === "admin";
return ( return (
<div className="flex min-w-0 flex-col gap-6 px-1 sm:px-0"> <div className="flex min-w-0 flex-col gap-6 px-1 sm:px-0">
@@ -253,9 +161,7 @@ export default function ModelsPage() {
<Badge variant="default" size="sm">{models.length}</Badge> <Badge variant="default" size="sm">{models.length}</Badge>
</div> </div>
<p className="mt-1 text-sm text-text-muted"> <p className="mt-1 text-sm text-text-muted">
{canManage Browse models currently available through connected providers.
? "Manage models available from connected providers."
: "Browse models currently available through connected providers."}
</p> </p>
</div> </div>
<label className="relative block w-full sm:w-80"> <label className="relative block w-full sm:w-80">
@@ -306,10 +212,6 @@ export default function ModelsPage() {
<ProviderModelsCard <ProviderModelsCard
key={group.provider.alias} key={group.provider.alias}
group={group} group={group}
canManage={canManage}
pendingIds={pendingIds}
onSetModelDisabled={setModelDisabled}
onSetModelsDisabled={setModelsDisabled}
/> />
))} ))}
</div> </div>
@@ -2,7 +2,7 @@ import PropTypes from "prop-types";
import { CapacityBadges } from "@/shared/components"; import { CapacityBadges } from "@/shared/components";
import ActionMenu from "@/shared/components/ActionMenu"; import ActionMenu from "@/shared/components/ActionMenu";
export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onRemove, onTest, isTesting, onDisable, caps, thinkingSuffix }) { export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onRemove, onTest, isTesting, onDeleteModel, caps, thinkingSuffix }) {
const displayModel = thinkingSuffix ? `${fullModel}(${thinkingSuffix})` : fullModel; const displayModel = thinkingSuffix ? `${fullModel}(${thinkingSuffix})` : fullModel;
const borderColor = testStatus === "ok" const borderColor = testStatus === "ok"
? "border-green-500/40" ? "border-green-500/40"
@@ -15,7 +15,7 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test
: testStatus === "error" : testStatus === "error"
? "#ef4444" ? "#ef4444"
: undefined; : undefined;
const deleteModel = onRemove || (isCustom ? onDeleteAlias : onDisable); const deleteModel = onRemove || (isCustom ? onDeleteAlias : onDeleteModel);
const deletesPermanently = !!deleteModel; const deletesPermanently = !!deleteModel;
const actionItems = [ const actionItems = [
...(onTest ? [{ ...(onTest ? [{
@@ -81,7 +81,7 @@ ModelRow.propTypes = {
onRemove: PropTypes.func, onRemove: PropTypes.func,
onTest: PropTypes.func, onTest: PropTypes.func,
isTesting: PropTypes.bool, isTesting: PropTypes.bool,
onDisable: PropTypes.func, onDeleteModel: PropTypes.func,
caps: PropTypes.object, caps: PropTypes.object,
thinkingSuffix: PropTypes.string, thinkingSuffix: PropTypes.string,
}; };
@@ -67,7 +67,6 @@ export default function ProviderDetailPage() {
const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} }); const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} });
const [suggestedModels, setSuggestedModels] = useState([]); const [suggestedModels, setSuggestedModels] = useState([]);
const [kiloFreeModels, setKiloFreeModels] = useState([]); const [kiloFreeModels, setKiloFreeModels] = useState([]);
const [disabledModelIds, setDisabledModelIds] = useState([]);
const [deletedModelIds, setDeletedModelIds] = useState([]); const [deletedModelIds, setDeletedModelIds] = useState([]);
const [confirmState, setConfirmState] = useState(null); const [confirmState, setConfirmState] = useState(null);
const [showAgRiskModal, setShowAgRiskModal] = useState(false); const [showAgRiskModal, setShowAgRiskModal] = useState(false);
@@ -185,17 +184,13 @@ export default function ProviderDetailPage() {
? (providerNode?.prefix || providerId) ? (providerNode?.prefix || providerId)
: providerAlias; : providerAlias;
const fetchDisabledModels = useCallback(async () => { const fetchDeletedModels = useCallback(async () => {
try { try {
const [disabledRes, deletedRes] = await Promise.all([ const deletedRes = await fetch(`/api/models/delete?providerAlias=${encodeURIComponent(providerStorageAlias)}`, { cache: "no-store" });
fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}`, { cache: "no-store" }), const deletedData = await deletedRes.json();
fetch(`/api/models/delete?providerAlias=${encodeURIComponent(providerStorageAlias)}`, { cache: "no-store" }),
]);
const [disabledData, deletedData] = await Promise.all([disabledRes.json(), deletedRes.json()]);
if (disabledRes.ok) setDisabledModelIds(disabledData.ids || []);
if (deletedRes.ok) setDeletedModelIds(deletedData.ids || []); if (deletedRes.ok) setDeletedModelIds(deletedData.ids || []);
} catch (error) { } catch (error) {
console.log("Error fetching disabled models:", error); console.log("Error fetching deleted models:", error);
} }
}, [providerStorageAlias]); }, [providerStorageAlias]);
@@ -220,7 +215,7 @@ export default function ProviderDetailPage() {
return; return;
} }
await Promise.all([fetchAliases(), fetchCustomModels(), fetchDisabledModels()]); await Promise.all([fetchAliases(), fetchCustomModels(), fetchDeletedModels()]);
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("customModelChanged")); if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("customModelChanged"));
} catch (error) { } catch (error) {
console.log("Error permanently deleting model:", error); console.log("Error permanently deleting model:", error);
@@ -230,36 +225,6 @@ export default function ProviderDetailPage() {
}); });
}; };
const handleDisableAll = async (ids) => {
if (!ids.length) return;
setConfirmState({
title: "Disable All Models",
message: `Disable all ${ids.length} model(s)?`,
onConfirm: async () => {
setConfirmState(null);
try {
const res = await fetch("/api/models/disabled", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ providerAlias: providerStorageAlias, ids }),
});
if (res.ok) await fetchDisabledModels();
} catch (error) {
console.log("Error disabling all models:", error);
}
}
});
};
const handleEnableAll = async () => {
try {
const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}`, { method: "DELETE" });
if (res.ok) await fetchDisabledModels();
} catch (error) {
console.log("Error enabling all models:", error);
}
};
// Define callbacks BEFORE the useEffect that uses them // Define callbacks BEFORE the useEffect that uses them
const fetchAliases = useCallback(async () => { const fetchAliases = useCallback(async () => {
try { try {
@@ -459,8 +424,8 @@ export default function ProviderDetailPage() {
fetchConnections(); fetchConnections();
fetchAliases(); fetchAliases();
fetchCustomModels(); fetchCustomModels();
fetchDisabledModels(); fetchDeletedModels();
}, [fetchConnections, fetchAliases, fetchCustomModels, fetchDisabledModels]); }, [fetchConnections, fetchAliases, fetchCustomModels, fetchDeletedModels]);
// Fetch suggested models from provider's public API (if configured) // Fetch suggested models from provider's public API (if configured)
useEffect(() => { useEffect(() => {
@@ -1069,7 +1034,6 @@ export default function ProviderDetailPage() {
...models, ...models,
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)), ...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)),
].filter((m) => { const k = getModelKind(m); return !k || k === "llm"; }); ].filter((m) => { const k = getModelKind(m); return !k || k === "llm"; });
const disabledSet = new Set(disabledModelIds);
const addedModelIds = new Set( const addedModelIds = new Set(
customModels customModels
.filter((entry) => ( .filter((entry) => (
@@ -1080,8 +1044,7 @@ export default function ProviderDetailPage() {
.map((entry) => entry.id), .map((entry) => entry.id),
); );
const deletedSet = new Set(deletedModelIds); const deletedSet = new Set(deletedModelIds);
const displayModels = allModels.filter((model) => !disabledSet.has(model.id) && !deletedSet.has(model.id)); const displayModels = allModels.filter((model) => !deletedSet.has(model.id));
const disabledDisplayModels = allModels.filter((model) => disabledSet.has(model.id) && !deletedSet.has(model.id));
const customModelRows = getProviderCustomModelRows({ const customModelRows = getProviderCustomModelRows({
customModels, customModels,
modelAliases, modelAliases,
@@ -1138,7 +1101,7 @@ export default function ProviderDetailPage() {
onRemove={addedModelIds.has(model.id) onRemove={addedModelIds.has(model.id)
? () => handlePermanentlyDeleteModel(model.id, providerStorageAlias) ? () => handlePermanentlyDeleteModel(model.id, providerStorageAlias)
: undefined} : undefined}
onDisable={() => handlePermanentlyDeleteModel(model.id, providerStorageAlias)} onDeleteModel={() => handlePermanentlyDeleteModel(model.id, providerStorageAlias)}
caps={getCaps(`${providerId}/${model.id}`)} caps={getCaps(`${providerId}/${model.id}`)}
thinkingSuffix={resolveThinkingSuffix(model.id)} thinkingSuffix={resolveThinkingSuffix(model.id)}
/> />
@@ -1201,33 +1164,6 @@ export default function ProviderDetailPage() {
); );
})()} })()}
{/* Disabled models stay restorable; permanently deleted models do not. */}
{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">
{disabledDisplayModels.map((model) => (
<button
key={model.id}
onClick={async () => {
try {
const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}&id=${encodeURIComponent(model.id)}`, { method: "DELETE" });
if (res.ok) await fetchDisabledModels();
} catch (error) {
console.log("Error enabling model:", error);
}
}}
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-dashed border-black/10 dark:border-white/10 text-xs text-text-muted hover:text-primary hover:border-primary/40 hover:bg-primary/5 transition-colors"
title="Restore model"
>
<span className="material-symbols-outlined text-[13px]">add</span>
{model.id}
</button>
))}
</div>
</div>
)}
</div> </div>
); );
}; };
@@ -1641,27 +1577,6 @@ export default function ProviderDetailPage() {
</select> </select>
)} )}
</div> </div>
{!isCompatible && (() => {
const allIds = [
...models,
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)),
].filter((m) => { const k = getModelKind(m); return !k || k === "llm"; }).map((m) => m.id);
const activeIds = allIds.filter((id) => !disabledModelIds.includes(id));
return (
<div className="flex gap-2">
{disabledModelIds.length > 0 && (
<Button size="sm" variant="secondary" icon="restart_alt" onClick={handleEnableAll}>
Active All
</Button>
)}
{activeIds.length > 0 && (
<Button size="sm" variant="secondary" icon="block" onClick={() => handleDisableAll(activeIds)}>
Disable All
</Button>
)}
</div>
);
})()}
</div> </div>
{!!modelsTestError && ( {!!modelsTestError && (
<p className="text-xs text-red-500 mb-3 break-words">{modelsTestError}</p> <p className="text-xs text-red-500 mb-3 break-words">{modelsTestError}</p>
+3 -53
View File
@@ -6,9 +6,8 @@ import {
getProviderNodes, getProviderNodes,
} from "@/models"; } from "@/models";
import { getUsers } from "@/lib/db"; import { getUsers } from "@/lib/db";
import { disableModels, enableModels, getDisabledModels } from "@/lib/disabledModelsDb";
import { getDeletedModels } from "@/lib/db"; import { getDeletedModels } from "@/lib/db";
import { requireAdminUser, requireUsageDashboardUser } from "@/lib/auth/currentUser"; import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
import { import {
AI_PROVIDERS, AI_PROVIDERS,
getProviderAlias, getProviderAlias,
@@ -73,15 +72,6 @@ function getCompatibleProviderLabel(providerId, node, connection) {
}; };
} }
function getModelDisabledState(disabledModels, storageAlias, providerEntry, modelId) {
const disabled = new Set([
...(disabledModels[storageAlias] || []),
...(disabledModels[providerEntry.providerId] || []),
...(disabledModels[providerEntry.providerAlias] || []),
]);
return disabled.has(modelId);
}
function isDeletedModelId(deletedModels, storageAlias, providerEntry, modelId) { function isDeletedModelId(deletedModels, storageAlias, providerEntry, modelId) {
const deleted = new Set([ const deleted = new Set([
...(deletedModels[storageAlias] || []), ...(deletedModels[storageAlias] || []),
@@ -95,7 +85,6 @@ function isDeletedModelId(deletedModels, storageAlias, providerEntry, modelId) {
} }
function createConnectedModel({ function createConnectedModel({
disabledModels,
deletedModels, deletedModels,
fullModel, fullModel,
isCustom, isCustom,
@@ -115,7 +104,6 @@ function createConnectedModel({
name: name || modelId, name: name || modelId,
fullModel, fullModel,
alias: modelAliases.get(fullModel) || modelId, alias: modelAliases.get(fullModel) || modelId,
disabled: getModelDisabledState(disabledModels, storageAlias, providerEntry, modelId),
isCustom, isCustom,
caps: { caps: {
vision: caps.vision, vision: caps.vision,
@@ -140,12 +128,11 @@ function getForbiddenResponse(error) {
// customModels records extend the catalog and can provide administrator names. // customModels records extend the catalog and can provide administrator names.
export async function GET() { export async function GET() {
try { try {
const user = await requireUsageDashboardUser(); await requireUsageDashboardUser();
const [connections, customModels, disabledModels, deletedModels, modelAliases, providerNodes, users] = await Promise.all([ const [connections, customModels, deletedModels, modelAliases, providerNodes, users] = await Promise.all([
getProviderConnections(), getProviderConnections(),
getCustomModels(), getCustomModels(),
getDisabledModels(),
getDeletedModels(), getDeletedModels(),
getModelAliases(), getModelAliases(),
getProviderNodes(), getProviderNodes(),
@@ -221,7 +208,6 @@ export async function GET() {
if (seenFullModels.has(fullModel)) return; if (seenFullModels.has(fullModel)) return;
seenFullModels.add(fullModel); seenFullModels.add(fullModel);
const connectedModel = createConnectedModel({ const connectedModel = createConnectedModel({
disabledModels,
deletedModels, deletedModels,
fullModel, fullModel,
isCustom, isCustom,
@@ -273,7 +259,6 @@ export async function GET() {
} }
const visibleModels = models const visibleModels = models
.filter((model) => user.role === "admin" || !model.disabled)
.sort((a, b) => ( .sort((a, b) => (
a.provider.name.localeCompare(b.provider.name) a.provider.name.localeCompare(b.provider.name)
|| a.name.localeCompare(b.name) || a.name.localeCompare(b.name)
@@ -289,38 +274,3 @@ export async function GET() {
return NextResponse.json({ error: "Failed to fetch connected models" }, { status: 500 }); 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 });
}
}
-71
View File
@@ -1,71 +0,0 @@
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 {
const { searchParams } = new URL(request.url);
const providerAlias = searchParams.get("providerAlias");
const all = await getDisabledModels();
if (providerAlias) return NextResponse.json({ ids: all[providerAlias] || [] });
return NextResponse.json({ disabled: all });
} catch (error) {
console.log("Error fetching disabled models:", error);
return NextResponse.json({ error: "Failed to fetch disabled models" }, { status: 500 });
}
}
// 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 });
}
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 });
}
}
// 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");
if (!providerAlias) {
return NextResponse.json({ error: "providerAlias required" }, { status: 400 });
}
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 });
}
}
+2 -4
View File
@@ -1,6 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getModelAliases, setModelAlias } from "@/models"; import { getModelAliases, setModelAlias } from "@/models";
import { getDisabledModels } from "@/lib/disabledModelsDb";
import { getDeletedModels, isDeletedModelReference } from "@/lib/db"; import { getDeletedModels, isDeletedModelReference } from "@/lib/db";
import { AI_MODELS } from "@/shared/constants/config"; import { AI_MODELS } from "@/shared/constants/config";
import { getProviderAlias } from "@/shared/constants/providers"; import { getProviderAlias } from "@/shared/constants/providers";
@@ -10,14 +9,13 @@ import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
export async function GET() { export async function GET() {
try { try {
const modelAliases = await getModelAliases(); const modelAliases = await getModelAliases();
const [disabled, deleted] = await Promise.all([getDisabledModels(), getDeletedModels()]); const deleted = await getDeletedModels();
const models = AI_MODELS const models = AI_MODELS
.filter((m) => { .filter((m) => {
const alias = getProviderAlias(m.provider) || m.provider; const alias = getProviderAlias(m.provider) || m.provider;
const list = disabled[alias] || disabled[m.provider] || [];
const deletedIds = [...(deleted[alias] || []), ...(deleted[m.provider] || [])]; const deletedIds = [...(deleted[alias] || []), ...(deleted[m.provider] || [])];
return !list.includes(m.model) && !deletedIds.some((id) => ( return !deletedIds.some((id) => (
m.model === id || (m.model.startsWith(`${id}(`) && m.model.endsWith(")")) m.model === id || (m.model.startsWith(`${id}(`) && m.model.endsWith(")"))
)); ));
}) })
+2 -13
View File
@@ -6,7 +6,6 @@ import {
isOpenAICompatibleProvider, isOpenAICompatibleProvider,
} from "@/shared/constants/providers"; } from "@/shared/constants/providers";
import { getApiKeyByKey, getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb"; import { getApiKeyByKey, getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb";
import { getDisabledModels } from "@/lib/disabledModelsDb";
import { getDeletedModels } from "@/lib/db"; import { getDeletedModels } from "@/lib/db";
import { resolveKiroModels } from "open-sse/services/kiroModels.js"; import { resolveKiroModels } from "open-sse/services/kiroModels.js";
import { resolveKimchiModels } from "open-sse/services/kimchiModels.js"; import { resolveKimchiModels } from "open-sse/services/kimchiModels.js";
@@ -256,14 +255,6 @@ export async function buildModelsList(kindFilter, ownerOrOptions = {}) {
console.log("Could not fetch model aliases"); console.log("Could not fetch model aliases");
} }
let disabledByAlias = {};
try {
disabledByAlias = await getDisabledModels();
} catch (e) {
console.log("Could not fetch disabled models");
}
const isDisabled = (alias, modelId) => Array.isArray(disabledByAlias[alias]) && disabledByAlias[alias].includes(modelId);
let deletedByAlias = {}; let deletedByAlias = {};
try { try {
deletedByAlias = await getDeletedModels(); deletedByAlias = await getDeletedModels();
@@ -311,7 +302,7 @@ export async function buildModelsList(kindFilter, ownerOrOptions = {}) {
if (!providerMatchesKinds(providerId, kindFilter)) continue; if (!providerMatchesKinds(providerId, kindFilter)) continue;
for (const model of providerModels) { for (const model of providerModels) {
if (!kindFilter.includes(modelKind(model))) continue; if (!kindFilter.includes(modelKind(model))) continue;
if (isDisabled(alias, model.id) || isDeleted(model.id, alias, providerId)) continue; if (isDeleted(model.id, alias, providerId)) continue;
models.push({ models.push({
id: `${alias}/${model.id}`, id: `${alias}/${model.id}`,
object: "model", object: "model",
@@ -467,9 +458,7 @@ export async function buildModelsList(kindFilter, ownerOrOptions = {}) {
const allowAsLlm = kind === "imageToText" && kindFilter.includes(LLM_KIND); const allowAsLlm = kind === "imageToText" && kindFilter.includes(LLM_KIND);
if (!kindFilter.includes(kind) && !allowAsLlm) continue; if (!kindFilter.includes(kind) && !allowAsLlm) continue;
if ( if (
isDisabled(outputAlias, modelId) isDeleted(modelId, outputAlias, staticAlias, providerId)
|| isDisabled(staticAlias, modelId)
|| isDeleted(modelId, outputAlias, staticAlias, providerId)
) continue; ) continue;
const model = { const model = {
+1 -16
View File
@@ -78,11 +78,6 @@ export {
getPricing, getPricingForModel, updatePricing, resetPricing, resetAllPricing, getPricing, getPricingForModel, updatePricing, resetPricing, resetAllPricing,
} from "./repos/pricingRepo.js"; } from "./repos/pricingRepo.js";
// Disabled models
export {
getDisabledModels, getDisabledByProvider, disableModels, enableModels,
} from "./repos/disabledModelsRepo.js";
// Permanently deleted models // Permanently deleted models
export { export {
getDeletedModels, isDeletedModel, isDeletedModelReference, deleteModelPermanently, getDeletedModels, isDeletedModel, isDeletedModelReference, deleteModelPermanently,
@@ -120,7 +115,6 @@ export async function exportDb() {
customModels: [], customModels: [],
mitmAlias: {}, mitmAlias: {},
pricing: {}, pricing: {},
disabledModels: {},
deletedModels: {}, deletedModels: {},
}; };
@@ -128,7 +122,6 @@ export async function exportDb() {
for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'customModels'`)) out.customModels.push(parseJson(r.value)); for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'customModels'`)) out.customModels.push(parseJson(r.value));
for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'mitmAlias'`)) out.mitmAlias[r.key] = parseJson(r.value); for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'mitmAlias'`)) out.mitmAlias[r.key] = parseJson(r.value);
for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'pricing'`)) out.pricing[r.key] = parseJson(r.value); for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'pricing'`)) out.pricing[r.key] = parseJson(r.value);
for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'disabledModels'`)) out.disabledModels[r.key] = parseJson(r.value, []);
for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'deletedModels'`)) out.deletedModels[r.key] = parseJson(r.value, []); for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'deletedModels'`)) out.deletedModels[r.key] = parseJson(r.value, []);
return out; return out;
@@ -162,7 +155,7 @@ export async function importDb(payload) {
db.run(`DELETE FROM proxyPools`); db.run(`DELETE FROM proxyPools`);
db.run(`DELETE FROM apiKeys`); db.run(`DELETE FROM apiKeys`);
db.run(`DELETE FROM combos`); db.run(`DELETE FROM combos`);
db.run(`DELETE FROM kv WHERE scope IN ('modelAliases', 'customModels', 'mitmAlias', 'pricing', 'disabledModels', 'deletedModels')`); db.run(`DELETE FROM kv WHERE scope IN ('modelAliases', 'customModels', 'mitmAlias', 'pricing', 'deletedModels')`);
// Settings // Settings
if (payload.settings) { if (payload.settings) {
@@ -261,14 +254,6 @@ export async function importDb(payload) {
for (const [provider, models] of Object.entries(payload.pricing || {})) { for (const [provider, models] of Object.entries(payload.pricing || {})) {
db.run(`INSERT OR REPLACE INTO kv(scope, key, value) VALUES('pricing', ?, ?)`, [provider, stringifyJson(models || {})]); db.run(`INSERT OR REPLACE INTO kv(scope, key, value) VALUES('pricing', ?, ?)`, [provider, stringifyJson(models || {})]);
} }
for (const [providerAlias, modelIds] of Object.entries(payload.disabledModels || {})) {
const validModelIds = Array.isArray(modelIds)
? modelIds.filter((modelId) => typeof modelId === "string" && modelId)
: [];
if (providerAlias && validModelIds.length > 0) {
db.run(`INSERT OR REPLACE INTO kv(scope, key, value) VALUES('disabledModels', ?, ?)`, [providerAlias, stringifyJson([...new Set(validModelIds)])]);
}
}
for (const [providerAlias, modelIds] of Object.entries(payload.deletedModels || {})) { for (const [providerAlias, modelIds] of Object.entries(payload.deletedModels || {})) {
const validModelIds = Array.isArray(modelIds) const validModelIds = Array.isArray(modelIds)
? modelIds.filter((modelId) => typeof modelId === "string" && modelId) ? modelIds.filter((modelId) => typeof modelId === "string" && modelId)
+1 -10
View File
@@ -206,13 +206,6 @@ function importLegacyUsage(adapter, data) {
} }
} }
function importLegacyDisabled(adapter, data) {
if (!data || typeof data.disabled !== "object") return;
for (const [provider, ids] of Object.entries(data.disabled)) {
adapter.run(`INSERT OR REPLACE INTO kv(scope, key, value) VALUES('disabledModels', ?, ?)`, [provider, stringifyJson(ids || [])]);
}
}
function importLegacyDetails(adapter, data) { function importLegacyDetails(adapter, data) {
if (!data || !Array.isArray(data.records)) return; if (!data || !Array.isArray(data.records)) return;
for (const r of data.records) { for (const r of data.records) {
@@ -267,9 +260,8 @@ export async function runMigrationOnce(adapter) {
const alreadyImported = fs.existsSync(MIGRATED_MARKER); const alreadyImported = fs.existsSync(MIGRATED_MARKER);
const legacyMain = readJsonSafe(LEGACY_FILES.main); const legacyMain = readJsonSafe(LEGACY_FILES.main);
const legacyUsage = readJsonSafe(LEGACY_FILES.usage); const legacyUsage = readJsonSafe(LEGACY_FILES.usage);
const legacyDisabled = readJsonSafe(LEGACY_FILES.disabled);
const legacyDetails = readJsonSafe(LEGACY_FILES.details); const legacyDetails = readJsonSafe(LEGACY_FILES.details);
const hasLegacy = !!(legacyMain || legacyUsage || legacyDisabled || legacyDetails); const hasLegacy = !!(legacyMain || legacyUsage || legacyDetails);
if (fresh && hasLegacy && !alreadyImported) { if (fresh && hasLegacy && !alreadyImported) {
const t0 = Date.now(); const t0 = Date.now();
@@ -280,7 +272,6 @@ export async function runMigrationOnce(adapter) {
adapter.transaction(() => { adapter.transaction(() => {
importLegacyMain(adapter, legacyMain); importLegacyMain(adapter, legacyMain);
importLegacyUsage(adapter, legacyUsage); importLegacyUsage(adapter, legacyUsage);
importLegacyDisabled(adapter, legacyDisabled);
importLegacyDetails(adapter, legacyDetails); importLegacyDetails(adapter, legacyDetails);
setMetaSync(adapter, "appVersion", getAppVersion()); setMetaSync(adapter, "appVersion", getAppVersion());
setMetaSync(adapter, "backupSchemaVersion", SCHEMA_VERSION); setMetaSync(adapter, "backupSchemaVersion", SCHEMA_VERSION);
-1
View File
@@ -8,7 +8,6 @@ export const BACKUPS_DIR = path.join(DB_DIR, "backups");
export const LEGACY_FILES = { export const LEGACY_FILES = {
main: path.join(DATA_DIR, "db.json"), main: path.join(DATA_DIR, "db.json"),
usage: path.join(DATA_DIR, "usage.json"), usage: path.join(DATA_DIR, "usage.json"),
disabled: path.join(DATA_DIR, "disabledModels.json"),
details: path.join(DATA_DIR, "request-details.json"), details: path.join(DATA_DIR, "request-details.json"),
}; };
export function ensureDirs() { export function ensureDirs() {
+1 -16
View File
@@ -44,7 +44,7 @@ function modelWhereClause(column, modelId) {
function removeModelFromKvScopesSync(db, providerAliases, modelId) { function removeModelFromKvScopesSync(db, providerAliases, modelId) {
const aliases = new Set(providerAliases); const aliases = new Set(providerAliases);
const result = { aliases: 0, customModels: 0, pricing: 0, disabledModels: 0 }; const result = { aliases: 0, customModels: 0, pricing: 0 };
const modelAliasRows = db.all(`SELECT key, value FROM kv WHERE scope = 'modelAliases'`); const modelAliasRows = db.all(`SELECT key, value FROM kv WHERE scope = 'modelAliases'`);
for (const row of modelAliasRows) { for (const row of modelAliasRows) {
@@ -76,20 +76,6 @@ function removeModelFromKvScopesSync(db, providerAliases, modelId) {
} }
} }
const disabledRows = db.all(`SELECT key, value FROM kv WHERE scope = 'disabledModels'`);
for (const row of disabledRows) {
if (!aliases.has(row.key)) continue;
const current = normalizeIds(parseJson(row.value, []));
const next = current.filter((storedModelId) => !matchesDeletedModelId(storedModelId, modelId));
result.disabledModels += current.length - next.length;
if (next.length === current.length) continue;
if (next.length === 0) {
db.run(`DELETE FROM kv WHERE scope = 'disabledModels' AND key = ?`, [row.key]);
} else {
db.run(`UPDATE kv SET value = ? WHERE scope = 'disabledModels' AND key = ?`, [stringifyJson(next), row.key]);
}
}
return result; return result;
} }
@@ -337,7 +323,6 @@ export async function deleteModelPermanently(providerAlias, modelId) {
removedAliases: kv.aliases, removedAliases: kv.aliases,
removedCustomModels: kv.customModels, removedCustomModels: kv.customModels,
removedPricingEntries: kv.pricing, removedPricingEntries: kv.pricing,
removedDisabledModels: kv.disabledModels,
updatedComboIds: combos.updatedComboIds, updatedComboIds: combos.updatedComboIds,
deletedComboIds: combos.deletedComboIds, deletedComboIds: combos.deletedComboIds,
updatedCliToolConfigs, updatedCliToolConfigs,
-56
View File
@@ -1,56 +0,0 @@
import { getAdapter } from "../driver.js";
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
const SCOPE = "disabledModels";
export async function getDisabledModels() {
const db = await getAdapter();
const rows = db.all(`SELECT key, value FROM kv WHERE scope = ?`, [SCOPE]);
const out = {};
for (const r of rows) out[r.key] = parseJson(r.value, []);
return out;
}
export async function getDisabledByProvider(providerAlias) {
const db = await getAdapter();
const row = db.get(`SELECT value FROM kv WHERE scope = ? AND key = ?`, [SCOPE, providerAlias]);
return row ? (parseJson(row.value, []) || []) : [];
}
// Atomic read-merge-write inside a transaction (no JS yield mid-transaction).
export async function disableModels(providerAlias, ids) {
if (!providerAlias || !Array.isArray(ids)) return;
const db = await getAdapter();
db.transaction(() => {
const row = db.get(`SELECT value FROM kv WHERE scope = ? AND key = ?`, [SCOPE, providerAlias]);
const current = row ? (parseJson(row.value, []) || []) : [];
const merged = [...new Set([...current, ...ids])];
db.run(
`INSERT INTO kv(scope, key, value) VALUES(?, ?, ?) ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value`,
[SCOPE, providerAlias, stringifyJson(merged)]
);
});
}
export async function enableModels(providerAlias, ids) {
if (!providerAlias) return;
const db = await getAdapter();
db.transaction(() => {
if (!Array.isArray(ids) || ids.length === 0) {
db.run(`DELETE FROM kv WHERE scope = ? AND key = ?`, [SCOPE, providerAlias]);
return;
}
const row = db.get(`SELECT value FROM kv WHERE scope = ? AND key = ?`, [SCOPE, providerAlias]);
const current = row ? (parseJson(row.value, []) || []) : [];
const removeSet = new Set(ids);
const next = current.filter((id) => !removeSet.has(id));
if (next.length === 0) {
db.run(`DELETE FROM kv WHERE scope = ? AND key = ?`, [SCOPE, providerAlias]);
} else {
db.run(
`INSERT INTO kv(scope, key, value) VALUES(?, ?, ?) ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value`,
[SCOPE, providerAlias, stringifyJson(next)]
);
}
});
}
-4
View File
@@ -1,4 +0,0 @@
// Shim → re-export from new SQLite-based DB layer (src/lib/db/)
export {
getDisabledModels, getDisabledByProvider, disableModels, enableModels,
} from "@/lib/db/index.js";
+10 -25
View File
@@ -48,7 +48,6 @@ export default function ModelSelectModal({
const [combos, setCombos] = useState([]); const [combos, setCombos] = useState([]);
const [providerNodes, setProviderNodes] = useState([]); const [providerNodes, setProviderNodes] = useState([]);
const [customModels, setCustomModels] = useState([]); const [customModels, setCustomModels] = useState([]);
const [disabledModels, setDisabledModels] = useState({});
const [deletedModels, setDeletedModels] = useState({}); const [deletedModels, setDeletedModels] = useState({});
const fetchCombos = async () => { const fetchCombos = async () => {
@@ -102,28 +101,20 @@ export default function ModelSelectModal({
if (isOpen) fetchCustomModels(); if (isOpen) fetchCustomModels();
}, [isOpen]); }, [isOpen]);
const fetchDisabledModels = async () => { const fetchDeletedModels = async () => {
try { try {
const [disabledRes, deletedRes] = await Promise.all([ const deletedRes = await fetch("/api/models/delete");
fetch("/api/models/disabled"), if (!deletedRes.ok) throw new Error(`Failed to fetch deleted models: ${deletedRes.status}`);
fetch("/api/models/delete"), const deletedData = await deletedRes.json();
]);
if (!disabledRes.ok) throw new Error(`Failed to fetch disabled models: ${disabledRes.status}`);
const [disabledData, deletedData] = await Promise.all([
disabledRes.json(),
deletedRes.ok ? deletedRes.json() : Promise.resolve({ deleted: {} }),
]);
setDisabledModels(disabledData.disabled || {});
setDeletedModels(deletedData.deleted || {}); setDeletedModels(deletedData.deleted || {});
} catch (error) { } catch (error) {
console.error("Error fetching disabled models:", error); console.error("Error fetching deleted models:", error);
setDisabledModels({});
setDeletedModels({}); setDeletedModels({});
} }
}; };
useEffect(() => { useEffect(() => {
if (isOpen) fetchDisabledModels(); if (isOpen) fetchDeletedModels();
}, [isOpen]); }, [isOpen]);
const allProviders = useMemo(() => ({ ...OAUTH_PROVIDERS, ...FREE_PROVIDERS, ...FREE_TIER_PROVIDERS, ...APIKEY_PROVIDERS }), []); const allProviders = useMemo(() => ({ ...OAUTH_PROVIDERS, ...FREE_PROVIDERS, ...FREE_TIER_PROVIDERS, ...APIKEY_PROVIDERS }), []);
@@ -379,21 +370,15 @@ export default function ModelSelectModal({
} }
}); });
// Filter out disabled models per provider (disabled keyed by storage alias OR providerId) // Filter out permanently deleted models per provider (keyed by storage alias OR provider ID).
Object.entries(groups).forEach(([providerId, group]) => { Object.entries(groups).forEach(([providerId, group]) => {
const aliasKey = getProviderAlias(providerId); const aliasKey = getProviderAlias(providerId);
const disabledIds = new Set([
...(disabledModels[aliasKey] || []),
...(disabledModels[providerId] || []),
]);
const deletedIds = new Set([ const deletedIds = new Set([
...(deletedModels[aliasKey] || []), ...(deletedModels[aliasKey] || []),
...(deletedModels[providerId] || []), ...(deletedModels[providerId] || []),
]); ]);
if (disabledIds.size === 0 && deletedIds.size === 0) return; if (deletedIds.size === 0) return;
group.models = group.models.filter((model) => !disabledIds.has(model.id) && ![ group.models = group.models.filter((model) => ![...deletedIds].some((deletedModelId) => (
...deletedIds,
].some((deletedModelId) => (
model.id === deletedModelId model.id === deletedModelId
|| (model.id.startsWith(`${deletedModelId}(`) && model.id.endsWith(")")) || (model.id.startsWith(`${deletedModelId}(`) && model.id.endsWith(")"))
))); )));
@@ -401,7 +386,7 @@ export default function ModelSelectModal({
}); });
return groups; return groups;
}, [availableModels, filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, deletedModels, kindFilter, activeProviders]); }, [availableModels, filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, deletedModels, kindFilter, activeProviders]);
// Filter combos by search query (and hide combos when kindFilter is set — combos are LLM-only by design) // Filter combos by search query (and hide combos when kindFilter is set — combos are LLM-only by design)
const filteredCombos = useMemo(() => { const filteredCombos = useMemo(() => {
+3 -3
View File
@@ -25,7 +25,7 @@ import { detectFormatByEndpoint } from "open-sse/translator/formats.js";
import * as log from "../utils/logger.js"; import * as log from "../utils/logger.js";
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js"; import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
import { getProjectIdForConnection } from "open-sse/services/projectId.js"; import { getProjectIdForConnection } from "open-sse/services/projectId.js";
import { getDisabledModelResponse } from "../services/disabledModels.js"; import { getDeletedModelResponse } from "../services/deletedModels.js";
import { checkUserTokenLimit } from "@/lib/tokenLimitEnforcer.js"; import { checkUserTokenLimit } from "@/lib/tokenLimitEnforcer.js";
/** /**
@@ -201,8 +201,8 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
const { provider, model } = modelInfo; const { provider, model } = modelInfo;
const disabledModelResponse = await getDisabledModelResponse(provider, model); const deletedModelResponse = await getDeletedModelResponse(provider, model);
if (disabledModelResponse) return disabledModelResponse; if (deletedModelResponse) return deletedModelResponse;
const tokenLimitResult = await checkUserTokenLimit(ownerId, provider); const tokenLimitResult = await checkUserTokenLimit(ownerId, provider);
if (tokenLimitResult) { if (tokenLimitResult) {
+3 -3
View File
@@ -13,7 +13,7 @@ import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js"; import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
import * as log from "../utils/logger.js"; import * as log from "../utils/logger.js";
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js"; import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
import { getDisabledModelResponse } from "../services/disabledModels.js"; import { getDeletedModelResponse } from "../services/deletedModels.js";
/** /**
* Handle embeddings request for the SSE/Next.js server. * Handle embeddings request for the SSE/Next.js server.
@@ -76,8 +76,8 @@ export async function handleEmbeddings(request) {
const { provider, model } = modelInfo; const { provider, model } = modelInfo;
const disabledModelResponse = await getDisabledModelResponse(provider, model); const deletedModelResponse = await getDeletedModelResponse(provider, model);
if (disabledModelResponse) return disabledModelResponse; if (deletedModelResponse) return deletedModelResponse;
if (modelStr !== `${provider}/${model}`) { if (modelStr !== `${provider}/${model}`) {
log.info("ROUTING", `${modelStr}${provider}/${model}`); log.info("ROUTING", `${modelStr}${provider}/${model}`);
+3 -3
View File
@@ -14,7 +14,7 @@ import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js"; import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
import { handleComboChat } from "open-sse/services/combo.js"; import { handleComboChat } from "open-sse/services/combo.js";
import * as log from "../utils/logger.js"; import * as log from "../utils/logger.js";
import { getDisabledModelResponse } from "../services/disabledModels.js"; import { getDeletedModelResponse } from "../services/deletedModels.js";
// Providers that don't require credentials (noAuth) // Providers that don't require credentials (noAuth)
const NO_AUTH_PROVIDERS = new Set(["sdwebui", "comfyui"]); const NO_AUTH_PROVIDERS = new Set(["sdwebui", "comfyui"]);
@@ -78,8 +78,8 @@ async function handleSingleModelImage(body, modelStr, { wantsStream, binaryOutpu
const { provider, model } = modelInfo; const { provider, model } = modelInfo;
const disabledModelResponse = await getDisabledModelResponse(provider, model); const deletedModelResponse = await getDeletedModelResponse(provider, model);
if (disabledModelResponse) return disabledModelResponse; if (deletedModelResponse) return deletedModelResponse;
// noAuth providers — no credential needed // noAuth providers — no credential needed
if (NO_AUTH_PROVIDERS.has(provider)) { if (NO_AUTH_PROVIDERS.has(provider)) {
+3 -3
View File
@@ -9,7 +9,7 @@ import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js"; import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
import { AI_PROVIDERS } from "@/shared/constants/providers"; import { AI_PROVIDERS } from "@/shared/constants/providers";
import * as log from "../utils/logger.js"; import * as log from "../utils/logger.js";
import { getDisabledModelResponse } from "../services/disabledModels.js"; import { getDeletedModelResponse } from "../services/deletedModels.js";
// Providers requiring credentials for STT // Providers requiring credentials for STT
const CREDENTIALED_PROVIDERS = new Set( const CREDENTIALED_PROVIDERS = new Set(
@@ -46,8 +46,8 @@ export async function handleStt(request) {
const { provider, model } = modelInfo; const { provider, model } = modelInfo;
const disabledModelResponse = await getDisabledModelResponse(provider, model); const deletedModelResponse = await getDeletedModelResponse(provider, model);
if (disabledModelResponse) return disabledModelResponse; if (deletedModelResponse) return deletedModelResponse;
log.info("ROUTING", `Provider: ${provider}, Model: ${model}`); log.info("ROUTING", `Provider: ${provider}, Model: ${model}`);
+3 -3
View File
@@ -11,7 +11,7 @@ import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
import { AI_PROVIDERS } from "@/shared/constants/providers"; import { AI_PROVIDERS } from "@/shared/constants/providers";
import { handleComboChat } from "open-sse/services/combo.js"; import { handleComboChat } from "open-sse/services/combo.js";
import * as log from "../utils/logger.js"; import * as log from "../utils/logger.js";
import { getDisabledModelResponse } from "../services/disabledModels.js"; import { getDeletedModelResponse } from "../services/deletedModels.js";
// Derived from providers.js: any TTS provider not noAuth requires stored credentials // Derived from providers.js: any TTS provider not noAuth requires stored credentials
const CREDENTIALED_PROVIDERS = new Set( const CREDENTIALED_PROVIDERS = new Set(
@@ -75,8 +75,8 @@ async function handleSingleModelTts(body, modelStr, responseFormat, language, ow
const { provider, model } = modelInfo; const { provider, model } = modelInfo;
const disabledModelResponse = await getDisabledModelResponse(provider, model); const deletedModelResponse = await getDeletedModelResponse(provider, model);
if (disabledModelResponse) return disabledModelResponse; if (deletedModelResponse) return deletedModelResponse;
log.info("ROUTING", `Provider: ${provider}, Voice: ${model}`); log.info("ROUTING", `Provider: ${provider}, Voice: ${model}`);
+24
View File
@@ -0,0 +1,24 @@
import { isDeletedModel } from "@/lib/db";
import { errorResponse } from "open-sse/utils/error.js";
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
/**
* Return a not-found response when a resolved provider/model pair has been
* permanently deleted by an administrator.
*/
export async function getDeletedModelResponse(provider, model) {
try {
if (!await isDeletedModel(provider, model)) return null;
return errorResponse(
HTTP_STATUS.NOT_FOUND,
`Model ${provider}/${model} has been deleted by an administrator`,
);
} catch (error) {
console.log("Error checking deleted model status:", error);
return errorResponse(
HTTP_STATUS.SERVER_ERROR,
"Unable to verify whether the requested model has been deleted",
);
}
}
-53
View File
@@ -1,53 +0,0 @@
import { getDisabledModels } from "@/lib/disabledModelsDb";
import { getDeletedModels } from "@/lib/db";
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
* 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, deletedModels] = await Promise.all([getDisabledModels(), getDeletedModels()]);
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] || []),
]);
const deletedIds = new Set([
...(deletedModels[providerAlias] || []),
...(deletedModels[provider] || []),
]);
const matchesDeletedModel = [...deletedIds].some((modelId) => (
model === modelId
|| baseModel === modelId
|| (model.startsWith(`${modelId}(`) && model.endsWith(")"))
));
if (!matchesDeletedModel && !disabledIds.has(model) && !disabledIds.has(baseModel)) return null;
return errorResponse(
HTTP_STATUS.NOT_FOUND,
matchesDeletedModel
? `Model ${provider}/${model} has been deleted by an administrator`
: `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",
);
}
}
+12 -41
View File
@@ -6,7 +6,6 @@ const getCustomModels = vi.fn();
const getProviderNodes = vi.fn(); const getProviderNodes = vi.fn();
const getUsers = vi.fn(); const getUsers = vi.fn();
const getDeletedModels = vi.fn(); const getDeletedModels = vi.fn();
const getDisabledModels = vi.fn();
const requireUsageDashboardUser = vi.fn(); const requireUsageDashboardUser = vi.fn();
const getCapabilitiesForModel = vi.fn(); const getCapabilitiesForModel = vi.fn();
@@ -19,7 +18,6 @@ vi.mock("@/models", () => ({
vi.mock("@/lib/db", () => ({ getUsers, getDeletedModels })); vi.mock("@/lib/db", () => ({ getUsers, getDeletedModels }));
vi.mock("@/lib/disabledModelsDb", () => ({ getDisabledModels }));
vi.mock("@/lib/auth/currentUser", () => ({ vi.mock("@/lib/auth/currentUser", () => ({
requireUsageDashboardUser, requireUsageDashboardUser,
})); }));
@@ -27,7 +25,7 @@ vi.mock("open-sse/config/providerModels.js", () => ({
getModelsByProviderId: (providerId) => ({ getModelsByProviderId: (providerId) => ({
alpha: [ alpha: [
{ id: "enabled", name: "Enabled model" }, { id: "enabled", name: "Enabled model" },
{ id: "disabled", name: "Disabled model" }, { id: "alternative", name: "Alternative model" },
], ],
beta: [{ id: "inactive", name: "Inactive provider model" }], beta: [{ id: "inactive", name: "Inactive provider model" }],
"orbit-provider": [ "orbit-provider": [
@@ -63,16 +61,14 @@ describe("GET /api/models/connected", () => {
getProviderNodes.mockReset(); getProviderNodes.mockReset();
getUsers.mockReset(); getUsers.mockReset();
getDeletedModels.mockReset(); getDeletedModels.mockReset();
getDisabledModels.mockReset();
requireUsageDashboardUser.mockReset(); requireUsageDashboardUser.mockReset();
getCapabilitiesForModel.mockReset(); getCapabilitiesForModel.mockReset();
getModelAliases.mockResolvedValue({ "preferred-alpha": "alpha-alias/enabled" }); getModelAliases.mockResolvedValue({ "preferred-alpha": "alpha-alias/enabled" });
getDisabledModels.mockResolvedValue({ "alpha-alias": ["disabled"] });
getDeletedModels.mockResolvedValue({}); getDeletedModels.mockResolvedValue({});
getCustomModels.mockResolvedValue([ getCustomModels.mockResolvedValue([
{ providerAlias: "alpha-alias", id: "enabled", name: "Enabled model", type: "llm" }, { providerAlias: "alpha-alias", id: "enabled", name: "Enabled model", type: "llm" },
{ providerAlias: "alpha-alias", id: "disabled", name: "Disabled model", type: "llm" }, { providerAlias: "alpha-alias", id: "alternative", name: "Alternative model", type: "llm" },
{ providerAlias: "alpha-alias", id: "embedding", name: "Embedding model", type: "embedding" }, { providerAlias: "alpha-alias", id: "embedding", name: "Embedding model", type: "embedding" },
{ providerAlias: "beta-alias", id: "inactive", name: "Inactive provider model", type: "llm" }, { providerAlias: "beta-alias", id: "inactive", name: "Inactive provider model", type: "llm" },
]); ]);
@@ -85,7 +81,7 @@ describe("GET /api/models/connected", () => {
]); ]);
}); });
it("returns added models for a connected provider to an administrator, including disabled rows", async () => { it("returns added models for a connected provider", async () => {
requireUsageDashboardUser.mockResolvedValue({ id: "admin", role: "admin" }); requireUsageDashboardUser.mockResolvedValue({ id: "admin", role: "admin" });
const response = await GET(); const response = await GET();
@@ -94,14 +90,12 @@ describe("GET /api/models/connected", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(body.models).toEqual([ expect(body.models).toEqual([
expect.objectContaining({ expect.objectContaining({
fullModel: "alpha-alias/disabled", fullModel: "alpha-alias/alternative",
providerAlias: "alpha-alias", providerAlias: "alpha-alias",
disabled: true,
}), }),
expect.objectContaining({ expect.objectContaining({
fullModel: "alpha-alias/enabled", fullModel: "alpha-alias/enabled",
alias: "preferred-alpha", alias: "preferred-alpha",
disabled: false,
caps: { vision: false, search: true, reasoning: true }, caps: { vision: false, search: true, reasoning: true },
}), }),
]); ]);
@@ -126,8 +120,7 @@ describe("GET /api/models/connected", () => {
isCustom: false, isCustom: false,
}), }),
expect.objectContaining({ expect.objectContaining({
fullModel: "alpha-alias/disabled", fullModel: "alpha-alias/alternative",
disabled: true,
isCustom: false, isCustom: false,
}), }),
])); ]));
@@ -171,16 +164,17 @@ describe("GET /api/models/connected", () => {
expect(body.models.filter((model) => model.fullModel === "orbit/claude-opus-4-8")).toHaveLength(1); expect(body.models.filter((model) => model.fullModel === "orbit/claude-opus-4-8")).toHaveLength(1);
}); });
it("excludes disabled models for non-administrators", async () => { it("returns the same catalog to non-administrators", async () => {
requireUsageDashboardUser.mockResolvedValue({ id: "member", role: "user" }); requireUsageDashboardUser.mockResolvedValue({ id: "member", role: "user" });
const response = await GET(); const response = await GET();
const body = await response.json(); const body = await response.json();
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(body.models).toEqual([ expect(body.models).toEqual(expect.arrayContaining([
expect.objectContaining({ fullModel: "alpha-alias/enabled", disabled: false }), expect.objectContaining({ fullModel: "alpha-alias/enabled" }),
]); expect.objectContaining({ fullModel: "alpha-alias/alternative" }),
]));
}); });
it("includes administrator-managed compatible-provider models for non-administrators", async () => { it("includes administrator-managed compatible-provider models for non-administrators", async () => {
@@ -224,39 +218,16 @@ describe("GET /api/models/connected", () => {
])); ]));
}); });
it("does not expose disabled compatible-provider models to non-administrators", async () => {
const providerId = "anthropic-compatible-test-node";
requireUsageDashboardUser.mockResolvedValue({ id: "member", role: "user" });
getProviderConnections.mockResolvedValue([
{ provider: providerId, isActive: true, apiKey: "admin-secret", ownerId: "admin" },
]);
getProviderNodes.mockResolvedValue([
{ id: providerId, type: "anthropic-compatible", name: "Company Anthropic" },
]);
getCustomModels.mockResolvedValue([
{ providerAlias: providerId, id: "claude-company", type: "llm" },
]);
getDisabledModels.mockResolvedValue({ [providerId]: ["claude-company"] });
const response = await GET();
const body = await response.json();
expect(response.status).toBe(200);
expect(body.models).not.toEqual(expect.arrayContaining([
expect.objectContaining({ fullModel: `${providerId}/claude-company` }),
]));
});
it("does not expose permanently deleted models to administrators", async () => { it("does not expose permanently deleted models to administrators", async () => {
requireUsageDashboardUser.mockResolvedValue({ id: "admin", role: "admin" }); requireUsageDashboardUser.mockResolvedValue({ id: "admin", role: "admin" });
getDeletedModels.mockResolvedValue({ "alpha-alias": ["disabled", "enabled"] }); getDeletedModels.mockResolvedValue({ "alpha-alias": ["alternative", "enabled"] });
const response = await GET(); const response = await GET();
const body = await response.json(); const body = await response.json();
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(body.models).not.toEqual(expect.arrayContaining([ expect(body.models).not.toEqual(expect.arrayContaining([
expect.objectContaining({ fullModel: "alpha-alias/disabled" }), expect.objectContaining({ fullModel: "alpha-alias/alternative" }),
expect.objectContaining({ fullModel: "alpha-alias/enabled" }), expect.objectContaining({ fullModel: "alpha-alias/enabled" }),
])); ]));
}); });
+1 -5
View File
@@ -66,7 +66,7 @@ describe("DB Concurrency — atomic safety", () => {
expect(list.pagination.totalItems).toBeGreaterThanOrEqual(N); expect(list.pagination.totalItems).toBeGreaterThanOrEqual(N);
}, 15000); }, 15000);
it("mixed concurrent: usage + details + connections + aliases", async () => { it("mixed concurrent: usage, aliases, and connections", async () => {
const ops = []; const ops = [];
for (let i = 0; i < 50; i++) { for (let i = 0; i < 50; i++) {
ops.push(db.saveRequestUsage({ ops.push(db.saveRequestUsage({
@@ -74,16 +74,12 @@ describe("DB Concurrency — atomic safety", () => {
tokens: { prompt_tokens: 20 }, status: "ok", tokens: { prompt_tokens: 20 }, status: "ok",
})); }));
ops.push(db.setModelAlias(`a-${i}`, `target-${i}`)); ops.push(db.setModelAlias(`a-${i}`, `target-${i}`));
ops.push(db.disableModels("openai", [`d-${i}`]));
} }
await Promise.all(ops); await Promise.all(ops);
const aliases = await db.getModelAliases(); const aliases = await db.getModelAliases();
expect(Object.keys(aliases).filter((k) => k.startsWith("a-")).length).toBe(50); expect(Object.keys(aliases).filter((k) => k.startsWith("a-")).length).toBe(50);
const disabled = await db.getDisabledByProvider("openai");
expect(disabled.length).toBeGreaterThanOrEqual(50);
const stats = await db.getUsageStats("24h"); const stats = await db.getUsageStats("24h");
expect(stats.byProvider.anthropic.requests).toBe(50); expect(stats.byProvider.anthropic.requests).toBe(50);
}, 30000); }, 30000);
+1 -23
View File
@@ -275,15 +275,6 @@ describe("DB SQLite layer — public API parity", () => {
expect(all.cursor).toEqual({ "gpt-5": "claude-3" }); expect(all.cursor).toEqual({ "gpt-5": "claude-3" });
}); });
it("disabledModels: add/remove per provider", async () => {
await sqliteDb.disableModels("openai", ["gpt-3", "gpt-4"]);
expect(await sqliteDb.getDisabledByProvider("openai")).toEqual(expect.arrayContaining(["gpt-3", "gpt-4"]));
await sqliteDb.enableModels("openai", ["gpt-3"]);
expect(await sqliteDb.getDisabledByProvider("openai")).toEqual(["gpt-4"]);
await sqliteDb.enableModels("openai", []);
expect(await sqliteDb.getDisabledByProvider("openai")).toEqual([]);
});
it("usage: saveRequestUsage + getUsageHistory + getUsageStats", async () => { it("usage: saveRequestUsage + getUsageHistory + getUsageStats", async () => {
await sqliteDb.saveRequestUsage({ await sqliteDb.saveRequestUsage({
provider: "openai", model: "gpt-4", connectionId: "c1", provider: "openai", model: "gpt-4", connectionId: "c1",
@@ -341,6 +332,7 @@ describe("DB SQLite layer — public API parity", () => {
expect(exported.settings).toBeDefined(); expect(exported.settings).toBeDefined();
expect(Array.isArray(exported.providerConnections)).toBe(true); expect(Array.isArray(exported.providerConnections)).toBe(true);
expect(typeof exported.modelAliases).toBe("object"); expect(typeof exported.modelAliases).toBe("object");
expect(exported).not.toHaveProperty("disabledModels");
// Add marker, export, import a different payload, verify reset // Add marker, export, import a different payload, verify reset
await sqliteDb.setModelAlias("marker", "before"); await sqliteDb.setModelAlias("marker", "before");
@@ -353,20 +345,6 @@ describe("DB SQLite layer — public API parity", () => {
expect((await sqliteDb.getModelAliases()).marker).toBe("before"); expect((await sqliteDb.getModelAliases()).marker).toBe("before");
}); });
it("exportDb / importDb preserves disabled model settings", async () => {
await sqliteDb.disableModels("backup-provider", ["backup-model"]);
const snapshot = await sqliteDb.exportDb();
expect(snapshot.disabledModels).toMatchObject({
"backup-provider": ["backup-model"],
});
await sqliteDb.enableModels("backup-provider", []);
await sqliteDb.importDb(snapshot);
expect(await sqliteDb.getDisabledByProvider("backup-provider")).toEqual(["backup-model"]);
});
it("pricing: user pricing merged with constants", async () => { it("pricing: user pricing merged with constants", async () => {
await sqliteDb.updatePricing({ openai: { "gpt-test": { input: 1, output: 2 } } }); await sqliteDb.updatePricing({ openai: { "gpt-test": { input: 1, output: 2 } } });
const p = await sqliteDb.getPricing(); const p = await sqliteDb.getPricing();
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const isDeletedModel = vi.fn();
vi.mock("@/lib/db", () => ({ isDeletedModel }));
const { getDeletedModelResponse } = await import("../../src/sse/services/deletedModels.js");
describe("getDeletedModelResponse", () => {
beforeEach(() => {
isDeletedModel.mockReset();
});
it("allows a model that has not been permanently deleted", async () => {
isDeletedModel.mockResolvedValue(false);
await expect(getDeletedModelResponse("openai", "gpt-available")).resolves.toBeNull();
expect(isDeletedModel).toHaveBeenCalledWith("openai", "gpt-available");
});
it("blocks a permanently deleted model", async () => {
isDeletedModel.mockResolvedValue(true);
const response = await getDeletedModelResponse("openai", "gpt-deleted");
expect(response.status).toBe(404);
await expect(response.json()).resolves.toMatchObject({
error: {
code: "model_not_found",
message: "Model openai/gpt-deleted has been deleted by an administrator",
},
});
});
it("blocks permanently deleted thinking variants", async () => {
isDeletedModel.mockResolvedValue(true);
const response = await getDeletedModelResponse("codex", "gpt-deleted(high)");
expect(response.status).toBe(404);
expect(isDeletedModel).toHaveBeenCalledWith("codex", "gpt-deleted(high)");
});
it("fails closed when deleted-model storage cannot be read", async () => {
isDeletedModel.mockRejectedValue(new Error("database unavailable"));
const response = await getDeletedModelResponse("openai", "gpt-available");
expect(response.status).toBe(500);
});
});
@@ -1,85 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const getDisabledModels = vi.fn();
const getDeletedModels = vi.fn();
vi.mock("@/lib/disabledModelsDb", () => ({ getDisabledModels }));
vi.mock("@/lib/db", () => ({ getDeletedModels }));
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();
getDeletedModels.mockReset();
getDeletedModels.mockResolvedValue({});
});
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("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("blocks a permanently deleted model", async () => {
getDisabledModels.mockResolvedValue({});
getDeletedModels.mockResolvedValue({ oa: ["gpt-deleted"] });
const response = await getDisabledModelResponse("openai", "gpt-deleted");
expect(response.status).toBe(404);
await expect(response.json()).resolves.toMatchObject({
error: {
code: "model_not_found",
message: "Model openai/gpt-deleted has been deleted by an administrator",
},
});
});
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);
});
});
-69
View File
@@ -1,69 +0,0 @@
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();
});
});
@@ -42,7 +42,6 @@ describe("permanent model deletion", () => {
await db.setModelAlias("keep-alias", `${providerPrefix}/gpt-keep`); await db.setModelAlias("keep-alias", `${providerPrefix}/gpt-keep`);
await db.addCustomModel({ providerAlias: providerPrefix, id: modelId, type: "llm" }); await db.addCustomModel({ providerAlias: providerPrefix, id: modelId, type: "llm" });
await db.addCustomModel({ providerAlias: providerPrefix, id: "gpt-keep", type: "llm" }); await db.addCustomModel({ providerAlias: providerPrefix, id: "gpt-keep", type: "llm" });
await db.disableModels(providerPrefix, [modelId]);
await db.updatePricing({ await db.updatePricing({
[providerPrefix]: { [providerPrefix]: {
[modelId]: { prompt: 1, completion: 2 }, [modelId]: { prompt: 1, completion: 2 },
@@ -131,7 +130,6 @@ describe("permanent model deletion", () => {
removedAliases: 1, removedAliases: 1,
removedCustomModels: 1, removedCustomModels: 1,
removedPricingEntries: 1, removedPricingEntries: 1,
removedDisabledModels: 1,
removedRequestDetails: 1, removedRequestDetails: 1,
updatedCliToolConfigs: 2, updatedCliToolConfigs: 2,
updatedComboIds: [mixedCombo.id], updatedComboIds: [mixedCombo.id],
@@ -146,7 +144,6 @@ describe("permanent model deletion", () => {
expect(await db.getCustomModels()).toEqual([ expect(await db.getCustomModels()).toEqual([
expect.objectContaining({ providerAlias: providerPrefix, id: "gpt-keep" }), expect.objectContaining({ providerAlias: providerPrefix, id: "gpt-keep" }),
]); ]);
expect(await db.getDisabledByProvider(providerPrefix)).toEqual([]);
expect((await db.getPricing())[providerPrefix]).toEqual({ expect((await db.getPricing())[providerPrefix]).toEqual({
"gpt-keep": { prompt: 3, completion: 4 }, "gpt-keep": { prompt: 3, completion: 4 },
}); });