mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
fix: update the logic for remove model in provider
This commit is contained in:
@@ -254,8 +254,8 @@ export default function ModelsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-text-muted">
|
<p className="mt-1 text-sm text-text-muted">
|
||||||
{canManage
|
{canManage
|
||||||
? "Manage models explicitly added from connected providers."
|
? "Manage models available from connected providers."
|
||||||
: "Browse added models currently available through 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">
|
||||||
@@ -276,7 +276,7 @@ export default function ModelsPage() {
|
|||||||
<p className="mt-1 text-lg font-semibold tabular-nums text-text-main">{providerCount}</p>
|
<p className="mt-1 text-lg font-semibold tabular-nums text-text-main">{providerCount}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-surface px-4 py-3">
|
<div className="bg-surface px-4 py-3">
|
||||||
<p className="text-xs font-medium text-text-muted">Added models</p>
|
<p className="text-xs font-medium text-text-muted">Available models</p>
|
||||||
<p className="mt-1 text-lg font-semibold tabular-nums text-text-main">{models.length}</p>
|
<p className="mt-1 text-lg font-semibold tabular-nums text-text-main">{models.length}</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -296,7 +296,7 @@ export default function ModelsPage() {
|
|||||||
<span className="material-symbols-outlined text-[32px] text-text-muted">search_off</span>
|
<span className="material-symbols-outlined text-[32px] text-text-muted">search_off</span>
|
||||||
<p className="mt-2 text-sm text-text-muted">
|
<p className="mt-2 text-sm text-text-muted">
|
||||||
{models.length === 0
|
{models.length === 0
|
||||||
? "No models have been added. Open a connected provider and add a model first."
|
? "No models are available. Add an active provider connection or register a custom model first."
|
||||||
: "No models match your search."}
|
: "No models match your search."}
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { Button } from "@/shared/components";
|
import { Button } from "@/shared/components";
|
||||||
import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels";
|
import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels";
|
||||||
function CompatibleModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias, onTest, testStatus, isTesting }) {
|
function CompatibleModelRow({ modelId, fullModel, copied, onCopy, onDeleteModel, onTest, testStatus, isTesting }) {
|
||||||
const borderColor = testStatus === "ok"
|
const borderColor = testStatus === "ok"
|
||||||
? "border-green-500/40"
|
? "border-green-500/40"
|
||||||
: testStatus === "error"
|
: testStatus === "error"
|
||||||
@@ -61,9 +61,9 @@ function CompatibleModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onDeleteAlias}
|
onClick={onDeleteModel}
|
||||||
className="p-1 hover:bg-red-50 rounded text-red-500"
|
className="p-1 hover:bg-red-50 rounded text-red-500"
|
||||||
title="Remove model"
|
title="Permanently remove model from catalogs and saved configurations"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-sm">delete</span>
|
<span className="material-symbols-outlined text-sm">delete</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -71,7 +71,7 @@ function CompatibleModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, customModels, copied, onCopy, onDeleteAlias, onAddCustomModel, onDeleteCustomModel, connections, isAnthropic }) {
|
export default function CompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, customModels, copied, onCopy, onDeleteModel, onAddCustomModel, connections, isAnthropic }) {
|
||||||
const [newModel, setNewModel] = useState("");
|
const [newModel, setNewModel] = useState("");
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
@@ -202,7 +202,7 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider
|
|||||||
fullModel={`${providerDisplayAlias}/${id}`}
|
fullModel={`${providerDisplayAlias}/${id}`}
|
||||||
copied={copied}
|
copied={copied}
|
||||||
onCopy={onCopy}
|
onCopy={onCopy}
|
||||||
onDeleteAlias={() => source === "custom" ? onDeleteCustomModel(id) : onDeleteAlias(alias)}
|
onDeleteModel={() => onDeleteModel(id)}
|
||||||
onTest={connections.length > 0 ? () => handleTestModel(id) : undefined}
|
onTest={connections.length > 0 ? () => handleTestModel(id) : undefined}
|
||||||
testStatus={modelTestResults[id]}
|
testStatus={modelTestResults[id]}
|
||||||
isTesting={testingModelId === id}
|
isTesting={testingModelId === id}
|
||||||
@@ -221,9 +221,8 @@ CompatibleModelsSection.propTypes = {
|
|||||||
customModels: PropTypes.arrayOf(PropTypes.object),
|
customModels: PropTypes.arrayOf(PropTypes.object),
|
||||||
copied: PropTypes.string,
|
copied: PropTypes.string,
|
||||||
onCopy: PropTypes.func.isRequired,
|
onCopy: PropTypes.func.isRequired,
|
||||||
onDeleteAlias: PropTypes.func.isRequired,
|
onDeleteModel: PropTypes.func.isRequired,
|
||||||
onAddCustomModel: PropTypes.func.isRequired,
|
onAddCustomModel: PropTypes.func.isRequired,
|
||||||
onDeleteCustomModel: PropTypes.func.isRequired,
|
|
||||||
connections: PropTypes.arrayOf(PropTypes.shape({
|
connections: PropTypes.arrayOf(PropTypes.shape({
|
||||||
id: PropTypes.string,
|
id: PropTypes.string,
|
||||||
isActive: PropTypes.bool,
|
isActive: PropTypes.bool,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { CapacityBadges } from "@/shared/components";
|
import { CapacityBadges } from "@/shared/components";
|
||||||
|
|
||||||
export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isAdded, isFree, onDeleteAlias, onAdd, onRemove, onTest, isTesting, onDisable, caps, thinkingSuffix }) {
|
export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onRemove, onTest, isTesting, onDisable, 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"
|
||||||
@@ -14,9 +14,13 @@ 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 deletesPermanently = !!deleteModel;
|
||||||
|
|
||||||
|
const actionButtonClass = "inline-flex size-7 items-center justify-center rounded-md text-text-muted transition-[background-color,color,transform,box-shadow] duration-150 hover:bg-background hover:text-text-main focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-95 disabled:cursor-not-allowed disabled:opacity-50";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`group min-w-0 max-w-full rounded-lg border px-3 py-2 ${borderColor} hover:bg-sidebar/50`}>
|
<div className={`group min-w-0 max-w-full rounded-lg border px-3 py-2 transition-colors ${borderColor} hover:bg-sidebar/50 focus-within:border-primary/50`}>
|
||||||
<div className="flex min-w-0 items-start gap-2 sm:items-center">
|
<div className="flex min-w-0 items-start gap-2 sm:items-center">
|
||||||
<span
|
<span
|
||||||
className="material-symbols-outlined shrink-0 text-base"
|
className="material-symbols-outlined shrink-0 text-base"
|
||||||
@@ -25,67 +29,51 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test
|
|||||||
{testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"}
|
{testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
<code className="max-w-[72vw] truncate rounded bg-sidebar px-1.5 py-0.5 font-mono text-xs text-text-muted sm:max-w-[360px]">{displayModel}</code>
|
<code className="max-w-[72vw] truncate rounded bg-sidebar px-1.5 py-0.5 font-mono text-xs text-text-muted sm:max-w-90">{displayModel}</code>
|
||||||
<span className="flex min-w-0 items-center text-[9px] gap-1 pl-1">
|
<span className="flex min-w-0 items-center text-[9px] gap-1 pl-1">
|
||||||
{model.name && <span className="truncate text-[9px] italic text-text-muted/70">{model.name}</span>}
|
{model.name && <span className="truncate text-[9px] italic text-text-muted/70">{model.name}</span>}
|
||||||
<CapacityBadges caps={caps} colorOverride="text-text-muted/70" size={12} />
|
<CapacityBadges caps={caps} colorOverride="text-text-muted/70" size={12} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{onTest && (
|
<div className="ml-auto flex shrink-0 items-center gap-0.5 rounded-lg border border-border/80 bg-sidebar/60 p-1 shadow-[0_1px_0_rgb(255_255_255/0.03)]">
|
||||||
<div className="relative shrink-0 group/btn">
|
{onTest && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onTest}
|
onClick={onTest}
|
||||||
disabled={isTesting}
|
disabled={isTesting}
|
||||||
className={`rounded p-0.5 text-text-muted transition-opacity hover:bg-sidebar hover:text-primary ${isTesting ? "opacity-100" : "opacity-100 sm:opacity-0 sm:group-hover:opacity-100"}`}
|
className={actionButtonClass}
|
||||||
|
title={isTesting ? "Testing model" : "Test model"}
|
||||||
|
aria-label={isTesting ? `Testing ${fullModel}` : `Test ${fullModel}`}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-sm" style={isTesting ? { animation: "spin 1s linear infinite" } : undefined}>
|
<span className="material-symbols-outlined text-[17px]" style={isTesting ? { animation: "spin 1s linear infinite" } : undefined}>
|
||||||
{isTesting ? "progress_activity" : "science"}
|
{isTesting ? "progress_activity" : "science"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<span className="pointer-events-none absolute mt-1 top-5 left-1/2 -translate-x-1/2 text-[10px] text-text-muted whitespace-nowrap opacity-0 group-hover/btn:opacity-100 transition-opacity">
|
)}
|
||||||
{isTesting ? "Testing..." : "Test"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="relative shrink-0 group/btn">
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => onCopy(displayModel, `model-${model.id}`)}
|
onClick={() => onCopy(displayModel, `model-${model.id}`)}
|
||||||
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary"
|
className={actionButtonClass}
|
||||||
|
title={copied === `model-${model.id}` ? "Copied" : "Copy model ID"}
|
||||||
|
aria-label={copied === `model-${model.id}` ? `${fullModel} copied` : `Copy ${fullModel}`}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-sm">
|
<span className="material-symbols-outlined text-[17px]">
|
||||||
{copied === `model-${model.id}` ? "check" : "content_copy"}
|
{copied === `model-${model.id}` ? "check" : "content_copy"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<span className="pointer-events-none absolute mt-1 top-5 left-1/2 -translate-x-1/2 text-[10px] text-text-muted whitespace-nowrap opacity-0 group-hover/btn:opacity-100 transition-opacity">
|
{deleteModel && <span className="mx-0.5 h-4 w-px bg-border" aria-hidden="true" />}
|
||||||
{copied === `model-${model.id}` ? "Copied!" : "Copy"}
|
{deleteModel && (
|
||||||
</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={deleteModel}
|
||||||
|
className="inline-flex size-7 items-center justify-center rounded-md text-text-muted transition-[background-color,color,transform,box-shadow] duration-150 hover:bg-red-500/12 hover:text-red-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 active:scale-95"
|
||||||
|
title={deletesPermanently ? "Permanently remove model from catalogs and saved configurations" : "Hide model from the dashboard catalog"}
|
||||||
|
aria-label={deletesPermanently ? `Permanently delete ${fullModel}` : `Hide ${fullModel}`}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[17px]">delete</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{onAdd && !isAdded ? (
|
|
||||||
<button
|
|
||||||
onClick={onAdd}
|
|
||||||
className="ml-auto inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium text-primary transition-colors hover:bg-primary/10"
|
|
||||||
title="Add model to the dashboard catalog"
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined text-sm">add</span>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
) : onRemove || isCustom ? (
|
|
||||||
<button
|
|
||||||
onClick={onRemove || onDeleteAlias}
|
|
||||||
className="ml-auto rounded p-0.5 text-text-muted opacity-100 transition-opacity hover:bg-red-500/10 hover:text-red-500 sm:opacity-0 sm:group-hover:opacity-100"
|
|
||||||
title="Remove model from the dashboard catalog"
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined text-sm">close</span>
|
|
||||||
</button>
|
|
||||||
) : onDisable ? (
|
|
||||||
<button
|
|
||||||
onClick={onDisable}
|
|
||||||
className="ml-auto rounded p-0.5 text-text-muted opacity-100 transition-opacity hover:bg-red-500/10 hover:text-red-500 sm:opacity-0 sm:group-hover:opacity-100"
|
|
||||||
title="Disable this model"
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined text-sm">close</span>
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -101,10 +89,8 @@ ModelRow.propTypes = {
|
|||||||
onCopy: PropTypes.func.isRequired,
|
onCopy: PropTypes.func.isRequired,
|
||||||
testStatus: PropTypes.oneOf(["ok", "error"]),
|
testStatus: PropTypes.oneOf(["ok", "error"]),
|
||||||
isCustom: PropTypes.bool,
|
isCustom: PropTypes.bool,
|
||||||
isAdded: PropTypes.bool,
|
|
||||||
isFree: PropTypes.bool,
|
isFree: PropTypes.bool,
|
||||||
onDeleteAlias: PropTypes.func,
|
onDeleteAlias: PropTypes.func,
|
||||||
onAdd: PropTypes.func,
|
|
||||||
onRemove: PropTypes.func,
|
onRemove: PropTypes.func,
|
||||||
onTest: PropTypes.func,
|
onTest: PropTypes.func,
|
||||||
isTesting: PropTypes.bool,
|
isTesting: PropTypes.bool,
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export default function ProviderDetailPage() {
|
|||||||
const [suggestedModels, setSuggestedModels] = useState([]);
|
const [suggestedModels, setSuggestedModels] = useState([]);
|
||||||
const [kiloFreeModels, setKiloFreeModels] = useState([]);
|
const [kiloFreeModels, setKiloFreeModels] = useState([]);
|
||||||
const [disabledModelIds, setDisabledModelIds] = useState([]);
|
const [disabledModelIds, setDisabledModelIds] = 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);
|
||||||
const [oneByOneRunning, setOneByOneRunning] = useState(false);
|
const [oneByOneRunning, setOneByOneRunning] = useState(false);
|
||||||
@@ -186,34 +187,47 @@ export default function ProviderDetailPage() {
|
|||||||
|
|
||||||
const fetchDisabledModels = useCallback(async () => {
|
const fetchDisabledModels = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}`, { cache: "no-store" });
|
const [disabledRes, deletedRes] = await Promise.all([
|
||||||
const data = await res.json();
|
fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}`, { cache: "no-store" }),
|
||||||
if (res.ok) setDisabledModelIds(data.ids || []);
|
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 || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error fetching disabled models:", error);
|
console.log("Error fetching disabled models:", error);
|
||||||
}
|
}
|
||||||
}, [providerStorageAlias]);
|
}, [providerStorageAlias]);
|
||||||
|
|
||||||
const handleDisableModel = async (modelId) => {
|
const handlePermanentlyDeleteModel = (modelId, providerAliasOverride = providerStorageAlias) => {
|
||||||
try {
|
const displayAlias = providerAliasOverride === providerStorageAlias
|
||||||
const res = await fetch("/api/models/disabled", {
|
? providerDisplayAlias
|
||||||
method: "POST",
|
: providerAliasOverride;
|
||||||
headers: { "Content-Type": "application/json" },
|
setConfirmState({
|
||||||
body: JSON.stringify({ providerAlias: providerStorageAlias, ids: [modelId] }),
|
title: "Permanently Delete Model",
|
||||||
});
|
message: `Permanently delete ${displayAlias}/${modelId}? This removes it from model catalogs, combo memberships, saved tool configurations, and request details. Historical usage remains available. This cannot be undone.`,
|
||||||
if (res.ok) await fetchDisabledModels();
|
onConfirm: async () => {
|
||||||
} catch (error) {
|
setConfirmState(null);
|
||||||
console.log("Error disabling model:", error);
|
try {
|
||||||
}
|
const res = await fetch("/api/models/delete", {
|
||||||
};
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ providerAlias: providerAliasOverride, modelId }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
alert(data.error || "Failed to permanently delete model");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const handleEnableModel = async (modelId) => {
|
await Promise.all([fetchAliases(), fetchCustomModels(), fetchDisabledModels()]);
|
||||||
try {
|
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("customModelChanged"));
|
||||||
const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}&id=${encodeURIComponent(modelId)}`, { method: "DELETE" });
|
} catch (error) {
|
||||||
if (res.ok) await fetchDisabledModels();
|
console.log("Error permanently deleting model:", error);
|
||||||
} catch (error) {
|
alert("Failed to permanently delete model");
|
||||||
console.log("Error enabling model:", error);
|
}
|
||||||
}
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDisableAll = async (ids) => {
|
const handleDisableAll = async (ids) => {
|
||||||
@@ -1042,10 +1056,8 @@ export default function ProviderDetailPage() {
|
|||||||
customModels={customModels}
|
customModels={customModels}
|
||||||
copied={copied}
|
copied={copied}
|
||||||
onCopy={copy}
|
onCopy={copy}
|
||||||
onSetAlias={handleSetAlias}
|
|
||||||
onDeleteAlias={handleDeleteAlias}
|
|
||||||
onAddCustomModel={(modelId) => handleAddCustomModel(modelId, "llm", providerStorageAlias)}
|
onAddCustomModel={(modelId) => handleAddCustomModel(modelId, "llm", providerStorageAlias)}
|
||||||
onDeleteCustomModel={(modelId) => handleDeleteCustomModel(modelId, "llm", providerStorageAlias)}
|
onDeleteModel={(modelId) => handlePermanentlyDeleteModel(modelId, providerStorageAlias)}
|
||||||
connections={connections}
|
connections={connections}
|
||||||
isAnthropic={isAnthropicCompatible}
|
isAnthropic={isAnthropicCompatible}
|
||||||
/>
|
/>
|
||||||
@@ -1067,8 +1079,9 @@ export default function ProviderDetailPage() {
|
|||||||
))
|
))
|
||||||
.map((entry) => entry.id),
|
.map((entry) => entry.id),
|
||||||
);
|
);
|
||||||
const displayModels = allModels.filter((m) => !disabledSet.has(m.id));
|
const deletedSet = new Set(deletedModelIds);
|
||||||
const disabledDisplayModels = allModels.filter((m) => disabledSet.has(m.id));
|
const displayModels = allModels.filter((model) => !disabledSet.has(model.id) && !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,
|
||||||
@@ -1090,18 +1103,11 @@ export default function ProviderDetailPage() {
|
|||||||
copied={copied}
|
copied={copied}
|
||||||
onCopy={copy}
|
onCopy={copy}
|
||||||
onSetAlias={() => {}}
|
onSetAlias={() => {}}
|
||||||
onDeleteAlias={() => {
|
onDeleteAlias={() => handlePermanentlyDeleteModel(model.id, providerStorageAlias)}
|
||||||
if (model.source === "custom") {
|
|
||||||
handleDeleteCustomModel(model.id, "llm", providerStorageAlias);
|
|
||||||
} else {
|
|
||||||
handleDeleteAlias(model.alias);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
testStatus={modelTestResults[model.id]}
|
testStatus={modelTestResults[model.id]}
|
||||||
onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined}
|
onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined}
|
||||||
isTesting={testingModelIds.has(model.id)}
|
isTesting={testingModelIds.has(model.id)}
|
||||||
isCustom
|
isCustom
|
||||||
isAdded
|
|
||||||
isFree={false}
|
isFree={false}
|
||||||
caps={getCaps(`${providerId}/${model.id}`)}
|
caps={getCaps(`${providerId}/${model.id}`)}
|
||||||
thinkingSuffix={resolveThinkingSuffix(model.id)}
|
thinkingSuffix={resolveThinkingSuffix(model.id)}
|
||||||
@@ -1129,10 +1135,10 @@ export default function ProviderDetailPage() {
|
|||||||
isTesting={testingModelIds.has(model.id)}
|
isTesting={testingModelIds.has(model.id)}
|
||||||
isFree={model.isFree}
|
isFree={model.isFree}
|
||||||
isAdded={addedModelIds.has(model.id)}
|
isAdded={addedModelIds.has(model.id)}
|
||||||
onAdd={() => handleAddCustomModel(model.id, "llm", providerStorageAlias)}
|
|
||||||
onRemove={addedModelIds.has(model.id)
|
onRemove={addedModelIds.has(model.id)
|
||||||
? () => handleDeleteCustomModel(model.id, "llm", providerStorageAlias)
|
? () => handlePermanentlyDeleteModel(model.id, providerStorageAlias)
|
||||||
: undefined}
|
: undefined}
|
||||||
|
onDisable={() => handlePermanentlyDeleteModel(model.id, providerStorageAlias)}
|
||||||
caps={getCaps(`${providerId}/${model.id}`)}
|
caps={getCaps(`${providerId}/${model.id}`)}
|
||||||
thinkingSuffix={resolveThinkingSuffix(model.id)}
|
thinkingSuffix={resolveThinkingSuffix(model.id)}
|
||||||
/>
|
/>
|
||||||
@@ -1195,25 +1201,33 @@ export default function ProviderDetailPage() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Disabled models — restorable */}
|
{/* Disabled models stay restorable; permanently deleted models do not. */}
|
||||||
{disabledDisplayModels.length > 0 && (
|
{disabledDisplayModels.length > 0 && (
|
||||||
<div className="w-full mt-2">
|
<div className="w-full mt-2">
|
||||||
<p className="text-xs text-text-muted mb-2">Disabled models ({disabledDisplayModels.length}):</p>
|
<p className="text-xs text-text-muted mb-2">Disabled models ({disabledDisplayModels.length}):</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{disabledDisplayModels.map((m) => (
|
{disabledDisplayModels.map((model) => (
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
key={model.id}
|
||||||
onClick={() => handleEnableModel(m.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"
|
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"
|
title="Restore model"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[13px]">add</span>
|
<span className="material-symbols-outlined text-[13px]">add</span>
|
||||||
{m.id}
|
{model.id}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ function getQuotaTone(percentage) {
|
|||||||
return { bar: "bg-red-500", dot: "bg-red-500", text: "text-red-500" };
|
return { bar: "bg-red-500", dot: "bg-red-500", text: "text-red-500" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INACTIVE_SESSION_COUNTDOWN = "4h59m";
|
||||||
|
|
||||||
function TokenQuotaResetStatus({ quota }) {
|
function TokenQuotaResetStatus({ quota }) {
|
||||||
const [now, setNow] = useState(() => new Date());
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
|
||||||
@@ -49,7 +51,7 @@ function TokenQuotaResetStatus({ quota }) {
|
|||||||
const countdown = formatResetTime(quota.resetAt, now);
|
const countdown = formatResetTime(quota.resetAt, now);
|
||||||
const isSession = quota.windowType === USER_TOKEN_LIMIT_WINDOWS.SESSION;
|
const isSession = quota.windowType === USER_TOKEN_LIMIT_WINDOWS.SESSION;
|
||||||
const text = countdown === "-"
|
const text = countdown === "-"
|
||||||
? (isSession ? "No tokens pending expiry" : "Reset time unavailable")
|
? (isSession ? INACTIVE_SESSION_COUNTDOWN : "Reset time unavailable")
|
||||||
: (isSession ? `Next tokens restore in ${countdown}` : `Resets in ${countdown}`);
|
: (isSession ? `Next tokens restore in ${countdown}` : `Resets in ${countdown}`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getModelAliases, setModelAlias, deleteModelAlias } from "@/models";
|
import { getModelAliases, setModelAlias, deleteModelAlias } from "@/models";
|
||||||
|
import { isDeletedModelReference } from "@/lib/db";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -24,6 +25,10 @@ export async function PUT(request) {
|
|||||||
return NextResponse.json({ error: "Model and alias required" }, { status: 400 });
|
return NextResponse.json({ error: "Model and alias required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await isDeletedModelReference(model)) {
|
||||||
|
return NextResponse.json({ error: "This model was permanently deleted" }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
await setModelAlias(alias, model);
|
await setModelAlias(alias, model);
|
||||||
|
|
||||||
return NextResponse.json({ success: true, model, alias });
|
return NextResponse.json({ success: true, model, alias });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from "@/models";
|
} from "@/models";
|
||||||
import { getUsers } from "@/lib/db";
|
import { getUsers } from "@/lib/db";
|
||||||
import { disableModels, enableModels, getDisabledModels } from "@/lib/disabledModelsDb";
|
import { disableModels, enableModels, getDisabledModels } from "@/lib/disabledModelsDb";
|
||||||
|
import { getDeletedModels } from "@/lib/db";
|
||||||
import { requireAdminUser, requireUsageDashboardUser } from "@/lib/auth/currentUser";
|
import { requireAdminUser, requireUsageDashboardUser } from "@/lib/auth/currentUser";
|
||||||
import {
|
import {
|
||||||
AI_PROVIDERS,
|
AI_PROVIDERS,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
isAnthropicCompatibleProvider,
|
isAnthropicCompatibleProvider,
|
||||||
isOpenAICompatibleProvider,
|
isOpenAICompatibleProvider,
|
||||||
} from "@/shared/constants/providers";
|
} from "@/shared/constants/providers";
|
||||||
|
import { getModelsByProviderId } from "open-sse/config/providerModels.js";
|
||||||
import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
|
import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -71,6 +73,58 @@ 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) {
|
||||||
|
const deleted = new Set([
|
||||||
|
...(deletedModels[storageAlias] || []),
|
||||||
|
...(deletedModels[providerEntry.providerId] || []),
|
||||||
|
...(deletedModels[providerEntry.providerAlias] || []),
|
||||||
|
]);
|
||||||
|
return [...deleted].some((deletedModelId) => (
|
||||||
|
modelId === deletedModelId
|
||||||
|
|| (modelId.startsWith(`${deletedModelId}(`) && modelId.endsWith(")"))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConnectedModel({
|
||||||
|
disabledModels,
|
||||||
|
deletedModels,
|
||||||
|
fullModel,
|
||||||
|
isCustom,
|
||||||
|
modelAliases,
|
||||||
|
modelId,
|
||||||
|
name,
|
||||||
|
providerEntry,
|
||||||
|
storageAlias,
|
||||||
|
}) {
|
||||||
|
if (isDeletedModelId(deletedModels, storageAlias, providerEntry, modelId)) return null;
|
||||||
|
const caps = getCapabilitiesForModel(providerEntry.providerId, modelId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: providerEntry.provider,
|
||||||
|
providerAlias: storageAlias,
|
||||||
|
model: modelId,
|
||||||
|
name: name || modelId,
|
||||||
|
fullModel,
|
||||||
|
alias: modelAliases.get(fullModel) || modelId,
|
||||||
|
disabled: getModelDisabledState(disabledModels, storageAlias, providerEntry, modelId),
|
||||||
|
isCustom,
|
||||||
|
caps: {
|
||||||
|
vision: caps.vision,
|
||||||
|
search: caps.search,
|
||||||
|
reasoning: caps.reasoning,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getForbiddenResponse(error) {
|
function getForbiddenResponse(error) {
|
||||||
if (error.message === "Unauthorized") {
|
if (error.message === "Unauthorized") {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
@@ -81,23 +135,25 @@ function getForbiddenResponse(error) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/models/connected - List administrator-added LLMs from providers with
|
// GET /api/models/connected - List LLMs available through providers with a
|
||||||
// a usable active connection. Provider registries and live /models responses are
|
// usable active connection. Registry models are available immediately, while
|
||||||
// discovery sources only; a customModels record is the explicit availability source.
|
// customModels records extend the catalog and can provide administrator names.
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const user = await requireUsageDashboardUser();
|
const user = await requireUsageDashboardUser();
|
||||||
|
|
||||||
const [connections, customModels, disabledModels, modelAliases, providerNodes, users] = await Promise.all([
|
const [connections, customModels, disabledModels, deletedModels, modelAliases, providerNodes, users] = await Promise.all([
|
||||||
getProviderConnections(),
|
getProviderConnections(),
|
||||||
getCustomModels(),
|
getCustomModels(),
|
||||||
getDisabledModels(),
|
getDisabledModels(),
|
||||||
|
getDeletedModels(),
|
||||||
getModelAliases(),
|
getModelAliases(),
|
||||||
getProviderNodes(),
|
getProviderNodes(),
|
||||||
getUsers(),
|
getUsers(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const connectedProviderByAlias = new Map();
|
const connectedProviderByAlias = new Map();
|
||||||
|
const connectedProviderById = new Map();
|
||||||
for (const connection of connections) {
|
for (const connection of connections) {
|
||||||
if (!isViableConnection(connection)) continue;
|
if (!isViableConnection(connection)) continue;
|
||||||
|
|
||||||
@@ -108,13 +164,16 @@ export async function GET() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const providerEntry = {
|
||||||
|
providerId: connection.provider,
|
||||||
|
providerAlias: getProviderAlias(connection.provider) || connection.provider,
|
||||||
|
provider: getProviderLabel(connection.provider),
|
||||||
|
};
|
||||||
|
connectedProviderById.set(connection.provider, providerEntry);
|
||||||
|
|
||||||
for (const alias of getConnectionProviderAliases(connection)) {
|
for (const alias of getConnectionProviderAliases(connection)) {
|
||||||
if (!connectedProviderByAlias.has(alias)) {
|
if (!connectedProviderByAlias.has(alias)) {
|
||||||
connectedProviderByAlias.set(alias, {
|
connectedProviderByAlias.set(alias, providerEntry);
|
||||||
providerId: connection.provider,
|
|
||||||
providerAlias: getProviderAlias(connection.provider) || connection.provider,
|
|
||||||
provider: getProviderLabel(connection.provider),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,45 +215,64 @@ export async function GET() {
|
|||||||
|
|
||||||
const aliasByFullModel = getAliasByFullModel(modelAliases);
|
const aliasByFullModel = getAliasByFullModel(modelAliases);
|
||||||
const seenFullModels = new Set();
|
const seenFullModels = new Set();
|
||||||
const models = customModels
|
const models = [];
|
||||||
.filter((customModel) => customModel?.id && getModelType(customModel) === "llm")
|
const addModel = ({ isCustom, modelId, name, providerEntry, storageAlias }) => {
|
||||||
.map((customModel) => {
|
const fullModel = `${storageAlias}/${modelId}`;
|
||||||
const providerEntry = connectedProviderByAlias.get(customModel.providerAlias)
|
if (seenFullModels.has(fullModel)) return;
|
||||||
|| compatibleProviderByAlias.get(customModel.providerAlias);
|
seenFullModels.add(fullModel);
|
||||||
if (!providerEntry) return null;
|
const connectedModel = createConnectedModel({
|
||||||
|
disabledModels,
|
||||||
|
deletedModels,
|
||||||
|
fullModel,
|
||||||
|
isCustom,
|
||||||
|
modelAliases: aliasByFullModel,
|
||||||
|
modelId,
|
||||||
|
name,
|
||||||
|
providerEntry,
|
||||||
|
storageAlias,
|
||||||
|
});
|
||||||
|
if (connectedModel) models.push(connectedModel);
|
||||||
|
};
|
||||||
|
|
||||||
const modelId = String(customModel.id).trim();
|
// Custom registrations can supply an administrator-defined name. Add them
|
||||||
if (!modelId) return null;
|
// first so they take precedence when a model is also present in the registry.
|
||||||
|
for (const customModel of customModels) {
|
||||||
|
if (!customModel?.id || getModelType(customModel) !== "llm") continue;
|
||||||
|
|
||||||
const storageAlias = customModel.providerAlias;
|
const storageAlias = customModel.providerAlias;
|
||||||
const fullModel = `${storageAlias}/${modelId}`;
|
const providerEntry = connectedProviderByAlias.get(storageAlias)
|
||||||
if (seenFullModels.has(fullModel)) return null;
|
|| connectedProviderById.get(storageAlias)
|
||||||
seenFullModels.add(fullModel);
|
|| compatibleProviderByAlias.get(storageAlias);
|
||||||
|
const modelId = String(customModel.id).trim();
|
||||||
|
if (!providerEntry || !modelId) continue;
|
||||||
|
|
||||||
const disabled = new Set([
|
addModel({
|
||||||
...(disabledModels[storageAlias] || []),
|
isCustom: true,
|
||||||
...(disabledModels[providerEntry.providerId] || []),
|
modelId,
|
||||||
...(disabledModels[providerEntry.providerAlias] || []),
|
name: customModel.name,
|
||||||
]);
|
providerEntry,
|
||||||
const caps = getCapabilitiesForModel(providerEntry.providerId, modelId);
|
storageAlias,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
// Standard providers declare their model catalogs in the registry. Expose
|
||||||
provider: providerEntry.provider,
|
// those models after a usable connection exists; compatible providers stay
|
||||||
providerAlias: storageAlias,
|
// custom-model-only because their available models are runtime-defined.
|
||||||
model: modelId,
|
for (const providerEntry of connectedProviderById.values()) {
|
||||||
name: customModel.name || modelId,
|
const storageAlias = providerEntry.providerAlias;
|
||||||
fullModel,
|
for (const model of getModelsByProviderId(providerEntry.providerId)) {
|
||||||
alias: aliasByFullModel.get(fullModel) || modelId,
|
if (!model?.id || getModelType(model) !== "llm") continue;
|
||||||
disabled: disabled.has(modelId),
|
addModel({
|
||||||
isCustom: true,
|
isCustom: false,
|
||||||
caps: {
|
modelId: model.id,
|
||||||
vision: caps.vision,
|
name: model.name,
|
||||||
search: caps.search,
|
providerEntry,
|
||||||
reasoning: caps.reasoning,
|
storageAlias,
|
||||||
},
|
});
|
||||||
};
|
}
|
||||||
})
|
}
|
||||||
.filter(Boolean)
|
|
||||||
|
const visibleModels = models
|
||||||
.filter((model) => user.role === "admin" || !model.disabled)
|
.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)
|
||||||
@@ -202,7 +280,7 @@ export async function GET() {
|
|||||||
|| a.model.localeCompare(b.model)
|
|| a.model.localeCompare(b.model)
|
||||||
));
|
));
|
||||||
|
|
||||||
return NextResponse.json({ models });
|
return NextResponse.json({ models: visibleModels });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const accessError = getForbiddenResponse(error);
|
const accessError = getForbiddenResponse(error);
|
||||||
if (accessError) return accessError;
|
if (accessError) return accessError;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
addCustomModel,
|
addCustomModel,
|
||||||
deleteCustomModel,
|
deleteCustomModel,
|
||||||
} from "@/models";
|
} from "@/models";
|
||||||
|
import { isDeletedModel } from "@/lib/db";
|
||||||
import { requireAdminUser } from "@/lib/auth/currentUser";
|
import { requireAdminUser } from "@/lib/auth/currentUser";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -44,6 +45,9 @@ export async function POST(request) {
|
|||||||
return NextResponse.json({ error: "providerAlias and id required" }, { status: 400 });
|
return NextResponse.json({ error: "providerAlias and id required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
await requireCustomModelCatalogAdmin();
|
await requireCustomModelCatalogAdmin();
|
||||||
|
if (await isDeletedModel(providerAlias, id)) {
|
||||||
|
return NextResponse.json({ error: "This model was permanently deleted" }, { status: 409 });
|
||||||
|
}
|
||||||
const added = await addCustomModel({ providerAlias, id, type: type || "llm", name });
|
const added = await addCustomModel({ providerAlias, id, type: type || "llm", name });
|
||||||
return NextResponse.json({ success: true, added });
|
return NextResponse.json({ success: true, added });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
deleteModelPermanently,
|
||||||
|
getDeletedModels,
|
||||||
|
purgeRequestDetailBuffer,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { requireAdminUser } from "@/lib/auth/currentUser";
|
||||||
|
import { resetComboRotation } from "open-sse/services/combo.js";
|
||||||
|
|
||||||
|
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/delete?providerAlias=xxx
|
||||||
|
// Returns permanent-deletion tombstones for clients that render a provider catalog.
|
||||||
|
export async function GET(request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const providerAlias = searchParams.get("providerAlias");
|
||||||
|
const deleted = await getDeletedModels();
|
||||||
|
if (providerAlias) return NextResponse.json({ ids: deleted[providerAlias] || [] });
|
||||||
|
return NextResponse.json({ deleted });
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error fetching permanently deleted models:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch deleted models" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/models/delete body: { providerAlias, modelId }
|
||||||
|
// Permanently removes a model from selectable catalogs and saved routing configuration.
|
||||||
|
// Usage history is intentionally retained for accurate token reporting.
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
await requireAdminUser();
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const providerAlias = typeof body.providerAlias === "string" ? body.providerAlias.trim() : "";
|
||||||
|
const modelId = typeof body.modelId === "string" ? body.modelId.trim() : "";
|
||||||
|
if (!providerAlias || !modelId) {
|
||||||
|
return NextResponse.json({ error: "providerAlias and modelId are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await deleteModelPermanently(providerAlias, modelId);
|
||||||
|
for (const comboId of [...result.updatedComboIds, ...result.deletedComboIds]) {
|
||||||
|
resetComboRotation(comboId);
|
||||||
|
}
|
||||||
|
purgeRequestDetailBuffer(result.providerAliases, result.modelId);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, result });
|
||||||
|
} catch (error) {
|
||||||
|
const accessError = getAccessErrorResponse(error);
|
||||||
|
if (accessError) return accessError;
|
||||||
|
|
||||||
|
console.log("Error permanently deleting model:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to permanently delete model" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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 { getDisabledModels } from "@/lib/disabledModelsDb";
|
||||||
|
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";
|
||||||
import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
|
import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
|
||||||
@@ -9,13 +10,16 @@ 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 = await getDisabledModels();
|
const [disabled, deleted] = await Promise.all([getDisabledModels(), 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 list = disabled[alias] || disabled[m.provider] || [];
|
||||||
return !list.includes(m.model);
|
const deletedIds = [...(deleted[alias] || []), ...(deleted[m.provider] || [])];
|
||||||
|
return !list.includes(m.model) && !deletedIds.some((id) => (
|
||||||
|
m.model === id || (m.model.startsWith(`${id}(`) && m.model.endsWith(")"))
|
||||||
|
));
|
||||||
})
|
})
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
const fullModel = `${m.provider}/${m.model}`;
|
const fullModel = `${m.provider}/${m.model}`;
|
||||||
@@ -45,6 +49,10 @@ export async function PUT(request) {
|
|||||||
return NextResponse.json({ error: "Model and alias required" }, { status: 400 });
|
return NextResponse.json({ error: "Model and alias required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await isDeletedModelReference(model)) {
|
||||||
|
return NextResponse.json({ error: "This model was permanently deleted" }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
const modelAliases = await getModelAliases();
|
const modelAliases = await getModelAliases();
|
||||||
|
|
||||||
// Check if alias already exists for different model
|
// Check if alias already exists for different model
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getPricing, updatePricing, resetPricing, resetAllPricing } from "@/lib/localDb.js";
|
import { getPricing, updatePricing, resetPricing, resetAllPricing } from "@/lib/localDb.js";
|
||||||
|
import { isDeletedModel } from "@/lib/db";
|
||||||
import { getDefaultPricing } from "open-sse/providers/pricing.js";
|
import { getDefaultPricing } from "open-sse/providers/pricing.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,6 +47,12 @@ export async function PATCH(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const [model, pricing] of Object.entries(models)) {
|
for (const [model, pricing] of Object.entries(models)) {
|
||||||
|
if (await isDeletedModel(provider, model)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Model was permanently deleted: ${provider}/${model}` },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
if (typeof pricing !== "object" || pricing === null) {
|
if (typeof pricing !== "object" || pricing === null) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Invalid pricing for model: ${provider}/${model}` },
|
{ error: `Invalid pricing for model: ${provider}/${model}` },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { PROVIDER_MODELS } from "open-sse/config/providerModels.js";
|
import { PROVIDER_MODELS } from "open-sse/config/providerModels.js";
|
||||||
import { AI_PROVIDERS, ALIAS_TO_ID } from "@/shared/constants/providers";
|
import { AI_PROVIDERS, ALIAS_TO_ID } from "@/shared/constants/providers";
|
||||||
import { getModelKind } from "@/shared/constants/models";
|
import { getModelKind } from "@/shared/constants/models";
|
||||||
|
import { isDeletedModelReference } from "@/lib/db";
|
||||||
|
|
||||||
const KIND_ENDPOINT = {
|
const KIND_ENDPOINT = {
|
||||||
llm: "/v1/chat/completions",
|
llm: "/v1/chat/completions",
|
||||||
@@ -93,6 +94,12 @@ export async function GET(request) {
|
|||||||
{ status: 400, headers: { "Access-Control-Allow-Origin": "*" } },
|
{ status: 400, headers: { "Access-Control-Allow-Origin": "*" } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (await isDeletedModelReference(id)) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: { message: `Model not found: ${id}`, type: "not_found" } },
|
||||||
|
{ status: 404, headers: { "Access-Control-Allow-Origin": "*" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
const info = lookup(id, kind);
|
const info = lookup(id, kind);
|
||||||
if (!info) {
|
if (!info) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} 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 { getDisabledModels } from "@/lib/disabledModelsDb";
|
||||||
|
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";
|
||||||
import { resolveQoderModels } from "open-sse/services/qoderModels.js";
|
import { resolveQoderModels } from "open-sse/services/qoderModels.js";
|
||||||
@@ -263,6 +264,20 @@ export async function buildModelsList(kindFilter, ownerOrOptions = {}) {
|
|||||||
}
|
}
|
||||||
const isDisabled = (alias, modelId) => Array.isArray(disabledByAlias[alias]) && disabledByAlias[alias].includes(modelId);
|
const isDisabled = (alias, modelId) => Array.isArray(disabledByAlias[alias]) && disabledByAlias[alias].includes(modelId);
|
||||||
|
|
||||||
|
let deletedByAlias = {};
|
||||||
|
try {
|
||||||
|
deletedByAlias = await getDeletedModels();
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Could not fetch permanently deleted models");
|
||||||
|
}
|
||||||
|
const isDeleted = (modelId, ...providerAliases) => providerAliases.some((providerAlias) => (
|
||||||
|
Array.isArray(deletedByAlias[providerAlias])
|
||||||
|
&& deletedByAlias[providerAlias].some((deletedModelId) => (
|
||||||
|
modelId === deletedModelId
|
||||||
|
|| (modelId.startsWith(`${deletedModelId}(`) && modelId.endsWith(")"))
|
||||||
|
))
|
||||||
|
));
|
||||||
|
|
||||||
const activeConnectionByProvider = new Map();
|
const activeConnectionByProvider = new Map();
|
||||||
for (const conn of connections) {
|
for (const conn of connections) {
|
||||||
if (!activeConnectionByProvider.has(conn.provider)) {
|
if (!activeConnectionByProvider.has(conn.provider)) {
|
||||||
@@ -296,7 +311,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)) continue;
|
if (isDisabled(alias, model.id) || isDeleted(model.id, alias, providerId)) continue;
|
||||||
models.push({
|
models.push({
|
||||||
id: `${alias}/${model.id}`,
|
id: `${alias}/${model.id}`,
|
||||||
object: "model",
|
object: "model",
|
||||||
@@ -314,6 +329,7 @@ export async function buildModelsList(kindFilter, ownerOrOptions = {}) {
|
|||||||
|
|
||||||
const modelId = String(customModel.id).trim();
|
const modelId = String(customModel.id).trim();
|
||||||
if (!modelId) continue;
|
if (!modelId) continue;
|
||||||
|
if (isDeleted(modelId, providerAlias)) continue;
|
||||||
|
|
||||||
models.push({
|
models.push({
|
||||||
id: `${providerAlias}/${modelId}`,
|
id: `${providerAlias}/${modelId}`,
|
||||||
@@ -450,7 +466,11 @@ export async function buildModelsList(kindFilter, ownerOrOptions = {}) {
|
|||||||
// imageToText custom models stay in the LLM list (vision-capable chat models)
|
// imageToText custom models stay in the LLM list (vision-capable chat models)
|
||||||
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 (isDisabled(outputAlias, modelId) || isDisabled(staticAlias, modelId)) continue;
|
if (
|
||||||
|
isDisabled(outputAlias, modelId)
|
||||||
|
|| isDisabled(staticAlias, modelId)
|
||||||
|
|| isDeleted(modelId, outputAlias, staticAlias, providerId)
|
||||||
|
) continue;
|
||||||
|
|
||||||
const model = {
|
const model = {
|
||||||
id: `${outputAlias}/${modelId}`,
|
id: `${outputAlias}/${modelId}`,
|
||||||
|
|||||||
+17
-1
@@ -83,6 +83,11 @@ export {
|
|||||||
getDisabledModels, getDisabledByProvider, disableModels, enableModels,
|
getDisabledModels, getDisabledByProvider, disableModels, enableModels,
|
||||||
} from "./repos/disabledModelsRepo.js";
|
} from "./repos/disabledModelsRepo.js";
|
||||||
|
|
||||||
|
// Permanently deleted models
|
||||||
|
export {
|
||||||
|
getDeletedModels, isDeletedModel, isDeletedModelReference, deleteModelPermanently,
|
||||||
|
} from "./repos/deletedModelsRepo.js";
|
||||||
|
|
||||||
// Usage
|
// Usage
|
||||||
export {
|
export {
|
||||||
statsEmitter, trackPendingRequest, getActiveRequests,
|
statsEmitter, trackPendingRequest, getActiveRequests,
|
||||||
@@ -93,6 +98,7 @@ export {
|
|||||||
// Request details
|
// Request details
|
||||||
export {
|
export {
|
||||||
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
|
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
|
||||||
|
purgeRequestDetailBuffer,
|
||||||
} from "./repos/requestDetailsRepo.js";
|
} from "./repos/requestDetailsRepo.js";
|
||||||
|
|
||||||
// Export/import full DB
|
// Export/import full DB
|
||||||
@@ -115,6 +121,7 @@ export async function exportDb() {
|
|||||||
mitmAlias: {},
|
mitmAlias: {},
|
||||||
pricing: {},
|
pricing: {},
|
||||||
disabledModels: {},
|
disabledModels: {},
|
||||||
|
deletedModels: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'modelAliases'`)) out.modelAliases[r.key] = parseJson(r.value);
|
for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'modelAliases'`)) out.modelAliases[r.key] = parseJson(r.value);
|
||||||
@@ -122,6 +129,7 @@ export async function exportDb() {
|
|||||||
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 = '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, []);
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -154,7 +162,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')`);
|
db.run(`DELETE FROM kv WHERE scope IN ('modelAliases', 'customModels', 'mitmAlias', 'pricing', 'disabledModels', 'deletedModels')`);
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
if (payload.settings) {
|
if (payload.settings) {
|
||||||
@@ -261,6 +269,14 @@ export async function importDb(payload) {
|
|||||||
db.run(`INSERT OR REPLACE INTO kv(scope, key, value) VALUES('disabledModels', ?, ?)`, [providerAlias, stringifyJson([...new Set(validModelIds)])]);
|
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 || {})) {
|
||||||
|
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('deletedModels', ?, ?)`, [providerAlias, stringifyJson([...new Set(validModelIds)])]);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return await exportDb();
|
return await exportDb();
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import { getProviderAlias, getProviderByAlias } from "@/shared/constants/providers";
|
||||||
|
import { getAdapter } from "../driver.js";
|
||||||
|
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
|
||||||
|
import { invalidatePricingCache } from "./pricingRepo.js";
|
||||||
|
|
||||||
|
const SCOPE = "deletedModels";
|
||||||
|
|
||||||
|
function normalizeIds(value) {
|
||||||
|
return Array.isArray(value) ? value.filter((id) => typeof id === "string" && id) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRowsChanged(result) {
|
||||||
|
return Number(result?.changes || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProviderAliasesSync(db, providerAlias) {
|
||||||
|
const aliases = new Set([providerAlias]);
|
||||||
|
const provider = getProviderByAlias(providerAlias);
|
||||||
|
if (provider?.id) aliases.add(provider.id);
|
||||||
|
if (provider?.alias) aliases.add(provider.alias);
|
||||||
|
|
||||||
|
const nodeRows = db.all(`SELECT id, data FROM providerNodes`);
|
||||||
|
for (const node of nodeRows) {
|
||||||
|
const nodeData = parseJson(node.data, {}) || {};
|
||||||
|
if (node.id === providerAlias || nodeData.prefix === providerAlias || aliases.has(node.id)) {
|
||||||
|
aliases.add(node.id);
|
||||||
|
if (nodeData.prefix) aliases.add(nodeData.prefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const alias of [...aliases]) {
|
||||||
|
aliases.add(getProviderAlias(alias) || alias);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...aliases].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelWhereClause(column, modelId) {
|
||||||
|
return {
|
||||||
|
sql: `(${column} = ? OR (substr(${column}, 1, length(?)) = ? AND substr(${column}, -1) = ?))`,
|
||||||
|
params: [modelId, `${modelId}(`, `${modelId}(`, ")"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeModelFromKvScopesSync(db, providerAliases, modelId) {
|
||||||
|
const aliases = new Set(providerAliases);
|
||||||
|
const result = { aliases: 0, customModels: 0, pricing: 0, disabledModels: 0 };
|
||||||
|
|
||||||
|
const modelAliasRows = db.all(`SELECT key, value FROM kv WHERE scope = 'modelAliases'`);
|
||||||
|
for (const row of modelAliasRows) {
|
||||||
|
const fullModel = parseJson(row.value, null);
|
||||||
|
if (!matchesDeletedModelReference(fullModel, providerAliases, modelId)) continue;
|
||||||
|
result.aliases += getRowsChanged(db.run(`DELETE FROM kv WHERE scope = 'modelAliases' AND key = ?`, [row.key]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const customRows = db.all(`SELECT key, value FROM kv WHERE scope = 'customModels'`);
|
||||||
|
for (const row of customRows) {
|
||||||
|
const customModel = parseJson(row.value, {}) || {};
|
||||||
|
if (!aliases.has(customModel.providerAlias) || !matchesDeletedModelId(customModel.id, modelId)) continue;
|
||||||
|
result.customModels += getRowsChanged(db.run(`DELETE FROM kv WHERE scope = 'customModels' AND key = ?`, [row.key]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const pricingRows = db.all(`SELECT key, value FROM kv WHERE scope = 'pricing'`);
|
||||||
|
for (const row of pricingRows) {
|
||||||
|
if (!aliases.has(row.key)) continue;
|
||||||
|
const current = parseJson(row.value, {}) || {};
|
||||||
|
const next = Object.fromEntries(
|
||||||
|
Object.entries(current).filter(([storedModelId]) => !matchesDeletedModelId(storedModelId, modelId)),
|
||||||
|
);
|
||||||
|
if (Object.keys(next).length === Object.keys(current).length) continue;
|
||||||
|
result.pricing += Object.keys(current).length - Object.keys(next).length;
|
||||||
|
if (Object.keys(next).length === 0) {
|
||||||
|
db.run(`DELETE FROM kv WHERE scope = 'pricing' AND key = ?`, [row.key]);
|
||||||
|
} else {
|
||||||
|
db.run(`UPDATE kv SET value = ? WHERE scope = 'pricing' AND key = ?`, [stringifyJson(next), row.key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanCombosSync(db, providerAliases, modelId) {
|
||||||
|
const aliasRows = db.all(`SELECT key, value FROM kv WHERE scope = 'modelAliases'`);
|
||||||
|
const aliases = Object.fromEntries(aliasRows.map((row) => [row.key, parseJson(row.value, null)]));
|
||||||
|
const updatedComboIds = [];
|
||||||
|
const deletedComboIds = [];
|
||||||
|
const comboRows = db.all(`SELECT id, models FROM combos`);
|
||||||
|
|
||||||
|
for (const row of comboRows) {
|
||||||
|
const parsedModels = parseJson(row.models, []);
|
||||||
|
const models = Array.isArray(parsedModels) ? parsedModels : [];
|
||||||
|
const keptModels = models.filter((reference) => {
|
||||||
|
const resolvedReference = typeof reference === "string" ? aliases[reference] : null;
|
||||||
|
return !matchesDeletedModelReference(reference, providerAliases, modelId)
|
||||||
|
&& !matchesDeletedModelReference(resolvedReference, providerAliases, modelId);
|
||||||
|
});
|
||||||
|
if (keptModels.length === models.length) continue;
|
||||||
|
|
||||||
|
if (keptModels.length === 0) {
|
||||||
|
db.run(`DELETE FROM combos WHERE id = ?`, [row.id]);
|
||||||
|
deletedComboIds.push(row.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`UPDATE combos SET models = ?, updatedAt = ? WHERE id = ?`,
|
||||||
|
[stringifyJson(keptModels), new Date().toISOString(), row.id],
|
||||||
|
);
|
||||||
|
updatedComboIds.push(row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsRow = db.get(`SELECT data FROM settings WHERE id = 1`);
|
||||||
|
const settings = settingsRow ? (parseJson(settingsRow.data, {}) || {}) : {};
|
||||||
|
const comboStrategies = { ...(settings.comboStrategies || {}) };
|
||||||
|
let settingsChanged = false;
|
||||||
|
|
||||||
|
for (const comboId of deletedComboIds) {
|
||||||
|
if (comboStrategies[comboId] === undefined) continue;
|
||||||
|
delete comboStrategies[comboId];
|
||||||
|
settingsChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [comboId, strategy] of Object.entries(comboStrategies)) {
|
||||||
|
const resolvedJudgeModel = typeof strategy?.judgeModel === "string"
|
||||||
|
? aliases[strategy.judgeModel]
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
!strategy
|
||||||
|
|| (
|
||||||
|
!matchesDeletedModelReference(strategy.judgeModel, providerAliases, modelId)
|
||||||
|
&& !matchesDeletedModelReference(resolvedJudgeModel, providerAliases, modelId)
|
||||||
|
)
|
||||||
|
) continue;
|
||||||
|
const { judgeModel, ...nextStrategy } = strategy;
|
||||||
|
if (Object.keys(nextStrategy).length === 0) delete comboStrategies[comboId];
|
||||||
|
else comboStrategies[comboId] = nextStrategy;
|
||||||
|
if (!updatedComboIds.includes(comboId)) updatedComboIds.push(comboId);
|
||||||
|
settingsChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingsChanged) {
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO settings(id, data) VALUES(1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
|
||||||
|
[stringifyJson({ ...settings, comboStrategies })],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { updatedComboIds, deletedComboIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDeletedReference(reference, modelAliases, providerAliases, modelId) {
|
||||||
|
const resolvedReference = typeof reference === "string" ? modelAliases[reference] : null;
|
||||||
|
return matchesDeletedModelReference(reference, providerAliases, modelId)
|
||||||
|
|| matchesDeletedModelReference(resolvedReference, providerAliases, modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanCliToolConfigsSync(db, providerAliases, modelId) {
|
||||||
|
const aliasRows = db.all(`SELECT key, value FROM kv WHERE scope = 'modelAliases'`);
|
||||||
|
const modelAliases = Object.fromEntries(aliasRows.map((row) => [row.key, parseJson(row.value, null)]));
|
||||||
|
const configRows = db.all(`SELECT ownerId, toolId, data FROM cliToolConfigs`);
|
||||||
|
let updatedConfigs = 0;
|
||||||
|
|
||||||
|
for (const row of configRows) {
|
||||||
|
const config = parseJson(row.data, {}) || {};
|
||||||
|
let changed = false;
|
||||||
|
const deleted = (reference) => isDeletedReference(reference, modelAliases, providerAliases, modelId);
|
||||||
|
|
||||||
|
if (config.claudeModels && typeof config.claudeModels === "object") {
|
||||||
|
const claudeModels = { ...config.claudeModels };
|
||||||
|
const claudeThinking = { ...(config.claudeThinking || {}) };
|
||||||
|
for (const [slot, reference] of Object.entries(claudeModels)) {
|
||||||
|
if (!deleted(reference)) continue;
|
||||||
|
claudeModels[slot] = "";
|
||||||
|
delete claudeThinking[slot];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
config.claudeModels = claudeModels;
|
||||||
|
config.claudeThinking = claudeThinking;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleted(config.codexModel)) {
|
||||||
|
config.codexModel = "";
|
||||||
|
config.codexThinking = "";
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of ["opencodeModels", "selectedModels"]) {
|
||||||
|
if (!Array.isArray(config[field])) continue;
|
||||||
|
const removedModels = config[field].filter(deleted);
|
||||||
|
if (removedModels.length === 0) continue;
|
||||||
|
config[field] = config[field].filter((reference) => !deleted(reference));
|
||||||
|
for (const mapField of ["coworkThinking", "copilotThinking", "copilotTokens"]) {
|
||||||
|
if (!config[mapField] || typeof config[mapField] !== "object") continue;
|
||||||
|
const nextMap = { ...config[mapField] };
|
||||||
|
for (const reference of removedModels) delete nextMap[reference];
|
||||||
|
config[mapField] = nextMap;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleted(config.opencodeDefaultModel)) {
|
||||||
|
config.opencodeDefaultModel = config.opencodeModels?.[0] || "";
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) continue;
|
||||||
|
db.run(
|
||||||
|
`UPDATE cliToolConfigs SET data = ?, updatedAt = ? WHERE ownerId = ? AND toolId = ?`,
|
||||||
|
[stringifyJson(config), new Date().toISOString(), row.ownerId, row.toolId],
|
||||||
|
);
|
||||||
|
updatedConfigs += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedConfigs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesDeletedModelId(candidate, modelId) {
|
||||||
|
if (typeof candidate !== "string" || typeof modelId !== "string") return false;
|
||||||
|
if (candidate === modelId) return true;
|
||||||
|
return candidate.startsWith(`${modelId}(`) && candidate.endsWith(")");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesDeletedModelReference(reference, providerAliases, modelId) {
|
||||||
|
if (typeof reference !== "string" || !Array.isArray(providerAliases)) return false;
|
||||||
|
|
||||||
|
return providerAliases.some((providerAlias) => {
|
||||||
|
if (typeof providerAlias !== "string" || !providerAlias) return false;
|
||||||
|
const prefix = `${providerAlias}/`;
|
||||||
|
return reference.startsWith(prefix)
|
||||||
|
&& matchesDeletedModelId(reference.slice(prefix.length), modelId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesDeletedModelUsage(provider, model, providerAliases, modelId) {
|
||||||
|
return Array.isArray(providerAliases)
|
||||||
|
&& providerAliases.includes(provider)
|
||||||
|
&& matchesDeletedModelId(model, modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDeletedModelSync(db, provider, model) {
|
||||||
|
if (!db || !provider || !model) return false;
|
||||||
|
|
||||||
|
const providerAliases = getProviderAliasesSync(db, provider);
|
||||||
|
|
||||||
|
for (const alias of providerAliases) {
|
||||||
|
const row = db.get(`SELECT value FROM kv WHERE scope = ? AND key = ?`, [SCOPE, alias]);
|
||||||
|
const modelIds = normalizeIds(row ? parseJson(row.value, []) : []);
|
||||||
|
if (modelIds.some((modelId) => matchesDeletedModelId(model, modelId))) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markDeletedModelSync(db, providerAlias, modelId) {
|
||||||
|
if (!db || !providerAlias || !modelId) return false;
|
||||||
|
|
||||||
|
const row = db.get(`SELECT value FROM kv WHERE scope = ? AND key = ?`, [SCOPE, providerAlias]);
|
||||||
|
const current = normalizeIds(row ? parseJson(row.value, []) : []);
|
||||||
|
if (current.includes(modelId)) return false;
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO kv(scope, key, value) VALUES(?, ?, ?) ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value`,
|
||||||
|
[SCOPE, providerAlias, stringifyJson([...current, modelId])],
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeletedModels() {
|
||||||
|
const db = await getAdapter();
|
||||||
|
const rows = db.all(`SELECT key, value FROM kv WHERE scope = ?`, [SCOPE]);
|
||||||
|
const deleted = {};
|
||||||
|
for (const row of rows) deleted[row.key] = normalizeIds(parseJson(row.value, []));
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isDeletedModel(provider, model) {
|
||||||
|
const db = await getAdapter();
|
||||||
|
return isDeletedModelSync(db, provider, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isDeletedModelReference(reference) {
|
||||||
|
if (typeof reference !== "string") return false;
|
||||||
|
const separatorIndex = reference.indexOf("/");
|
||||||
|
if (separatorIndex <= 0 || separatorIndex === reference.length - 1) return false;
|
||||||
|
|
||||||
|
const providerAlias = reference.slice(0, separatorIndex);
|
||||||
|
const modelId = reference.slice(separatorIndex + 1);
|
||||||
|
const db = await getAdapter();
|
||||||
|
const providerAliases = getProviderAliasesSync(db, providerAlias);
|
||||||
|
|
||||||
|
return providerAliases.some((alias) => {
|
||||||
|
const row = db.get(`SELECT value FROM kv WHERE scope = ? AND key = ?`, [SCOPE, alias]);
|
||||||
|
const deletedModels = normalizeIds(row ? parseJson(row.value, []) : []);
|
||||||
|
return deletedModels.some((deletedModelId) => matchesDeletedModelId(modelId, deletedModelId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteModelPermanently(providerAlias, modelId) {
|
||||||
|
if (!providerAlias || !modelId) return null;
|
||||||
|
|
||||||
|
const db = await getAdapter();
|
||||||
|
let result = null;
|
||||||
|
db.transaction(() => {
|
||||||
|
const providerAliases = getProviderAliasesSync(db, providerAlias);
|
||||||
|
const storageAlias = getProviderAlias(providerAlias) || providerAlias;
|
||||||
|
const deleted = markDeletedModelSync(db, storageAlias, modelId);
|
||||||
|
const combos = cleanCombosSync(db, providerAliases, modelId);
|
||||||
|
const updatedCliToolConfigs = cleanCliToolConfigsSync(db, providerAliases, modelId);
|
||||||
|
const kv = removeModelFromKvScopesSync(db, providerAliases, modelId);
|
||||||
|
const where = modelWhereClause("model", modelId);
|
||||||
|
const providerPlaceholders = providerAliases.map(() => "?").join(", ");
|
||||||
|
const requestDetails = getRowsChanged(db.run(
|
||||||
|
`DELETE FROM requestDetails WHERE provider IN (${providerPlaceholders}) AND ${where.sql}`,
|
||||||
|
[...providerAliases, ...where.params],
|
||||||
|
));
|
||||||
|
|
||||||
|
result = {
|
||||||
|
deleted,
|
||||||
|
providerAliases,
|
||||||
|
modelId,
|
||||||
|
removedAliases: kv.aliases,
|
||||||
|
removedCustomModels: kv.customModels,
|
||||||
|
removedPricingEntries: kv.pricing,
|
||||||
|
removedDisabledModels: kv.disabledModels,
|
||||||
|
updatedComboIds: combos.updatedComboIds,
|
||||||
|
deletedComboIds: combos.deletedComboIds,
|
||||||
|
updatedCliToolConfigs,
|
||||||
|
removedRequestDetails: requestDetails,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
invalidatePricingCache();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -11,10 +11,28 @@ function invalidate() {
|
|||||||
cache = { value: null, expiresAt: 0 };
|
cache = { value: null, expiresAt: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function invalidatePricingCache() {
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
async function getUserPricing() {
|
async function getUserPricing() {
|
||||||
return await pricingKv.getAll();
|
return await pricingKv.getAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeDeletedModelsFromPricing(pricing) {
|
||||||
|
const { isDeletedModel } = await import("./deletedModelsRepo.js");
|
||||||
|
const filtered = {};
|
||||||
|
|
||||||
|
for (const [provider, models] of Object.entries(pricing)) {
|
||||||
|
const entries = await Promise.all(Object.entries(models).map(async ([modelId, modelPricing]) => (
|
||||||
|
(await isDeletedModel(provider, modelId)) ? null : [modelId, modelPricing]
|
||||||
|
)));
|
||||||
|
filtered[provider] = Object.fromEntries(entries.filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getPricing() {
|
export async function getPricing() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (cache.value && cache.expiresAt > now) return cache.value;
|
if (cache.value && cache.expiresAt > now) return cache.value;
|
||||||
@@ -44,12 +62,15 @@ export async function getPricing() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cache = { value: merged, expiresAt: now + CACHE_TTL_MS };
|
const filtered = await removeDeletedModelsFromPricing(merged);
|
||||||
return merged;
|
cache = { value: filtered, expiresAt: now + CACHE_TTL_MS };
|
||||||
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPricingForModel(provider, model) {
|
export async function getPricingForModel(provider, model) {
|
||||||
if (!model) return null;
|
if (!model) return null;
|
||||||
|
const { isDeletedModel } = await import("./deletedModelsRepo.js");
|
||||||
|
if (await isDeletedModel(provider, model)) return null;
|
||||||
const userPricing = await getUserPricing();
|
const userPricing = await getUserPricing();
|
||||||
if (provider && userPricing[provider]?.[model]) return userPricing[provider][model];
|
if (provider && userPricing[provider]?.[model]) return userPricing[provider][model];
|
||||||
const { getPricingForModel: resolveConst } = await import("open-sse/providers/pricing.js");
|
const { getPricingForModel: resolveConst } = await import("open-sse/providers/pricing.js");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getAdapter } from "../driver.js";
|
import { getAdapter } from "../driver.js";
|
||||||
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
|
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
|
||||||
import { appendUsageAccessClause, getUsageAccessScope } from "./usageAccessScope.js";
|
import { appendUsageAccessClause, getUsageAccessScope } from "./usageAccessScope.js";
|
||||||
|
import { isDeletedModelSync, matchesDeletedModelUsage } from "./deletedModelsRepo.js";
|
||||||
|
|
||||||
const DEFAULT_MAX_RECORDS = 200;
|
const DEFAULT_MAX_RECORDS = 200;
|
||||||
const DEFAULT_BATCH_SIZE = 20;
|
const DEFAULT_BATCH_SIZE = 20;
|
||||||
@@ -37,6 +38,13 @@ let writeBuffer = [];
|
|||||||
let flushTimer = null;
|
let flushTimer = null;
|
||||||
let isFlushing = false;
|
let isFlushing = false;
|
||||||
|
|
||||||
|
export function purgeRequestDetailBuffer(providerAliases, modelId) {
|
||||||
|
if (!Array.isArray(providerAliases) || !modelId) return;
|
||||||
|
writeBuffer = writeBuffer.filter(
|
||||||
|
(detail) => !matchesDeletedModelUsage(detail.provider, detail.model, providerAliases, modelId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeHeaders(headers) {
|
function sanitizeHeaders(headers) {
|
||||||
if (!headers || typeof headers !== "object") return {};
|
if (!headers || typeof headers !== "object") return {};
|
||||||
const sensitiveKeys = ["authorization", "x-api-key", "cookie", "token", "api-key"];
|
const sensitiveKeys = ["authorization", "x-api-key", "cookie", "token", "api-key"];
|
||||||
@@ -75,6 +83,7 @@ async function flushToDatabase() {
|
|||||||
|
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
if (isDeletedModelSync(db, item.provider, item.model)) continue;
|
||||||
if (!item.id) item.id = generateDetailId(item.model);
|
if (!item.id) item.id = generateDetailId(item.model);
|
||||||
if (!item.timestamp) item.timestamp = new Date().toISOString();
|
if (!item.timestamp) item.timestamp = new Date().toISOString();
|
||||||
if (item.request?.headers) item.request.headers = sanitizeHeaders(item.request.headers);
|
if (item.request?.headers) item.request.headers = sanitizeHeaders(item.request.headers);
|
||||||
|
|||||||
+1
-1
@@ -3,5 +3,5 @@ export {
|
|||||||
statsEmitter, trackPendingRequest, getActiveRequests,
|
statsEmitter, trackPendingRequest, getActiveRequests,
|
||||||
saveRequestUsage, getUsageHistory, getUsageStats, getChartData,
|
saveRequestUsage, getUsageHistory, getUsageStats, getChartData,
|
||||||
appendRequestLog, getRecentLogs,
|
appendRequestLog, getRecentLogs,
|
||||||
saveRequestDetail, getRequestDetails, getRequestDetailById,
|
saveRequestDetail, getRequestDetails, getRequestDetailById, purgeRequestDetailBuffer,
|
||||||
} from "@/lib/db/index.js";
|
} from "@/lib/db/index.js";
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export default function ModelSelectModal({
|
|||||||
const [providerNodes, setProviderNodes] = useState([]);
|
const [providerNodes, setProviderNodes] = useState([]);
|
||||||
const [customModels, setCustomModels] = useState([]);
|
const [customModels, setCustomModels] = useState([]);
|
||||||
const [disabledModels, setDisabledModels] = useState({});
|
const [disabledModels, setDisabledModels] = useState({});
|
||||||
|
const [deletedModels, setDeletedModels] = useState({});
|
||||||
|
|
||||||
const fetchCombos = async () => {
|
const fetchCombos = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -103,13 +104,21 @@ export default function ModelSelectModal({
|
|||||||
|
|
||||||
const fetchDisabledModels = async () => {
|
const fetchDisabledModels = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/models/disabled");
|
const [disabledRes, deletedRes] = await Promise.all([
|
||||||
if (!res.ok) throw new Error(`Failed to fetch disabled models: ${res.status}`);
|
fetch("/api/models/disabled"),
|
||||||
const data = await res.json();
|
fetch("/api/models/delete"),
|
||||||
setDisabledModels(data.disabled || {});
|
]);
|
||||||
|
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 || {});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching disabled models:", error);
|
console.error("Error fetching disabled models:", error);
|
||||||
setDisabledModels({});
|
setDisabledModels({});
|
||||||
|
setDeletedModels({});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -377,13 +386,22 @@ export default function ModelSelectModal({
|
|||||||
...(disabledModels[aliasKey] || []),
|
...(disabledModels[aliasKey] || []),
|
||||||
...(disabledModels[providerId] || []),
|
...(disabledModels[providerId] || []),
|
||||||
]);
|
]);
|
||||||
if (disabledIds.size === 0) return;
|
const deletedIds = new Set([
|
||||||
group.models = group.models.filter((m) => !disabledIds.has(m.id));
|
...(deletedModels[aliasKey] || []),
|
||||||
|
...(deletedModels[providerId] || []),
|
||||||
|
]);
|
||||||
|
if (disabledIds.size === 0 && deletedIds.size === 0) return;
|
||||||
|
group.models = group.models.filter((model) => !disabledIds.has(model.id) && ![
|
||||||
|
...deletedIds,
|
||||||
|
].some((deletedModelId) => (
|
||||||
|
model.id === deletedModelId
|
||||||
|
|| (model.id.startsWith(`${deletedModelId}(`) && model.id.endsWith(")"))
|
||||||
|
)));
|
||||||
if (group.models.length === 0) delete groups[providerId];
|
if (group.models.length === 0) delete groups[providerId];
|
||||||
});
|
});
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
}, [availableModels, filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders]);
|
}, [availableModels, filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, 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(() => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getDisabledModels } from "@/lib/disabledModelsDb";
|
import { getDisabledModels } from "@/lib/disabledModelsDb";
|
||||||
|
import { getDeletedModels } from "@/lib/db";
|
||||||
import { getProviderAlias } from "@/shared/constants/providers";
|
import { getProviderAlias } from "@/shared/constants/providers";
|
||||||
import { errorResponse } from "open-sse/utils/error.js";
|
import { errorResponse } from "open-sse/utils/error.js";
|
||||||
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
||||||
@@ -14,7 +15,7 @@ import { stripThinkingSuffix } from "open-sse/translator/concerns/thinkingUnifie
|
|||||||
*/
|
*/
|
||||||
export async function getDisabledModelResponse(provider, model) {
|
export async function getDisabledModelResponse(provider, model) {
|
||||||
try {
|
try {
|
||||||
const disabledModels = await getDisabledModels();
|
const [disabledModels, deletedModels] = await Promise.all([getDisabledModels(), getDeletedModels()]);
|
||||||
const providerAlias = getProviderAlias(provider) || provider;
|
const providerAlias = getProviderAlias(provider) || provider;
|
||||||
// Thinking variants use a client-facing suffix, e.g. `gpt-5.6-sol(high)`,
|
// 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
|
// but dispatch to the base upstream model. Evaluate the disabled policy
|
||||||
@@ -24,12 +25,23 @@ export async function getDisabledModelResponse(provider, model) {
|
|||||||
...(disabledModels[providerAlias] || []),
|
...(disabledModels[providerAlias] || []),
|
||||||
...(disabledModels[provider] || []),
|
...(disabledModels[provider] || []),
|
||||||
]);
|
]);
|
||||||
|
const deletedIds = new Set([
|
||||||
|
...(deletedModels[providerAlias] || []),
|
||||||
|
...(deletedModels[provider] || []),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!disabledIds.has(model) && !disabledIds.has(baseModel)) return null;
|
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(
|
return errorResponse(
|
||||||
HTTP_STATUS.NOT_FOUND,
|
HTTP_STATUS.NOT_FOUND,
|
||||||
`Model ${provider}/${model} is disabled by an administrator`,
|
matchesDeletedModel
|
||||||
|
? `Model ${provider}/${model} has been deleted by an administrator`
|
||||||
|
: `Model ${provider}/${model} is disabled by an administrator`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error checking disabled model status:", error);
|
console.log("Error checking disabled model status:", error);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const getProviderConnections = vi.fn();
|
|||||||
const getCustomModels = vi.fn();
|
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 getDisabledModels = vi.fn();
|
const getDisabledModels = vi.fn();
|
||||||
const requireUsageDashboardUser = vi.fn();
|
const requireUsageDashboardUser = vi.fn();
|
||||||
const getCapabilitiesForModel = vi.fn();
|
const getCapabilitiesForModel = vi.fn();
|
||||||
@@ -16,23 +17,30 @@ vi.mock("@/models", () => ({
|
|||||||
getProviderNodes,
|
getProviderNodes,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/db", () => ({ getUsers }));
|
vi.mock("@/lib/db", () => ({ getUsers, getDeletedModels }));
|
||||||
|
|
||||||
vi.mock("@/lib/disabledModelsDb", () => ({ getDisabledModels }));
|
vi.mock("@/lib/disabledModelsDb", () => ({ getDisabledModels }));
|
||||||
vi.mock("@/lib/auth/currentUser", () => ({
|
vi.mock("@/lib/auth/currentUser", () => ({
|
||||||
requireUsageDashboardUser,
|
requireUsageDashboardUser,
|
||||||
}));
|
}));
|
||||||
vi.mock("@/shared/constants/models", () => ({
|
vi.mock("open-sse/config/providerModels.js", () => ({
|
||||||
AI_MODELS: [
|
getModelsByProviderId: (providerId) => ({
|
||||||
{ provider: "alpha", model: "enabled", name: "Enabled model" },
|
alpha: [
|
||||||
{ provider: "alpha", model: "disabled", name: "Disabled model" },
|
{ id: "enabled", name: "Enabled model" },
|
||||||
{ provider: "beta", model: "inactive", name: "Inactive provider model" },
|
{ id: "disabled", name: "Disabled model" },
|
||||||
],
|
],
|
||||||
|
beta: [{ id: "inactive", name: "Inactive provider model" }],
|
||||||
|
"orbit-provider": [
|
||||||
|
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
|
||||||
|
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
|
||||||
|
],
|
||||||
|
}[providerId] || []),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/shared/constants/providers", () => {
|
vi.mock("@/shared/constants/providers", () => {
|
||||||
const providers = {
|
const providers = {
|
||||||
alpha: { id: "alpha", alias: "alpha-alias", name: "Alpha", color: "#111111" },
|
alpha: { id: "alpha", alias: "alpha-alias", name: "Alpha", color: "#111111" },
|
||||||
beta: { id: "beta", alias: "beta-alias", name: "Beta", color: "#222222" },
|
beta: { id: "beta", alias: "beta-alias", name: "Beta", color: "#222222" },
|
||||||
|
"orbit-provider": { id: "orbit-provider", alias: "orbit", name: "Orbit Provider", color: "#8B5CF6" },
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -54,12 +62,14 @@ describe("GET /api/models/connected", () => {
|
|||||||
getCustomModels.mockReset();
|
getCustomModels.mockReset();
|
||||||
getProviderNodes.mockReset();
|
getProviderNodes.mockReset();
|
||||||
getUsers.mockReset();
|
getUsers.mockReset();
|
||||||
|
getDeletedModels.mockReset();
|
||||||
getDisabledModels.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"] });
|
getDisabledModels.mockResolvedValue({ "alpha-alias": ["disabled"] });
|
||||||
|
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: "disabled", name: "Disabled model", type: "llm" },
|
||||||
@@ -101,7 +111,7 @@ describe("GET /api/models/connected", () => {
|
|||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not include registry models without an explicit added-model record", async () => {
|
it("includes registry models from a viable standard provider connection", async () => {
|
||||||
requireUsageDashboardUser.mockResolvedValue({ id: "admin", role: "admin" });
|
requireUsageDashboardUser.mockResolvedValue({ id: "admin", role: "admin" });
|
||||||
getCustomModels.mockResolvedValue([]);
|
getCustomModels.mockResolvedValue([]);
|
||||||
|
|
||||||
@@ -109,7 +119,56 @@ describe("GET /api/models/connected", () => {
|
|||||||
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",
|
||||||
|
providerAlias: "alpha-alias",
|
||||||
|
isCustom: false,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
fullModel: "alpha-alias/disabled",
|
||||||
|
disabled: true,
|
||||||
|
isCustom: false,
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
expect(body.models).not.toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ fullModel: "beta-alias/inactive" }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the storage alias for registry models when a provider ID differs from its alias", async () => {
|
||||||
|
requireUsageDashboardUser.mockResolvedValue({ id: "admin", role: "admin" });
|
||||||
|
getCustomModels.mockResolvedValue([
|
||||||
|
{ providerAlias: "orbit", id: "claude-opus-4-8", name: "Preferred Orbit Opus", type: "llm" },
|
||||||
|
]);
|
||||||
|
getProviderConnections.mockResolvedValue([
|
||||||
|
{ provider: "orbit-provider", isActive: true, apiKey: "secret" },
|
||||||
|
]);
|
||||||
|
getModelAliases.mockResolvedValue({ "orbit-opus": "orbit/claude-opus-4-8" });
|
||||||
|
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body.models).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: expect.objectContaining({ id: "orbit-provider", name: "Orbit Provider" }),
|
||||||
|
providerAlias: "orbit",
|
||||||
|
model: "claude-opus-4-8",
|
||||||
|
name: "Preferred Orbit Opus",
|
||||||
|
fullModel: "orbit/claude-opus-4-8",
|
||||||
|
alias: "orbit-opus",
|
||||||
|
isCustom: true,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
providerAlias: "orbit",
|
||||||
|
model: "claude-opus-4-6",
|
||||||
|
name: "Claude Opus 4.6",
|
||||||
|
fullModel: "orbit/claude-opus-4-6",
|
||||||
|
isCustom: false,
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
expect(body.models.filter((model) => model.fullModel === "orbit/claude-opus-4-8")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("excludes disabled models for non-administrators", async () => {
|
it("excludes disabled models for non-administrators", async () => {
|
||||||
@@ -188,6 +247,20 @@ describe("GET /api/models/connected", () => {
|
|||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not expose permanently deleted models to administrators", async () => {
|
||||||
|
requireUsageDashboardUser.mockResolvedValue({ id: "admin", role: "admin" });
|
||||||
|
getDeletedModels.mockResolvedValue({ "alpha-alias": ["disabled", "enabled"] });
|
||||||
|
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body.models).not.toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ fullModel: "alpha-alias/disabled" }),
|
||||||
|
expect.objectContaining({ fullModel: "alpha-alias/enabled" }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
it("does not treat a non-admin compatible-provider connection as shared", async () => {
|
it("does not treat a non-admin compatible-provider connection as shared", async () => {
|
||||||
const providerId = "openai-compatible-user-node";
|
const providerId = "openai-compatible-user-node";
|
||||||
requireUsageDashboardUser.mockResolvedValue({ id: "member-b", role: "user" });
|
requireUsageDashboardUser.mockResolvedValue({ id: "member-b", role: "user" });
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
const getCustomModels = vi.fn();
|
const getCustomModels = vi.fn();
|
||||||
const addCustomModel = vi.fn();
|
const addCustomModel = vi.fn();
|
||||||
const deleteCustomModel = vi.fn();
|
const deleteCustomModel = vi.fn();
|
||||||
|
const isDeletedModel = vi.fn();
|
||||||
const requireAdminUser = vi.fn();
|
const requireAdminUser = vi.fn();
|
||||||
|
|
||||||
vi.mock("@/models", () => ({
|
vi.mock("@/models", () => ({
|
||||||
@@ -10,6 +11,7 @@ vi.mock("@/models", () => ({
|
|||||||
addCustomModel,
|
addCustomModel,
|
||||||
deleteCustomModel,
|
deleteCustomModel,
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@/lib/db", () => ({ isDeletedModel }));
|
||||||
vi.mock("@/lib/auth/currentUser", () => ({ requireAdminUser }));
|
vi.mock("@/lib/auth/currentUser", () => ({ requireAdminUser }));
|
||||||
|
|
||||||
const { GET, POST, DELETE } = await import("../../src/app/api/models/custom/route.js");
|
const { GET, POST, DELETE } = await import("../../src/app/api/models/custom/route.js");
|
||||||
@@ -19,7 +21,9 @@ describe("/api/models/custom", () => {
|
|||||||
getCustomModels.mockReset();
|
getCustomModels.mockReset();
|
||||||
addCustomModel.mockReset();
|
addCustomModel.mockReset();
|
||||||
deleteCustomModel.mockReset();
|
deleteCustomModel.mockReset();
|
||||||
|
isDeletedModel.mockReset();
|
||||||
requireAdminUser.mockReset();
|
requireAdminUser.mockReset();
|
||||||
|
isDeletedModel.mockResolvedValue(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the shared catalog readable to authenticated model selectors", async () => {
|
it("keeps the shared catalog readable to authenticated model selectors", async () => {
|
||||||
@@ -64,6 +68,19 @@ describe("/api/models/custom", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not let an administrator re-add a permanently deleted model", async () => {
|
||||||
|
requireAdminUser.mockResolvedValue({ id: "admin", role: "admin" });
|
||||||
|
isDeletedModel.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const response = await POST(new Request("http://localhost/api/models/custom", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ providerAlias: "openai", id: "gpt-deleted", type: "llm" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(addCustomModel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects a non-admin deleting a shared custom model", async () => {
|
it("rejects a non-admin deleting a shared custom model", async () => {
|
||||||
requireAdminUser.mockRejectedValue(new Error("Forbidden"));
|
requireAdminUser.mockRejectedValue(new Error("Forbidden"));
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const getDisabledModels = vi.fn();
|
const getDisabledModels = vi.fn();
|
||||||
|
const getDeletedModels = vi.fn();
|
||||||
|
|
||||||
vi.mock("@/lib/disabledModelsDb", () => ({ getDisabledModels }));
|
vi.mock("@/lib/disabledModelsDb", () => ({ getDisabledModels }));
|
||||||
|
vi.mock("@/lib/db", () => ({ getDeletedModels }));
|
||||||
vi.mock("@/shared/constants/providers", () => ({
|
vi.mock("@/shared/constants/providers", () => ({
|
||||||
getProviderAlias: (provider) => ({ openai: "oa", claude: "claude" })[provider] || provider,
|
getProviderAlias: (provider) => ({ openai: "oa", claude: "claude" })[provider] || provider,
|
||||||
}));
|
}));
|
||||||
@@ -12,6 +14,8 @@ const { getDisabledModelResponse } = await import("../../src/sse/services/disabl
|
|||||||
describe("getDisabledModelResponse", () => {
|
describe("getDisabledModelResponse", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
getDisabledModels.mockReset();
|
getDisabledModels.mockReset();
|
||||||
|
getDeletedModels.mockReset();
|
||||||
|
getDeletedModels.mockResolvedValue({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows an enabled model", async () => {
|
it("allows an enabled model", async () => {
|
||||||
@@ -56,6 +60,21 @@ describe("getDisabledModelResponse", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it("fails closed when disabled-model storage cannot be read", async () => {
|
||||||
getDisabledModels.mockRejectedValue(new Error("database unavailable"));
|
getDisabledModels.mockRejectedValue(new Error("database unavailable"));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const deleteModelPermanently = vi.fn();
|
||||||
|
const getDeletedModels = vi.fn();
|
||||||
|
const purgeRequestDetailBuffer = vi.fn();
|
||||||
|
const requireAdminUser = vi.fn();
|
||||||
|
const resetComboRotation = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", () => ({
|
||||||
|
deleteModelPermanently,
|
||||||
|
getDeletedModels,
|
||||||
|
purgeRequestDetailBuffer,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth/currentUser", () => ({ requireAdminUser }));
|
||||||
|
vi.mock("open-sse/services/combo.js", () => ({ resetComboRotation }));
|
||||||
|
|
||||||
|
const { GET, POST } = await import("../../src/app/api/models/delete/route.js");
|
||||||
|
|
||||||
|
describe("/api/models/delete", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
deleteModelPermanently.mockReset();
|
||||||
|
getDeletedModels.mockReset();
|
||||||
|
purgeRequestDetailBuffer.mockReset();
|
||||||
|
requireAdminUser.mockReset();
|
||||||
|
resetComboRotation.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-admin permanent deletion", async () => {
|
||||||
|
requireAdminUser.mockRejectedValue(new Error("Forbidden"));
|
||||||
|
|
||||||
|
const response = await POST(new Request("http://localhost/api/models/delete", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ providerAlias: "alpha", modelId: "model-a" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(deleteModelPermanently).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates model deletion input", async () => {
|
||||||
|
requireAdminUser.mockResolvedValue({ id: "admin", role: "admin" });
|
||||||
|
|
||||||
|
const response = await POST(new Request("http://localhost/api/models/delete", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ providerAlias: "alpha" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(deleteModelPermanently).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cascades an admin deletion without purging usage history", async () => {
|
||||||
|
requireAdminUser.mockResolvedValue({ id: "admin", role: "admin" });
|
||||||
|
deleteModelPermanently.mockResolvedValue({
|
||||||
|
providerAliases: ["alpha", "alpha-id"],
|
||||||
|
modelId: "model-a",
|
||||||
|
updatedComboIds: ["combo-updated"],
|
||||||
|
deletedComboIds: ["combo-deleted"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(new Request("http://localhost/api/models/delete", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ providerAlias: "alpha", modelId: "model-a" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(deleteModelPermanently).toHaveBeenCalledWith("alpha", "model-a");
|
||||||
|
expect(resetComboRotation).toHaveBeenCalledWith("combo-updated");
|
||||||
|
expect(resetComboRotation).toHaveBeenCalledWith("combo-deleted");
|
||||||
|
expect(purgeRequestDetailBuffer).toHaveBeenCalledWith(["alpha", "alpha-id"], "model-a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns permanent-deletion tombstones for catalog readers", async () => {
|
||||||
|
getDeletedModels.mockResolvedValue({ alpha: ["model-a"] });
|
||||||
|
|
||||||
|
const response = await GET(new Request("http://localhost/api/models/delete?providerAlias=alpha"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
await expect(response.json()).resolves.toEqual({ ids: ["model-a"] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const originalDataDir = process.env.DATA_DIR;
|
||||||
|
let tempDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-model-delete-"));
|
||||||
|
process.env.DATA_DIR = tempDir;
|
||||||
|
delete global._dbAdapter;
|
||||||
|
delete global._pendingRequests;
|
||||||
|
delete global._pendingTimers;
|
||||||
|
delete global._recentRing;
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try { global._dbAdapter?.instance?.close?.(); } catch {}
|
||||||
|
delete global._dbAdapter;
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||||
|
else process.env.DATA_DIR = originalDataDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("permanent model deletion", () => {
|
||||||
|
it("removes dependent catalog, combo, pricing, and observability data while retaining usage history", async () => {
|
||||||
|
const db = await import("@/lib/db/index.js");
|
||||||
|
const providerId = "openai-compatible-cascade-test";
|
||||||
|
const providerPrefix = "cascade";
|
||||||
|
const modelId = "gpt-delete";
|
||||||
|
|
||||||
|
await db.createProviderNode({
|
||||||
|
id: providerId,
|
||||||
|
type: "openai-compatible",
|
||||||
|
name: "Cascade Test Provider",
|
||||||
|
prefix: providerPrefix,
|
||||||
|
baseUrl: "https://example.invalid/v1",
|
||||||
|
});
|
||||||
|
await db.setModelAlias("deleted-alias", `${providerPrefix}/${modelId}`);
|
||||||
|
await db.setModelAlias("keep-alias", `${providerPrefix}/gpt-keep`);
|
||||||
|
await db.addCustomModel({ providerAlias: providerPrefix, id: modelId, type: "llm" });
|
||||||
|
await db.addCustomModel({ providerAlias: providerPrefix, id: "gpt-keep", type: "llm" });
|
||||||
|
await db.disableModels(providerPrefix, [modelId]);
|
||||||
|
await db.updatePricing({
|
||||||
|
[providerPrefix]: {
|
||||||
|
[modelId]: { prompt: 1, completion: 2 },
|
||||||
|
"gpt-keep": { prompt: 3, completion: 4 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mixedCombo = await db.createCombo({
|
||||||
|
name: "cascade-mixed",
|
||||||
|
models: [`${providerPrefix}/${modelId}`, `${providerPrefix}/gpt-keep`],
|
||||||
|
});
|
||||||
|
const aliasCombo = await db.createCombo({
|
||||||
|
name: "cascade-alias-only",
|
||||||
|
models: ["deleted-alias"],
|
||||||
|
});
|
||||||
|
await db.updateSettings({
|
||||||
|
comboStrategies: {
|
||||||
|
[mixedCombo.id]: {
|
||||||
|
fallbackStrategy: "fusion",
|
||||||
|
judgeModel: "deleted-alias",
|
||||||
|
},
|
||||||
|
[aliasCombo.id]: { fallbackStrategy: "round-robin" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const cliUser = await db.createUser({ username: "cascade-cli-user", password: "password", role: "user" });
|
||||||
|
await db.upsertCliToolConfig(cliUser.id, "codex", {
|
||||||
|
baseUrl: "http://127.0.0.1:20127",
|
||||||
|
codexModel: "deleted-alias",
|
||||||
|
codexThinking: "high",
|
||||||
|
});
|
||||||
|
await db.upsertCliToolConfig(cliUser.id, "cowork", {
|
||||||
|
baseUrl: "http://127.0.0.1:20127",
|
||||||
|
selectedModels: ["deleted-alias", `${providerPrefix}/gpt-keep`],
|
||||||
|
coworkThinking: { "deleted-alias": "high", [`${providerPrefix}/gpt-keep`]: "low" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.saveRequestUsage({
|
||||||
|
timestamp: "2026-07-18T10:00:00.000Z",
|
||||||
|
provider: providerId,
|
||||||
|
model: modelId,
|
||||||
|
connectionId: "cascade-connection",
|
||||||
|
tokens: { prompt_tokens: 10, completion_tokens: 5 },
|
||||||
|
endpoint: "/v1/chat/completions",
|
||||||
|
});
|
||||||
|
await db.saveRequestUsage({
|
||||||
|
timestamp: "2026-07-18T10:01:00.000Z",
|
||||||
|
provider: providerPrefix,
|
||||||
|
model: `${modelId}(high)`,
|
||||||
|
connectionId: "cascade-connection",
|
||||||
|
tokens: { prompt_tokens: 20, completion_tokens: 10 },
|
||||||
|
endpoint: "/v1/chat/completions",
|
||||||
|
});
|
||||||
|
await db.saveRequestUsage({
|
||||||
|
timestamp: "2026-07-18T10:02:00.000Z",
|
||||||
|
provider: providerId,
|
||||||
|
model: "gpt-keep",
|
||||||
|
connectionId: "cascade-connection",
|
||||||
|
tokens: { prompt_tokens: 30, completion_tokens: 15 },
|
||||||
|
endpoint: "/v1/chat/completions",
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.updateSettings({ enableObservability: true, observabilityBatchSize: 1 });
|
||||||
|
await db.saveRequestDetail({
|
||||||
|
id: "cascade-deleted-detail",
|
||||||
|
provider: providerPrefix,
|
||||||
|
model: modelId,
|
||||||
|
status: "ok",
|
||||||
|
request: {},
|
||||||
|
response: {},
|
||||||
|
});
|
||||||
|
await db.saveRequestDetail({
|
||||||
|
id: "cascade-keep-detail",
|
||||||
|
provider: providerId,
|
||||||
|
model: "gpt-keep",
|
||||||
|
status: "ok",
|
||||||
|
request: {},
|
||||||
|
response: {},
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
const result = await db.deleteModelPermanently(providerId, modelId);
|
||||||
|
db.purgeRequestDetailBuffer(result.providerAliases, result.modelId);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
deleted: true,
|
||||||
|
removedAliases: 1,
|
||||||
|
removedCustomModels: 1,
|
||||||
|
removedPricingEntries: 1,
|
||||||
|
removedDisabledModels: 1,
|
||||||
|
removedRequestDetails: 1,
|
||||||
|
updatedCliToolConfigs: 2,
|
||||||
|
updatedComboIds: [mixedCombo.id],
|
||||||
|
deletedComboIds: [aliasCombo.id],
|
||||||
|
});
|
||||||
|
expect(result.providerAliases).toEqual(expect.arrayContaining([providerId, providerPrefix]));
|
||||||
|
expect(await db.isDeletedModel(providerId, modelId)).toBe(true);
|
||||||
|
expect(await db.isDeletedModel(providerId, `${modelId}(high)`)).toBe(true);
|
||||||
|
expect((await db.getDeletedModels())[providerId]).toContain(modelId);
|
||||||
|
|
||||||
|
expect(await db.getModelAliases()).toEqual({ "keep-alias": `${providerPrefix}/gpt-keep` });
|
||||||
|
expect(await db.getCustomModels()).toEqual([
|
||||||
|
expect.objectContaining({ providerAlias: providerPrefix, id: "gpt-keep" }),
|
||||||
|
]);
|
||||||
|
expect(await db.getDisabledByProvider(providerPrefix)).toEqual([]);
|
||||||
|
expect((await db.getPricing())[providerPrefix]).toEqual({
|
||||||
|
"gpt-keep": { prompt: 3, completion: 4 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await db.getComboById(mixedCombo.id)).toMatchObject({
|
||||||
|
models: [`${providerPrefix}/gpt-keep`],
|
||||||
|
});
|
||||||
|
expect(await db.getComboById(aliasCombo.id)).toBeNull();
|
||||||
|
expect((await db.getSettings()).comboStrategies).toEqual({
|
||||||
|
[mixedCombo.id]: { fallbackStrategy: "fusion" },
|
||||||
|
});
|
||||||
|
expect((await db.getCliToolConfig(cliUser.id, "codex")).config).toMatchObject({
|
||||||
|
codexModel: "",
|
||||||
|
codexThinking: "",
|
||||||
|
});
|
||||||
|
expect((await db.getCliToolConfig(cliUser.id, "cowork")).config).toMatchObject({
|
||||||
|
selectedModels: [`${providerPrefix}/gpt-keep`],
|
||||||
|
coworkThinking: { [`${providerPrefix}/gpt-keep`]: "low" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const history = await db.getUsageHistory({});
|
||||||
|
expect(history).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ provider: providerId, model: modelId }),
|
||||||
|
expect.objectContaining({ provider: providerPrefix, model: `${modelId}(high)` }),
|
||||||
|
expect.objectContaining({ provider: providerId, model: "gpt-keep" }),
|
||||||
|
]));
|
||||||
|
expect(history).toHaveLength(3);
|
||||||
|
const stats = await db.getUsageStats("all");
|
||||||
|
expect(stats.totalRequests).toBe(3);
|
||||||
|
expect(stats.byModel).toMatchObject({
|
||||||
|
[`${modelId} (${providerId})`]: expect.objectContaining({ requests: 1, promptTokens: 10, completionTokens: 5 }),
|
||||||
|
[`${modelId}(high) (${providerPrefix})`]: expect.objectContaining({ requests: 1, promptTokens: 20, completionTokens: 10 }),
|
||||||
|
[`gpt-keep (${providerId})`]: expect.objectContaining({ requests: 1 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await db.getRequestDetailById("cascade-deleted-detail")).toBeNull();
|
||||||
|
expect(await db.getRequestDetailById("cascade-keep-detail")).toMatchObject({ id: "cascade-keep-detail" });
|
||||||
|
|
||||||
|
await db.saveRequestUsage({
|
||||||
|
timestamp: "2026-07-18T10:03:00.000Z",
|
||||||
|
provider: providerId,
|
||||||
|
model: modelId,
|
||||||
|
tokens: { prompt_tokens: 100, completion_tokens: 100 },
|
||||||
|
});
|
||||||
|
await db.saveRequestDetail({
|
||||||
|
id: "cascade-deleted-detail-after-tombstone",
|
||||||
|
provider: providerId,
|
||||||
|
model: modelId,
|
||||||
|
status: "ok",
|
||||||
|
request: {},
|
||||||
|
response: {},
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
const historyAfterTombstone = await db.getUsageHistory({});
|
||||||
|
expect(historyAfterTombstone).toHaveLength(4);
|
||||||
|
expect(historyAfterTombstone).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: providerId,
|
||||||
|
model: modelId,
|
||||||
|
tokens: expect.objectContaining({ prompt_tokens: 100, completion_tokens: 100 }),
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
const statsAfterTombstone = await db.getUsageStats("all");
|
||||||
|
expect(statsAfterTombstone.totalRequests).toBe(4);
|
||||||
|
expect(statsAfterTombstone.byModel[`${modelId} (${providerId})`]).toMatchObject({
|
||||||
|
requests: 2,
|
||||||
|
promptTokens: 110,
|
||||||
|
completionTokens: 105,
|
||||||
|
});
|
||||||
|
expect(await db.getRequestDetailById("cascade-deleted-detail-after-tombstone")).toBeNull();
|
||||||
|
|
||||||
|
const backup = await db.exportDb();
|
||||||
|
expect(backup.deletedModels).toMatchObject({ [providerId]: [modelId] });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user