fix: update the logic for remove model in provider

This commit is contained in:
2026-07-19 18:32:14 +07:00
parent fa7cd62d58
commit 62975ab15b
25 changed files with 1209 additions and 172 deletions
+4 -4
View File
@@ -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 (
+5
View File
@@ -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 });
+123 -45
View File
@@ -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
View File
@@ -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) {
+65
View File
@@ -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 });
}
}
+10 -2
View File
@@ -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
+7
View File
@@ -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}` },
+7
View File
@@ -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(
+22 -2
View File
@@ -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
View File
@@ -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();
+351
View File
@@ -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;
}
+23 -2
View File
@@ -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");
+9
View File
@@ -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
View File
@@ -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";
+25 -7
View File
@@ -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(() => {
+15 -3
View File
@@ -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);
+82 -9
View File
@@ -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" });
+17
View File
@@ -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"] });
});
});
+225
View File
@@ -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] });
});
});