mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
fix: remove the suggest model in provider page
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(")"))
|
||||||
));
|
));
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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";
|
|
||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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" }),
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user