mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
fix: update the logic for remove model in provider
This commit is contained in:
@@ -254,8 +254,8 @@ export default function ModelsPage() {
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
{canManage
|
||||
? "Manage models explicitly added from connected providers."
|
||||
: "Browse added models currently available through connected providers."}
|
||||
? "Manage models available from connected providers."
|
||||
: "Browse models currently available through connected providers."}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
@@ -296,7 +296,7 @@ export default function ModelsPage() {
|
||||
<span className="material-symbols-outlined text-[32px] text-text-muted">search_off</span>
|
||||
<p className="mt-2 text-sm text-text-muted">
|
||||
{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."}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Button } from "@/shared/components";
|
||||
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"
|
||||
? "border-green-500/40"
|
||||
: testStatus === "error"
|
||||
@@ -61,9 +61,9 @@ function CompatibleModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias,
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onDeleteAlias}
|
||||
onClick={onDeleteModel}
|
||||
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>
|
||||
</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 [adding, setAdding] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
@@ -202,7 +202,7 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider
|
||||
fullModel={`${providerDisplayAlias}/${id}`}
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
onDeleteAlias={() => source === "custom" ? onDeleteCustomModel(id) : onDeleteAlias(alias)}
|
||||
onDeleteModel={() => onDeleteModel(id)}
|
||||
onTest={connections.length > 0 ? () => handleTestModel(id) : undefined}
|
||||
testStatus={modelTestResults[id]}
|
||||
isTesting={testingModelId === id}
|
||||
@@ -221,9 +221,8 @@ CompatibleModelsSection.propTypes = {
|
||||
customModels: PropTypes.arrayOf(PropTypes.object),
|
||||
copied: PropTypes.string,
|
||||
onCopy: PropTypes.func.isRequired,
|
||||
onDeleteAlias: PropTypes.func.isRequired,
|
||||
onDeleteModel: PropTypes.func.isRequired,
|
||||
onAddCustomModel: PropTypes.func.isRequired,
|
||||
onDeleteCustomModel: PropTypes.func.isRequired,
|
||||
connections: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
isActive: PropTypes.bool,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import PropTypes from "prop-types";
|
||||
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 borderColor = testStatus === "ok"
|
||||
? "border-green-500/40"
|
||||
@@ -14,9 +14,13 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test
|
||||
: testStatus === "error"
|
||||
? "#ef4444"
|
||||
: 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 (
|
||||
<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">
|
||||
<span
|
||||
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"}
|
||||
</span>
|
||||
<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">
|
||||
{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} />
|
||||
</span>
|
||||
</div>
|
||||
{onTest && (
|
||||
<div className="relative shrink-0 group/btn">
|
||||
<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)]">
|
||||
{onTest && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTest}
|
||||
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"}
|
||||
</span>
|
||||
</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
|
||||
type="button"
|
||||
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"}
|
||||
</span>
|
||||
</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">
|
||||
{copied === `model-${model.id}` ? "Copied!" : "Copy"}
|
||||
</span>
|
||||
{deleteModel && <span className="mx-0.5 h-4 w-px bg-border" aria-hidden="true" />}
|
||||
{deleteModel && (
|
||||
<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>
|
||||
{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>
|
||||
);
|
||||
@@ -101,10 +89,8 @@ ModelRow.propTypes = {
|
||||
onCopy: PropTypes.func.isRequired,
|
||||
testStatus: PropTypes.oneOf(["ok", "error"]),
|
||||
isCustom: PropTypes.bool,
|
||||
isAdded: PropTypes.bool,
|
||||
isFree: PropTypes.bool,
|
||||
onDeleteAlias: PropTypes.func,
|
||||
onAdd: PropTypes.func,
|
||||
onRemove: PropTypes.func,
|
||||
onTest: PropTypes.func,
|
||||
isTesting: PropTypes.bool,
|
||||
|
||||
@@ -68,6 +68,7 @@ export default function ProviderDetailPage() {
|
||||
const [suggestedModels, setSuggestedModels] = useState([]);
|
||||
const [kiloFreeModels, setKiloFreeModels] = useState([]);
|
||||
const [disabledModelIds, setDisabledModelIds] = useState([]);
|
||||
const [deletedModelIds, setDeletedModelIds] = useState([]);
|
||||
const [confirmState, setConfirmState] = useState(null);
|
||||
const [showAgRiskModal, setShowAgRiskModal] = useState(false);
|
||||
const [oneByOneRunning, setOneByOneRunning] = useState(false);
|
||||
@@ -186,34 +187,47 @@ export default function ProviderDetailPage() {
|
||||
|
||||
const fetchDisabledModels = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (res.ok) setDisabledModelIds(data.ids || []);
|
||||
const [disabledRes, deletedRes] = await Promise.all([
|
||||
fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}`, { cache: "no-store" }),
|
||||
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) {
|
||||
console.log("Error fetching disabled models:", error);
|
||||
}
|
||||
}, [providerStorageAlias]);
|
||||
|
||||
const handleDisableModel = async (modelId) => {
|
||||
try {
|
||||
const res = await fetch("/api/models/disabled", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ providerAlias: providerStorageAlias, ids: [modelId] }),
|
||||
});
|
||||
if (res.ok) await fetchDisabledModels();
|
||||
} catch (error) {
|
||||
console.log("Error disabling model:", error);
|
||||
}
|
||||
};
|
||||
const handlePermanentlyDeleteModel = (modelId, providerAliasOverride = providerStorageAlias) => {
|
||||
const displayAlias = providerAliasOverride === providerStorageAlias
|
||||
? providerDisplayAlias
|
||||
: providerAliasOverride;
|
||||
setConfirmState({
|
||||
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.`,
|
||||
onConfirm: async () => {
|
||||
setConfirmState(null);
|
||||
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) => {
|
||||
try {
|
||||
const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}&id=${encodeURIComponent(modelId)}`, { method: "DELETE" });
|
||||
if (res.ok) await fetchDisabledModels();
|
||||
} catch (error) {
|
||||
console.log("Error enabling model:", error);
|
||||
}
|
||||
await Promise.all([fetchAliases(), fetchCustomModels(), fetchDisabledModels()]);
|
||||
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("customModelChanged"));
|
||||
} catch (error) {
|
||||
console.log("Error permanently deleting model:", error);
|
||||
alert("Failed to permanently delete model");
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDisableAll = async (ids) => {
|
||||
@@ -1042,10 +1056,8 @@ export default function ProviderDetailPage() {
|
||||
customModels={customModels}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onSetAlias={handleSetAlias}
|
||||
onDeleteAlias={handleDeleteAlias}
|
||||
onAddCustomModel={(modelId) => handleAddCustomModel(modelId, "llm", providerStorageAlias)}
|
||||
onDeleteCustomModel={(modelId) => handleDeleteCustomModel(modelId, "llm", providerStorageAlias)}
|
||||
onDeleteModel={(modelId) => handlePermanentlyDeleteModel(modelId, providerStorageAlias)}
|
||||
connections={connections}
|
||||
isAnthropic={isAnthropicCompatible}
|
||||
/>
|
||||
@@ -1067,8 +1079,9 @@ export default function ProviderDetailPage() {
|
||||
))
|
||||
.map((entry) => entry.id),
|
||||
);
|
||||
const displayModels = allModels.filter((m) => !disabledSet.has(m.id));
|
||||
const disabledDisplayModels = allModels.filter((m) => disabledSet.has(m.id));
|
||||
const deletedSet = new Set(deletedModelIds);
|
||||
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({
|
||||
customModels,
|
||||
modelAliases,
|
||||
@@ -1090,18 +1103,11 @@ export default function ProviderDetailPage() {
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onSetAlias={() => {}}
|
||||
onDeleteAlias={() => {
|
||||
if (model.source === "custom") {
|
||||
handleDeleteCustomModel(model.id, "llm", providerStorageAlias);
|
||||
} else {
|
||||
handleDeleteAlias(model.alias);
|
||||
}
|
||||
}}
|
||||
onDeleteAlias={() => handlePermanentlyDeleteModel(model.id, providerStorageAlias)}
|
||||
testStatus={modelTestResults[model.id]}
|
||||
onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined}
|
||||
isTesting={testingModelIds.has(model.id)}
|
||||
isCustom
|
||||
isAdded
|
||||
isFree={false}
|
||||
caps={getCaps(`${providerId}/${model.id}`)}
|
||||
thinkingSuffix={resolveThinkingSuffix(model.id)}
|
||||
@@ -1129,10 +1135,10 @@ export default function ProviderDetailPage() {
|
||||
isTesting={testingModelIds.has(model.id)}
|
||||
isFree={model.isFree}
|
||||
isAdded={addedModelIds.has(model.id)}
|
||||
onAdd={() => handleAddCustomModel(model.id, "llm", providerStorageAlias)}
|
||||
onRemove={addedModelIds.has(model.id)
|
||||
? () => handleDeleteCustomModel(model.id, "llm", providerStorageAlias)
|
||||
? () => handlePermanentlyDeleteModel(model.id, providerStorageAlias)
|
||||
: undefined}
|
||||
onDisable={() => handlePermanentlyDeleteModel(model.id, providerStorageAlias)}
|
||||
caps={getCaps(`${providerId}/${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 && (
|
||||
<div className="w-full mt-2">
|
||||
<p className="text-xs text-text-muted mb-2">Disabled models ({disabledDisplayModels.length}):</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{disabledDisplayModels.map((m) => (
|
||||
{disabledDisplayModels.map((model) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => handleEnableModel(m.id)}
|
||||
key={model.id}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}&id=${encodeURIComponent(model.id)}`, { method: "DELETE" });
|
||||
if (res.ok) await fetchDisabledModels();
|
||||
} catch (error) {
|
||||
console.log("Error enabling model:", error);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-dashed border-black/10 dark:border-white/10 text-xs text-text-muted hover:text-primary hover:border-primary/40 hover:bg-primary/5 transition-colors"
|
||||
title="Restore model"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">add</span>
|
||||
{m.id}
|
||||
{model.id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,6 +36,8 @@ function getQuotaTone(percentage) {
|
||||
return { bar: "bg-red-500", dot: "bg-red-500", text: "text-red-500" };
|
||||
}
|
||||
|
||||
const INACTIVE_SESSION_COUNTDOWN = "4h59m";
|
||||
|
||||
function TokenQuotaResetStatus({ quota }) {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
@@ -49,7 +51,7 @@ function TokenQuotaResetStatus({ quota }) {
|
||||
const countdown = formatResetTime(quota.resetAt, now);
|
||||
const isSession = quota.windowType === USER_TOKEN_LIMIT_WINDOWS.SESSION;
|
||||
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}`);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getModelAliases, setModelAlias, deleteModelAlias } from "@/models";
|
||||
import { isDeletedModelReference } from "@/lib/db";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -24,6 +25,10 @@ export async function PUT(request) {
|
||||
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);
|
||||
|
||||
return NextResponse.json({ success: true, model, alias });
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@/models";
|
||||
import { getUsers } from "@/lib/db";
|
||||
import { disableModels, enableModels, getDisabledModels } from "@/lib/disabledModelsDb";
|
||||
import { getDeletedModels } from "@/lib/db";
|
||||
import { requireAdminUser, requireUsageDashboardUser } from "@/lib/auth/currentUser";
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
isAnthropicCompatibleProvider,
|
||||
isOpenAICompatibleProvider,
|
||||
} from "@/shared/constants/providers";
|
||||
import { getModelsByProviderId } from "open-sse/config/providerModels.js";
|
||||
import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
|
||||
|
||||
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) {
|
||||
if (error.message === "Unauthorized") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
@@ -81,23 +135,25 @@ function getForbiddenResponse(error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// GET /api/models/connected - List administrator-added LLMs from providers with
|
||||
// a usable active connection. Provider registries and live /models responses are
|
||||
// discovery sources only; a customModels record is the explicit availability source.
|
||||
// GET /api/models/connected - List LLMs available through providers with a
|
||||
// usable active connection. Registry models are available immediately, while
|
||||
// customModels records extend the catalog and can provide administrator names.
|
||||
export async function GET() {
|
||||
try {
|
||||
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(),
|
||||
getCustomModels(),
|
||||
getDisabledModels(),
|
||||
getDeletedModels(),
|
||||
getModelAliases(),
|
||||
getProviderNodes(),
|
||||
getUsers(),
|
||||
]);
|
||||
|
||||
const connectedProviderByAlias = new Map();
|
||||
const connectedProviderById = new Map();
|
||||
for (const connection of connections) {
|
||||
if (!isViableConnection(connection)) continue;
|
||||
|
||||
@@ -108,13 +164,16 @@ export async function GET() {
|
||||
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)) {
|
||||
if (!connectedProviderByAlias.has(alias)) {
|
||||
connectedProviderByAlias.set(alias, {
|
||||
providerId: connection.provider,
|
||||
providerAlias: getProviderAlias(connection.provider) || connection.provider,
|
||||
provider: getProviderLabel(connection.provider),
|
||||
});
|
||||
connectedProviderByAlias.set(alias, providerEntry);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,45 +215,64 @@ export async function GET() {
|
||||
|
||||
const aliasByFullModel = getAliasByFullModel(modelAliases);
|
||||
const seenFullModels = new Set();
|
||||
const models = customModels
|
||||
.filter((customModel) => customModel?.id && getModelType(customModel) === "llm")
|
||||
.map((customModel) => {
|
||||
const providerEntry = connectedProviderByAlias.get(customModel.providerAlias)
|
||||
|| compatibleProviderByAlias.get(customModel.providerAlias);
|
||||
if (!providerEntry) return null;
|
||||
const models = [];
|
||||
const addModel = ({ isCustom, modelId, name, providerEntry, storageAlias }) => {
|
||||
const fullModel = `${storageAlias}/${modelId}`;
|
||||
if (seenFullModels.has(fullModel)) return;
|
||||
seenFullModels.add(fullModel);
|
||||
const connectedModel = createConnectedModel({
|
||||
disabledModels,
|
||||
deletedModels,
|
||||
fullModel,
|
||||
isCustom,
|
||||
modelAliases: aliasByFullModel,
|
||||
modelId,
|
||||
name,
|
||||
providerEntry,
|
||||
storageAlias,
|
||||
});
|
||||
if (connectedModel) models.push(connectedModel);
|
||||
};
|
||||
|
||||
const modelId = String(customModel.id).trim();
|
||||
if (!modelId) return null;
|
||||
// Custom registrations can supply an administrator-defined name. Add them
|
||||
// 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 fullModel = `${storageAlias}/${modelId}`;
|
||||
if (seenFullModels.has(fullModel)) return null;
|
||||
seenFullModels.add(fullModel);
|
||||
const storageAlias = customModel.providerAlias;
|
||||
const providerEntry = connectedProviderByAlias.get(storageAlias)
|
||||
|| connectedProviderById.get(storageAlias)
|
||||
|| compatibleProviderByAlias.get(storageAlias);
|
||||
const modelId = String(customModel.id).trim();
|
||||
if (!providerEntry || !modelId) continue;
|
||||
|
||||
const disabled = new Set([
|
||||
...(disabledModels[storageAlias] || []),
|
||||
...(disabledModels[providerEntry.providerId] || []),
|
||||
...(disabledModels[providerEntry.providerAlias] || []),
|
||||
]);
|
||||
const caps = getCapabilitiesForModel(providerEntry.providerId, modelId);
|
||||
addModel({
|
||||
isCustom: true,
|
||||
modelId,
|
||||
name: customModel.name,
|
||||
providerEntry,
|
||||
storageAlias,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
provider: providerEntry.provider,
|
||||
providerAlias: storageAlias,
|
||||
model: modelId,
|
||||
name: customModel.name || modelId,
|
||||
fullModel,
|
||||
alias: aliasByFullModel.get(fullModel) || modelId,
|
||||
disabled: disabled.has(modelId),
|
||||
isCustom: true,
|
||||
caps: {
|
||||
vision: caps.vision,
|
||||
search: caps.search,
|
||||
reasoning: caps.reasoning,
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
// Standard providers declare their model catalogs in the registry. Expose
|
||||
// those models after a usable connection exists; compatible providers stay
|
||||
// custom-model-only because their available models are runtime-defined.
|
||||
for (const providerEntry of connectedProviderById.values()) {
|
||||
const storageAlias = providerEntry.providerAlias;
|
||||
for (const model of getModelsByProviderId(providerEntry.providerId)) {
|
||||
if (!model?.id || getModelType(model) !== "llm") continue;
|
||||
addModel({
|
||||
isCustom: false,
|
||||
modelId: model.id,
|
||||
name: model.name,
|
||||
providerEntry,
|
||||
storageAlias,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const visibleModels = models
|
||||
.filter((model) => user.role === "admin" || !model.disabled)
|
||||
.sort((a, b) => (
|
||||
a.provider.name.localeCompare(b.provider.name)
|
||||
@@ -202,7 +280,7 @@ export async function GET() {
|
||||
|| a.model.localeCompare(b.model)
|
||||
));
|
||||
|
||||
return NextResponse.json({ models });
|
||||
return NextResponse.json({ models: visibleModels });
|
||||
} catch (error) {
|
||||
const accessError = getForbiddenResponse(error);
|
||||
if (accessError) return accessError;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
addCustomModel,
|
||||
deleteCustomModel,
|
||||
} from "@/models";
|
||||
import { isDeletedModel } from "@/lib/db";
|
||||
import { requireAdminUser } from "@/lib/auth/currentUser";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -44,6 +45,9 @@ export async function POST(request) {
|
||||
return NextResponse.json({ error: "providerAlias and id required" }, { status: 400 });
|
||||
}
|
||||
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 });
|
||||
return NextResponse.json({ success: true, added });
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
deleteModelPermanently,
|
||||
getDeletedModels,
|
||||
purgeRequestDetailBuffer,
|
||||
} from "@/lib/db";
|
||||
import { requireAdminUser } from "@/lib/auth/currentUser";
|
||||
import { resetComboRotation } from "open-sse/services/combo.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function getAccessErrorResponse(error) {
|
||||
if (error.message === "Unauthorized") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (error.message === "Forbidden") {
|
||||
return NextResponse.json({ error: "Administrator access required" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// GET /api/models/delete?providerAlias=xxx
|
||||
// Returns permanent-deletion tombstones for clients that render a provider catalog.
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const providerAlias = searchParams.get("providerAlias");
|
||||
const deleted = await getDeletedModels();
|
||||
if (providerAlias) return NextResponse.json({ ids: deleted[providerAlias] || [] });
|
||||
return NextResponse.json({ deleted });
|
||||
} catch (error) {
|
||||
console.log("Error fetching permanently deleted models:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch deleted models" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/models/delete body: { providerAlias, modelId }
|
||||
// Permanently removes a model from selectable catalogs and saved routing configuration.
|
||||
// Usage history is intentionally retained for accurate token reporting.
|
||||
export async function POST(request) {
|
||||
try {
|
||||
await requireAdminUser();
|
||||
|
||||
const body = await request.json();
|
||||
const providerAlias = typeof body.providerAlias === "string" ? body.providerAlias.trim() : "";
|
||||
const modelId = typeof body.modelId === "string" ? body.modelId.trim() : "";
|
||||
if (!providerAlias || !modelId) {
|
||||
return NextResponse.json({ error: "providerAlias and modelId are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await deleteModelPermanently(providerAlias, modelId);
|
||||
for (const comboId of [...result.updatedComboIds, ...result.deletedComboIds]) {
|
||||
resetComboRotation(comboId);
|
||||
}
|
||||
purgeRequestDetailBuffer(result.providerAliases, result.modelId);
|
||||
|
||||
return NextResponse.json({ success: true, result });
|
||||
} catch (error) {
|
||||
const accessError = getAccessErrorResponse(error);
|
||||
if (accessError) return accessError;
|
||||
|
||||
console.log("Error permanently deleting model:", error);
|
||||
return NextResponse.json({ error: "Failed to permanently delete model" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getModelAliases, setModelAlias } from "@/models";
|
||||
import { getDisabledModels } from "@/lib/disabledModelsDb";
|
||||
import { getDeletedModels, isDeletedModelReference } from "@/lib/db";
|
||||
import { AI_MODELS } from "@/shared/constants/config";
|
||||
import { getProviderAlias } from "@/shared/constants/providers";
|
||||
import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
|
||||
@@ -9,13 +10,16 @@ import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
|
||||
export async function GET() {
|
||||
try {
|
||||
const modelAliases = await getModelAliases();
|
||||
const disabled = await getDisabledModels();
|
||||
const [disabled, deleted] = await Promise.all([getDisabledModels(), getDeletedModels()]);
|
||||
|
||||
const models = AI_MODELS
|
||||
.filter((m) => {
|
||||
const alias = getProviderAlias(m.provider) || 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) => {
|
||||
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 });
|
||||
}
|
||||
|
||||
if (await isDeletedModelReference(model)) {
|
||||
return NextResponse.json({ error: "This model was permanently deleted" }, { status: 409 });
|
||||
}
|
||||
|
||||
const modelAliases = await getModelAliases();
|
||||
|
||||
// Check if alias already exists for different model
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPricing, updatePricing, resetPricing, resetAllPricing } from "@/lib/localDb.js";
|
||||
import { isDeletedModel } from "@/lib/db";
|
||||
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)) {
|
||||
if (await isDeletedModel(provider, model)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Model was permanently deleted: ${provider}/${model}` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (typeof pricing !== "object" || pricing === null) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid pricing for model: ${provider}/${model}` },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PROVIDER_MODELS } from "open-sse/config/providerModels.js";
|
||||
import { AI_PROVIDERS, ALIAS_TO_ID } from "@/shared/constants/providers";
|
||||
import { getModelKind } from "@/shared/constants/models";
|
||||
import { isDeletedModelReference } from "@/lib/db";
|
||||
|
||||
const KIND_ENDPOINT = {
|
||||
llm: "/v1/chat/completions",
|
||||
@@ -93,6 +94,12 @@ export async function GET(request) {
|
||||
{ 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);
|
||||
if (!info) {
|
||||
return Response.json(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@/shared/constants/providers";
|
||||
import { getApiKeyByKey, getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb";
|
||||
import { getDisabledModels } from "@/lib/disabledModelsDb";
|
||||
import { getDeletedModels } from "@/lib/db";
|
||||
import { resolveKiroModels } from "open-sse/services/kiroModels.js";
|
||||
import { resolveKimchiModels } from "open-sse/services/kimchiModels.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);
|
||||
|
||||
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();
|
||||
for (const conn of connections) {
|
||||
if (!activeConnectionByProvider.has(conn.provider)) {
|
||||
@@ -296,7 +311,7 @@ export async function buildModelsList(kindFilter, ownerOrOptions = {}) {
|
||||
if (!providerMatchesKinds(providerId, kindFilter)) continue;
|
||||
for (const model of providerModels) {
|
||||
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({
|
||||
id: `${alias}/${model.id}`,
|
||||
object: "model",
|
||||
@@ -314,6 +329,7 @@ export async function buildModelsList(kindFilter, ownerOrOptions = {}) {
|
||||
|
||||
const modelId = String(customModel.id).trim();
|
||||
if (!modelId) continue;
|
||||
if (isDeleted(modelId, providerAlias)) continue;
|
||||
|
||||
models.push({
|
||||
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)
|
||||
const allowAsLlm = kind === "imageToText" && kindFilter.includes(LLM_KIND);
|
||||
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 = {
|
||||
id: `${outputAlias}/${modelId}`,
|
||||
|
||||
+17
-1
@@ -83,6 +83,11 @@ export {
|
||||
getDisabledModels, getDisabledByProvider, disableModels, enableModels,
|
||||
} from "./repos/disabledModelsRepo.js";
|
||||
|
||||
// Permanently deleted models
|
||||
export {
|
||||
getDeletedModels, isDeletedModel, isDeletedModelReference, deleteModelPermanently,
|
||||
} from "./repos/deletedModelsRepo.js";
|
||||
|
||||
// Usage
|
||||
export {
|
||||
statsEmitter, trackPendingRequest, getActiveRequests,
|
||||
@@ -93,6 +98,7 @@ export {
|
||||
// Request details
|
||||
export {
|
||||
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
|
||||
purgeRequestDetailBuffer,
|
||||
} from "./repos/requestDetailsRepo.js";
|
||||
|
||||
// Export/import full DB
|
||||
@@ -115,6 +121,7 @@ export async function exportDb() {
|
||||
mitmAlias: {},
|
||||
pricing: {},
|
||||
disabledModels: {},
|
||||
deletedModels: {},
|
||||
};
|
||||
|
||||
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 = 'pricing'`)) out.pricing[r.key] = parseJson(r.value);
|
||||
for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'disabledModels'`)) out.disabledModels[r.key] = parseJson(r.value, []);
|
||||
for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'deletedModels'`)) out.deletedModels[r.key] = parseJson(r.value, []);
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -154,7 +162,7 @@ export async function importDb(payload) {
|
||||
db.run(`DELETE FROM proxyPools`);
|
||||
db.run(`DELETE FROM apiKeys`);
|
||||
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
|
||||
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)])]);
|
||||
}
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { getProviderAlias, getProviderByAlias } from "@/shared/constants/providers";
|
||||
import { getAdapter } from "../driver.js";
|
||||
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
|
||||
import { invalidatePricingCache } from "./pricingRepo.js";
|
||||
|
||||
const SCOPE = "deletedModels";
|
||||
|
||||
function normalizeIds(value) {
|
||||
return Array.isArray(value) ? value.filter((id) => typeof id === "string" && id) : [];
|
||||
}
|
||||
|
||||
function getRowsChanged(result) {
|
||||
return Number(result?.changes || 0);
|
||||
}
|
||||
|
||||
function getProviderAliasesSync(db, providerAlias) {
|
||||
const aliases = new Set([providerAlias]);
|
||||
const provider = getProviderByAlias(providerAlias);
|
||||
if (provider?.id) aliases.add(provider.id);
|
||||
if (provider?.alias) aliases.add(provider.alias);
|
||||
|
||||
const nodeRows = db.all(`SELECT id, data FROM providerNodes`);
|
||||
for (const node of nodeRows) {
|
||||
const nodeData = parseJson(node.data, {}) || {};
|
||||
if (node.id === providerAlias || nodeData.prefix === providerAlias || aliases.has(node.id)) {
|
||||
aliases.add(node.id);
|
||||
if (nodeData.prefix) aliases.add(nodeData.prefix);
|
||||
}
|
||||
}
|
||||
|
||||
for (const alias of [...aliases]) {
|
||||
aliases.add(getProviderAlias(alias) || alias);
|
||||
}
|
||||
|
||||
return [...aliases].filter(Boolean);
|
||||
}
|
||||
|
||||
function modelWhereClause(column, modelId) {
|
||||
return {
|
||||
sql: `(${column} = ? OR (substr(${column}, 1, length(?)) = ? AND substr(${column}, -1) = ?))`,
|
||||
params: [modelId, `${modelId}(`, `${modelId}(`, ")"],
|
||||
};
|
||||
}
|
||||
|
||||
function removeModelFromKvScopesSync(db, providerAliases, modelId) {
|
||||
const aliases = new Set(providerAliases);
|
||||
const result = { aliases: 0, customModels: 0, pricing: 0, disabledModels: 0 };
|
||||
|
||||
const modelAliasRows = db.all(`SELECT key, value FROM kv WHERE scope = 'modelAliases'`);
|
||||
for (const row of modelAliasRows) {
|
||||
const fullModel = parseJson(row.value, null);
|
||||
if (!matchesDeletedModelReference(fullModel, providerAliases, modelId)) continue;
|
||||
result.aliases += getRowsChanged(db.run(`DELETE FROM kv WHERE scope = 'modelAliases' AND key = ?`, [row.key]));
|
||||
}
|
||||
|
||||
const customRows = db.all(`SELECT key, value FROM kv WHERE scope = 'customModels'`);
|
||||
for (const row of customRows) {
|
||||
const customModel = parseJson(row.value, {}) || {};
|
||||
if (!aliases.has(customModel.providerAlias) || !matchesDeletedModelId(customModel.id, modelId)) continue;
|
||||
result.customModels += getRowsChanged(db.run(`DELETE FROM kv WHERE scope = 'customModels' AND key = ?`, [row.key]));
|
||||
}
|
||||
|
||||
const pricingRows = db.all(`SELECT key, value FROM kv WHERE scope = 'pricing'`);
|
||||
for (const row of pricingRows) {
|
||||
if (!aliases.has(row.key)) continue;
|
||||
const current = parseJson(row.value, {}) || {};
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(current).filter(([storedModelId]) => !matchesDeletedModelId(storedModelId, modelId)),
|
||||
);
|
||||
if (Object.keys(next).length === Object.keys(current).length) continue;
|
||||
result.pricing += Object.keys(current).length - Object.keys(next).length;
|
||||
if (Object.keys(next).length === 0) {
|
||||
db.run(`DELETE FROM kv WHERE scope = 'pricing' AND key = ?`, [row.key]);
|
||||
} else {
|
||||
db.run(`UPDATE kv SET value = ? WHERE scope = 'pricing' AND key = ?`, [stringifyJson(next), row.key]);
|
||||
}
|
||||
}
|
||||
|
||||
const disabledRows = db.all(`SELECT key, value FROM kv WHERE scope = 'disabledModels'`);
|
||||
for (const row of disabledRows) {
|
||||
if (!aliases.has(row.key)) continue;
|
||||
const current = normalizeIds(parseJson(row.value, []));
|
||||
const next = current.filter((storedModelId) => !matchesDeletedModelId(storedModelId, modelId));
|
||||
result.disabledModels += current.length - next.length;
|
||||
if (next.length === current.length) continue;
|
||||
if (next.length === 0) {
|
||||
db.run(`DELETE FROM kv WHERE scope = 'disabledModels' AND key = ?`, [row.key]);
|
||||
} else {
|
||||
db.run(`UPDATE kv SET value = ? WHERE scope = 'disabledModels' AND key = ?`, [stringifyJson(next), row.key]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function cleanCombosSync(db, providerAliases, modelId) {
|
||||
const aliasRows = db.all(`SELECT key, value FROM kv WHERE scope = 'modelAliases'`);
|
||||
const aliases = Object.fromEntries(aliasRows.map((row) => [row.key, parseJson(row.value, null)]));
|
||||
const updatedComboIds = [];
|
||||
const deletedComboIds = [];
|
||||
const comboRows = db.all(`SELECT id, models FROM combos`);
|
||||
|
||||
for (const row of comboRows) {
|
||||
const parsedModels = parseJson(row.models, []);
|
||||
const models = Array.isArray(parsedModels) ? parsedModels : [];
|
||||
const keptModels = models.filter((reference) => {
|
||||
const resolvedReference = typeof reference === "string" ? aliases[reference] : null;
|
||||
return !matchesDeletedModelReference(reference, providerAliases, modelId)
|
||||
&& !matchesDeletedModelReference(resolvedReference, providerAliases, modelId);
|
||||
});
|
||||
if (keptModels.length === models.length) continue;
|
||||
|
||||
if (keptModels.length === 0) {
|
||||
db.run(`DELETE FROM combos WHERE id = ?`, [row.id]);
|
||||
deletedComboIds.push(row.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
db.run(
|
||||
`UPDATE combos SET models = ?, updatedAt = ? WHERE id = ?`,
|
||||
[stringifyJson(keptModels), new Date().toISOString(), row.id],
|
||||
);
|
||||
updatedComboIds.push(row.id);
|
||||
}
|
||||
|
||||
const settingsRow = db.get(`SELECT data FROM settings WHERE id = 1`);
|
||||
const settings = settingsRow ? (parseJson(settingsRow.data, {}) || {}) : {};
|
||||
const comboStrategies = { ...(settings.comboStrategies || {}) };
|
||||
let settingsChanged = false;
|
||||
|
||||
for (const comboId of deletedComboIds) {
|
||||
if (comboStrategies[comboId] === undefined) continue;
|
||||
delete comboStrategies[comboId];
|
||||
settingsChanged = true;
|
||||
}
|
||||
|
||||
for (const [comboId, strategy] of Object.entries(comboStrategies)) {
|
||||
const resolvedJudgeModel = typeof strategy?.judgeModel === "string"
|
||||
? aliases[strategy.judgeModel]
|
||||
: null;
|
||||
if (
|
||||
!strategy
|
||||
|| (
|
||||
!matchesDeletedModelReference(strategy.judgeModel, providerAliases, modelId)
|
||||
&& !matchesDeletedModelReference(resolvedJudgeModel, providerAliases, modelId)
|
||||
)
|
||||
) continue;
|
||||
const { judgeModel, ...nextStrategy } = strategy;
|
||||
if (Object.keys(nextStrategy).length === 0) delete comboStrategies[comboId];
|
||||
else comboStrategies[comboId] = nextStrategy;
|
||||
if (!updatedComboIds.includes(comboId)) updatedComboIds.push(comboId);
|
||||
settingsChanged = true;
|
||||
}
|
||||
|
||||
if (settingsChanged) {
|
||||
db.run(
|
||||
`INSERT INTO settings(id, data) VALUES(1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
|
||||
[stringifyJson({ ...settings, comboStrategies })],
|
||||
);
|
||||
}
|
||||
|
||||
return { updatedComboIds, deletedComboIds };
|
||||
}
|
||||
|
||||
function isDeletedReference(reference, modelAliases, providerAliases, modelId) {
|
||||
const resolvedReference = typeof reference === "string" ? modelAliases[reference] : null;
|
||||
return matchesDeletedModelReference(reference, providerAliases, modelId)
|
||||
|| matchesDeletedModelReference(resolvedReference, providerAliases, modelId);
|
||||
}
|
||||
|
||||
function cleanCliToolConfigsSync(db, providerAliases, modelId) {
|
||||
const aliasRows = db.all(`SELECT key, value FROM kv WHERE scope = 'modelAliases'`);
|
||||
const modelAliases = Object.fromEntries(aliasRows.map((row) => [row.key, parseJson(row.value, null)]));
|
||||
const configRows = db.all(`SELECT ownerId, toolId, data FROM cliToolConfigs`);
|
||||
let updatedConfigs = 0;
|
||||
|
||||
for (const row of configRows) {
|
||||
const config = parseJson(row.data, {}) || {};
|
||||
let changed = false;
|
||||
const deleted = (reference) => isDeletedReference(reference, modelAliases, providerAliases, modelId);
|
||||
|
||||
if (config.claudeModels && typeof config.claudeModels === "object") {
|
||||
const claudeModels = { ...config.claudeModels };
|
||||
const claudeThinking = { ...(config.claudeThinking || {}) };
|
||||
for (const [slot, reference] of Object.entries(claudeModels)) {
|
||||
if (!deleted(reference)) continue;
|
||||
claudeModels[slot] = "";
|
||||
delete claudeThinking[slot];
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
config.claudeModels = claudeModels;
|
||||
config.claudeThinking = claudeThinking;
|
||||
}
|
||||
}
|
||||
|
||||
if (deleted(config.codexModel)) {
|
||||
config.codexModel = "";
|
||||
config.codexThinking = "";
|
||||
changed = true;
|
||||
}
|
||||
|
||||
for (const field of ["opencodeModels", "selectedModels"]) {
|
||||
if (!Array.isArray(config[field])) continue;
|
||||
const removedModels = config[field].filter(deleted);
|
||||
if (removedModels.length === 0) continue;
|
||||
config[field] = config[field].filter((reference) => !deleted(reference));
|
||||
for (const mapField of ["coworkThinking", "copilotThinking", "copilotTokens"]) {
|
||||
if (!config[mapField] || typeof config[mapField] !== "object") continue;
|
||||
const nextMap = { ...config[mapField] };
|
||||
for (const reference of removedModels) delete nextMap[reference];
|
||||
config[mapField] = nextMap;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (deleted(config.opencodeDefaultModel)) {
|
||||
config.opencodeDefaultModel = config.opencodeModels?.[0] || "";
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) continue;
|
||||
db.run(
|
||||
`UPDATE cliToolConfigs SET data = ?, updatedAt = ? WHERE ownerId = ? AND toolId = ?`,
|
||||
[stringifyJson(config), new Date().toISOString(), row.ownerId, row.toolId],
|
||||
);
|
||||
updatedConfigs += 1;
|
||||
}
|
||||
|
||||
return updatedConfigs;
|
||||
}
|
||||
|
||||
export function matchesDeletedModelId(candidate, modelId) {
|
||||
if (typeof candidate !== "string" || typeof modelId !== "string") return false;
|
||||
if (candidate === modelId) return true;
|
||||
return candidate.startsWith(`${modelId}(`) && candidate.endsWith(")");
|
||||
}
|
||||
|
||||
export function matchesDeletedModelReference(reference, providerAliases, modelId) {
|
||||
if (typeof reference !== "string" || !Array.isArray(providerAliases)) return false;
|
||||
|
||||
return providerAliases.some((providerAlias) => {
|
||||
if (typeof providerAlias !== "string" || !providerAlias) return false;
|
||||
const prefix = `${providerAlias}/`;
|
||||
return reference.startsWith(prefix)
|
||||
&& matchesDeletedModelId(reference.slice(prefix.length), modelId);
|
||||
});
|
||||
}
|
||||
|
||||
export function matchesDeletedModelUsage(provider, model, providerAliases, modelId) {
|
||||
return Array.isArray(providerAliases)
|
||||
&& providerAliases.includes(provider)
|
||||
&& matchesDeletedModelId(model, modelId);
|
||||
}
|
||||
|
||||
export function isDeletedModelSync(db, provider, model) {
|
||||
if (!db || !provider || !model) return false;
|
||||
|
||||
const providerAliases = getProviderAliasesSync(db, provider);
|
||||
|
||||
for (const alias of providerAliases) {
|
||||
const row = db.get(`SELECT value FROM kv WHERE scope = ? AND key = ?`, [SCOPE, alias]);
|
||||
const modelIds = normalizeIds(row ? parseJson(row.value, []) : []);
|
||||
if (modelIds.some((modelId) => matchesDeletedModelId(model, modelId))) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function markDeletedModelSync(db, providerAlias, modelId) {
|
||||
if (!db || !providerAlias || !modelId) return false;
|
||||
|
||||
const row = db.get(`SELECT value FROM kv WHERE scope = ? AND key = ?`, [SCOPE, providerAlias]);
|
||||
const current = normalizeIds(row ? parseJson(row.value, []) : []);
|
||||
if (current.includes(modelId)) return false;
|
||||
|
||||
db.run(
|
||||
`INSERT INTO kv(scope, key, value) VALUES(?, ?, ?) ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value`,
|
||||
[SCOPE, providerAlias, stringifyJson([...current, modelId])],
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getDeletedModels() {
|
||||
const db = await getAdapter();
|
||||
const rows = db.all(`SELECT key, value FROM kv WHERE scope = ?`, [SCOPE]);
|
||||
const deleted = {};
|
||||
for (const row of rows) deleted[row.key] = normalizeIds(parseJson(row.value, []));
|
||||
return deleted;
|
||||
}
|
||||
|
||||
export async function isDeletedModel(provider, model) {
|
||||
const db = await getAdapter();
|
||||
return isDeletedModelSync(db, provider, model);
|
||||
}
|
||||
|
||||
export async function isDeletedModelReference(reference) {
|
||||
if (typeof reference !== "string") return false;
|
||||
const separatorIndex = reference.indexOf("/");
|
||||
if (separatorIndex <= 0 || separatorIndex === reference.length - 1) return false;
|
||||
|
||||
const providerAlias = reference.slice(0, separatorIndex);
|
||||
const modelId = reference.slice(separatorIndex + 1);
|
||||
const db = await getAdapter();
|
||||
const providerAliases = getProviderAliasesSync(db, providerAlias);
|
||||
|
||||
return providerAliases.some((alias) => {
|
||||
const row = db.get(`SELECT value FROM kv WHERE scope = ? AND key = ?`, [SCOPE, alias]);
|
||||
const deletedModels = normalizeIds(row ? parseJson(row.value, []) : []);
|
||||
return deletedModels.some((deletedModelId) => matchesDeletedModelId(modelId, deletedModelId));
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteModelPermanently(providerAlias, modelId) {
|
||||
if (!providerAlias || !modelId) return null;
|
||||
|
||||
const db = await getAdapter();
|
||||
let result = null;
|
||||
db.transaction(() => {
|
||||
const providerAliases = getProviderAliasesSync(db, providerAlias);
|
||||
const storageAlias = getProviderAlias(providerAlias) || providerAlias;
|
||||
const deleted = markDeletedModelSync(db, storageAlias, modelId);
|
||||
const combos = cleanCombosSync(db, providerAliases, modelId);
|
||||
const updatedCliToolConfigs = cleanCliToolConfigsSync(db, providerAliases, modelId);
|
||||
const kv = removeModelFromKvScopesSync(db, providerAliases, modelId);
|
||||
const where = modelWhereClause("model", modelId);
|
||||
const providerPlaceholders = providerAliases.map(() => "?").join(", ");
|
||||
const requestDetails = getRowsChanged(db.run(
|
||||
`DELETE FROM requestDetails WHERE provider IN (${providerPlaceholders}) AND ${where.sql}`,
|
||||
[...providerAliases, ...where.params],
|
||||
));
|
||||
|
||||
result = {
|
||||
deleted,
|
||||
providerAliases,
|
||||
modelId,
|
||||
removedAliases: kv.aliases,
|
||||
removedCustomModels: kv.customModels,
|
||||
removedPricingEntries: kv.pricing,
|
||||
removedDisabledModels: kv.disabledModels,
|
||||
updatedComboIds: combos.updatedComboIds,
|
||||
deletedComboIds: combos.deletedComboIds,
|
||||
updatedCliToolConfigs,
|
||||
removedRequestDetails: requestDetails,
|
||||
};
|
||||
});
|
||||
|
||||
invalidatePricingCache();
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -11,10 +11,28 @@ function invalidate() {
|
||||
cache = { value: null, expiresAt: 0 };
|
||||
}
|
||||
|
||||
export function invalidatePricingCache() {
|
||||
invalidate();
|
||||
}
|
||||
|
||||
async function getUserPricing() {
|
||||
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() {
|
||||
const now = Date.now();
|
||||
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 };
|
||||
return merged;
|
||||
const filtered = await removeDeletedModelsFromPricing(merged);
|
||||
cache = { value: filtered, expiresAt: now + CACHE_TTL_MS };
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export async function getPricingForModel(provider, model) {
|
||||
if (!model) return null;
|
||||
const { isDeletedModel } = await import("./deletedModelsRepo.js");
|
||||
if (await isDeletedModel(provider, model)) return null;
|
||||
const userPricing = await getUserPricing();
|
||||
if (provider && userPricing[provider]?.[model]) return userPricing[provider][model];
|
||||
const { getPricingForModel: resolveConst } = await import("open-sse/providers/pricing.js");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getAdapter } from "../driver.js";
|
||||
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
|
||||
import { appendUsageAccessClause, getUsageAccessScope } from "./usageAccessScope.js";
|
||||
import { isDeletedModelSync, matchesDeletedModelUsage } from "./deletedModelsRepo.js";
|
||||
|
||||
const DEFAULT_MAX_RECORDS = 200;
|
||||
const DEFAULT_BATCH_SIZE = 20;
|
||||
@@ -37,6 +38,13 @@ let writeBuffer = [];
|
||||
let flushTimer = null;
|
||||
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) {
|
||||
if (!headers || typeof headers !== "object") return {};
|
||||
const sensitiveKeys = ["authorization", "x-api-key", "cookie", "token", "api-key"];
|
||||
@@ -75,6 +83,7 @@ async function flushToDatabase() {
|
||||
|
||||
db.transaction(() => {
|
||||
for (const item of items) {
|
||||
if (isDeletedModelSync(db, item.provider, item.model)) continue;
|
||||
if (!item.id) item.id = generateDetailId(item.model);
|
||||
if (!item.timestamp) item.timestamp = new Date().toISOString();
|
||||
if (item.request?.headers) item.request.headers = sanitizeHeaders(item.request.headers);
|
||||
|
||||
+1
-1
@@ -3,5 +3,5 @@ export {
|
||||
statsEmitter, trackPendingRequest, getActiveRequests,
|
||||
saveRequestUsage, getUsageHistory, getUsageStats, getChartData,
|
||||
appendRequestLog, getRecentLogs,
|
||||
saveRequestDetail, getRequestDetails, getRequestDetailById,
|
||||
saveRequestDetail, getRequestDetails, getRequestDetailById, purgeRequestDetailBuffer,
|
||||
} from "@/lib/db/index.js";
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function ModelSelectModal({
|
||||
const [providerNodes, setProviderNodes] = useState([]);
|
||||
const [customModels, setCustomModels] = useState([]);
|
||||
const [disabledModels, setDisabledModels] = useState({});
|
||||
const [deletedModels, setDeletedModels] = useState({});
|
||||
|
||||
const fetchCombos = async () => {
|
||||
try {
|
||||
@@ -103,13 +104,21 @@ export default function ModelSelectModal({
|
||||
|
||||
const fetchDisabledModels = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/disabled");
|
||||
if (!res.ok) throw new Error(`Failed to fetch disabled models: ${res.status}`);
|
||||
const data = await res.json();
|
||||
setDisabledModels(data.disabled || {});
|
||||
const [disabledRes, deletedRes] = await Promise.all([
|
||||
fetch("/api/models/disabled"),
|
||||
fetch("/api/models/delete"),
|
||||
]);
|
||||
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) {
|
||||
console.error("Error fetching disabled models:", error);
|
||||
setDisabledModels({});
|
||||
setDeletedModels({});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -377,13 +386,22 @@ export default function ModelSelectModal({
|
||||
...(disabledModels[aliasKey] || []),
|
||||
...(disabledModels[providerId] || []),
|
||||
]);
|
||||
if (disabledIds.size === 0) return;
|
||||
group.models = group.models.filter((m) => !disabledIds.has(m.id));
|
||||
const deletedIds = new Set([
|
||||
...(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];
|
||||
});
|
||||
|
||||
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)
|
||||
const filteredCombos = useMemo(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getDisabledModels } from "@/lib/disabledModelsDb";
|
||||
import { getDeletedModels } from "@/lib/db";
|
||||
import { getProviderAlias } from "@/shared/constants/providers";
|
||||
import { errorResponse } from "open-sse/utils/error.js";
|
||||
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
||||
@@ -14,7 +15,7 @@ import { stripThinkingSuffix } from "open-sse/translator/concerns/thinkingUnifie
|
||||
*/
|
||||
export async function getDisabledModelResponse(provider, model) {
|
||||
try {
|
||||
const disabledModels = await getDisabledModels();
|
||||
const [disabledModels, deletedModels] = await Promise.all([getDisabledModels(), getDeletedModels()]);
|
||||
const providerAlias = getProviderAlias(provider) || provider;
|
||||
// Thinking variants use a client-facing suffix, e.g. `gpt-5.6-sol(high)`,
|
||||
// but dispatch to the base upstream model. Evaluate the disabled policy
|
||||
@@ -24,12 +25,23 @@ export async function getDisabledModelResponse(provider, model) {
|
||||
...(disabledModels[providerAlias] || []),
|
||||
...(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(
|
||||
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) {
|
||||
console.log("Error checking disabled model status:", error);
|
||||
|
||||
@@ -5,6 +5,7 @@ const getProviderConnections = vi.fn();
|
||||
const getCustomModels = vi.fn();
|
||||
const getProviderNodes = vi.fn();
|
||||
const getUsers = vi.fn();
|
||||
const getDeletedModels = vi.fn();
|
||||
const getDisabledModels = vi.fn();
|
||||
const requireUsageDashboardUser = vi.fn();
|
||||
const getCapabilitiesForModel = vi.fn();
|
||||
@@ -16,23 +17,30 @@ vi.mock("@/models", () => ({
|
||||
getProviderNodes,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({ getUsers }));
|
||||
vi.mock("@/lib/db", () => ({ getUsers, getDeletedModels }));
|
||||
|
||||
vi.mock("@/lib/disabledModelsDb", () => ({ getDisabledModels }));
|
||||
vi.mock("@/lib/auth/currentUser", () => ({
|
||||
requireUsageDashboardUser,
|
||||
}));
|
||||
vi.mock("@/shared/constants/models", () => ({
|
||||
AI_MODELS: [
|
||||
{ provider: "alpha", model: "enabled", name: "Enabled model" },
|
||||
{ provider: "alpha", model: "disabled", name: "Disabled model" },
|
||||
{ provider: "beta", model: "inactive", name: "Inactive provider model" },
|
||||
],
|
||||
vi.mock("open-sse/config/providerModels.js", () => ({
|
||||
getModelsByProviderId: (providerId) => ({
|
||||
alpha: [
|
||||
{ id: "enabled", name: "Enabled 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", () => {
|
||||
const providers = {
|
||||
alpha: { id: "alpha", alias: "alpha-alias", name: "Alpha", color: "#111111" },
|
||||
beta: { id: "beta", alias: "beta-alias", name: "Beta", color: "#222222" },
|
||||
"orbit-provider": { id: "orbit-provider", alias: "orbit", name: "Orbit Provider", color: "#8B5CF6" },
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -54,12 +62,14 @@ describe("GET /api/models/connected", () => {
|
||||
getCustomModels.mockReset();
|
||||
getProviderNodes.mockReset();
|
||||
getUsers.mockReset();
|
||||
getDeletedModels.mockReset();
|
||||
getDisabledModels.mockReset();
|
||||
requireUsageDashboardUser.mockReset();
|
||||
getCapabilitiesForModel.mockReset();
|
||||
|
||||
getModelAliases.mockResolvedValue({ "preferred-alpha": "alpha-alias/enabled" });
|
||||
getDisabledModels.mockResolvedValue({ "alpha-alias": ["disabled"] });
|
||||
getDeletedModels.mockResolvedValue({});
|
||||
getCustomModels.mockResolvedValue([
|
||||
{ providerAlias: "alpha-alias", id: "enabled", name: "Enabled 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" });
|
||||
getCustomModels.mockResolvedValue([]);
|
||||
|
||||
@@ -109,7 +119,56 @@ describe("GET /api/models/connected", () => {
|
||||
const body = await response.json();
|
||||
|
||||
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 () => {
|
||||
@@ -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 () => {
|
||||
const providerId = "openai-compatible-user-node";
|
||||
requireUsageDashboardUser.mockResolvedValue({ id: "member-b", role: "user" });
|
||||
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const getCustomModels = vi.fn();
|
||||
const addCustomModel = vi.fn();
|
||||
const deleteCustomModel = vi.fn();
|
||||
const isDeletedModel = vi.fn();
|
||||
const requireAdminUser = vi.fn();
|
||||
|
||||
vi.mock("@/models", () => ({
|
||||
@@ -10,6 +11,7 @@ vi.mock("@/models", () => ({
|
||||
addCustomModel,
|
||||
deleteCustomModel,
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ isDeletedModel }));
|
||||
vi.mock("@/lib/auth/currentUser", () => ({ requireAdminUser }));
|
||||
|
||||
const { GET, POST, DELETE } = await import("../../src/app/api/models/custom/route.js");
|
||||
@@ -19,7 +21,9 @@ describe("/api/models/custom", () => {
|
||||
getCustomModels.mockReset();
|
||||
addCustomModel.mockReset();
|
||||
deleteCustomModel.mockReset();
|
||||
isDeletedModel.mockReset();
|
||||
requireAdminUser.mockReset();
|
||||
isDeletedModel.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
requireAdminUser.mockRejectedValue(new Error("Forbidden"));
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const getDisabledModels = vi.fn();
|
||||
const getDeletedModels = vi.fn();
|
||||
|
||||
vi.mock("@/lib/disabledModelsDb", () => ({ getDisabledModels }));
|
||||
vi.mock("@/lib/db", () => ({ getDeletedModels }));
|
||||
vi.mock("@/shared/constants/providers", () => ({
|
||||
getProviderAlias: (provider) => ({ openai: "oa", claude: "claude" })[provider] || provider,
|
||||
}));
|
||||
@@ -12,6 +14,8 @@ const { getDisabledModelResponse } = await import("../../src/sse/services/disabl
|
||||
describe("getDisabledModelResponse", () => {
|
||||
beforeEach(() => {
|
||||
getDisabledModels.mockReset();
|
||||
getDeletedModels.mockReset();
|
||||
getDeletedModels.mockResolvedValue({});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
getDisabledModels.mockRejectedValue(new Error("database unavailable"));
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const deleteModelPermanently = vi.fn();
|
||||
const getDeletedModels = vi.fn();
|
||||
const purgeRequestDetailBuffer = vi.fn();
|
||||
const requireAdminUser = vi.fn();
|
||||
const resetComboRotation = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
deleteModelPermanently,
|
||||
getDeletedModels,
|
||||
purgeRequestDetailBuffer,
|
||||
}));
|
||||
vi.mock("@/lib/auth/currentUser", () => ({ requireAdminUser }));
|
||||
vi.mock("open-sse/services/combo.js", () => ({ resetComboRotation }));
|
||||
|
||||
const { GET, POST } = await import("../../src/app/api/models/delete/route.js");
|
||||
|
||||
describe("/api/models/delete", () => {
|
||||
beforeEach(() => {
|
||||
deleteModelPermanently.mockReset();
|
||||
getDeletedModels.mockReset();
|
||||
purgeRequestDetailBuffer.mockReset();
|
||||
requireAdminUser.mockReset();
|
||||
resetComboRotation.mockReset();
|
||||
});
|
||||
|
||||
it("rejects non-admin permanent deletion", async () => {
|
||||
requireAdminUser.mockRejectedValue(new Error("Forbidden"));
|
||||
|
||||
const response = await POST(new Request("http://localhost/api/models/delete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ providerAlias: "alpha", modelId: "model-a" }),
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(deleteModelPermanently).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates model deletion input", async () => {
|
||||
requireAdminUser.mockResolvedValue({ id: "admin", role: "admin" });
|
||||
|
||||
const response = await POST(new Request("http://localhost/api/models/delete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ providerAlias: "alpha" }),
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(deleteModelPermanently).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cascades an admin deletion without purging usage history", async () => {
|
||||
requireAdminUser.mockResolvedValue({ id: "admin", role: "admin" });
|
||||
deleteModelPermanently.mockResolvedValue({
|
||||
providerAliases: ["alpha", "alpha-id"],
|
||||
modelId: "model-a",
|
||||
updatedComboIds: ["combo-updated"],
|
||||
deletedComboIds: ["combo-deleted"],
|
||||
});
|
||||
|
||||
const response = await POST(new Request("http://localhost/api/models/delete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ providerAlias: "alpha", modelId: "model-a" }),
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(deleteModelPermanently).toHaveBeenCalledWith("alpha", "model-a");
|
||||
expect(resetComboRotation).toHaveBeenCalledWith("combo-updated");
|
||||
expect(resetComboRotation).toHaveBeenCalledWith("combo-deleted");
|
||||
expect(purgeRequestDetailBuffer).toHaveBeenCalledWith(["alpha", "alpha-id"], "model-a");
|
||||
});
|
||||
|
||||
it("returns permanent-deletion tombstones for catalog readers", async () => {
|
||||
getDeletedModels.mockResolvedValue({ alpha: ["model-a"] });
|
||||
|
||||
const response = await GET(new Request("http://localhost/api/models/delete?providerAlias=alpha"));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({ ids: ["model-a"] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
let tempDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-model-delete-"));
|
||||
process.env.DATA_DIR = tempDir;
|
||||
delete global._dbAdapter;
|
||||
delete global._pendingRequests;
|
||||
delete global._pendingTimers;
|
||||
delete global._recentRing;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { global._dbAdapter?.instance?.close?.(); } catch {}
|
||||
delete global._dbAdapter;
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
});
|
||||
|
||||
describe("permanent model deletion", () => {
|
||||
it("removes dependent catalog, combo, pricing, and observability data while retaining usage history", async () => {
|
||||
const db = await import("@/lib/db/index.js");
|
||||
const providerId = "openai-compatible-cascade-test";
|
||||
const providerPrefix = "cascade";
|
||||
const modelId = "gpt-delete";
|
||||
|
||||
await db.createProviderNode({
|
||||
id: providerId,
|
||||
type: "openai-compatible",
|
||||
name: "Cascade Test Provider",
|
||||
prefix: providerPrefix,
|
||||
baseUrl: "https://example.invalid/v1",
|
||||
});
|
||||
await db.setModelAlias("deleted-alias", `${providerPrefix}/${modelId}`);
|
||||
await db.setModelAlias("keep-alias", `${providerPrefix}/gpt-keep`);
|
||||
await db.addCustomModel({ providerAlias: providerPrefix, id: modelId, type: "llm" });
|
||||
await db.addCustomModel({ providerAlias: providerPrefix, id: "gpt-keep", type: "llm" });
|
||||
await db.disableModels(providerPrefix, [modelId]);
|
||||
await db.updatePricing({
|
||||
[providerPrefix]: {
|
||||
[modelId]: { prompt: 1, completion: 2 },
|
||||
"gpt-keep": { prompt: 3, completion: 4 },
|
||||
},
|
||||
});
|
||||
|
||||
const mixedCombo = await db.createCombo({
|
||||
name: "cascade-mixed",
|
||||
models: [`${providerPrefix}/${modelId}`, `${providerPrefix}/gpt-keep`],
|
||||
});
|
||||
const aliasCombo = await db.createCombo({
|
||||
name: "cascade-alias-only",
|
||||
models: ["deleted-alias"],
|
||||
});
|
||||
await db.updateSettings({
|
||||
comboStrategies: {
|
||||
[mixedCombo.id]: {
|
||||
fallbackStrategy: "fusion",
|
||||
judgeModel: "deleted-alias",
|
||||
},
|
||||
[aliasCombo.id]: { fallbackStrategy: "round-robin" },
|
||||
},
|
||||
});
|
||||
const cliUser = await db.createUser({ username: "cascade-cli-user", password: "password", role: "user" });
|
||||
await db.upsertCliToolConfig(cliUser.id, "codex", {
|
||||
baseUrl: "http://127.0.0.1:20127",
|
||||
codexModel: "deleted-alias",
|
||||
codexThinking: "high",
|
||||
});
|
||||
await db.upsertCliToolConfig(cliUser.id, "cowork", {
|
||||
baseUrl: "http://127.0.0.1:20127",
|
||||
selectedModels: ["deleted-alias", `${providerPrefix}/gpt-keep`],
|
||||
coworkThinking: { "deleted-alias": "high", [`${providerPrefix}/gpt-keep`]: "low" },
|
||||
});
|
||||
|
||||
await db.saveRequestUsage({
|
||||
timestamp: "2026-07-18T10:00:00.000Z",
|
||||
provider: providerId,
|
||||
model: modelId,
|
||||
connectionId: "cascade-connection",
|
||||
tokens: { prompt_tokens: 10, completion_tokens: 5 },
|
||||
endpoint: "/v1/chat/completions",
|
||||
});
|
||||
await db.saveRequestUsage({
|
||||
timestamp: "2026-07-18T10:01:00.000Z",
|
||||
provider: providerPrefix,
|
||||
model: `${modelId}(high)`,
|
||||
connectionId: "cascade-connection",
|
||||
tokens: { prompt_tokens: 20, completion_tokens: 10 },
|
||||
endpoint: "/v1/chat/completions",
|
||||
});
|
||||
await db.saveRequestUsage({
|
||||
timestamp: "2026-07-18T10:02:00.000Z",
|
||||
provider: providerId,
|
||||
model: "gpt-keep",
|
||||
connectionId: "cascade-connection",
|
||||
tokens: { prompt_tokens: 30, completion_tokens: 15 },
|
||||
endpoint: "/v1/chat/completions",
|
||||
});
|
||||
|
||||
await db.updateSettings({ enableObservability: true, observabilityBatchSize: 1 });
|
||||
await db.saveRequestDetail({
|
||||
id: "cascade-deleted-detail",
|
||||
provider: providerPrefix,
|
||||
model: modelId,
|
||||
status: "ok",
|
||||
request: {},
|
||||
response: {},
|
||||
});
|
||||
await db.saveRequestDetail({
|
||||
id: "cascade-keep-detail",
|
||||
provider: providerId,
|
||||
model: "gpt-keep",
|
||||
status: "ok",
|
||||
request: {},
|
||||
response: {},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const result = await db.deleteModelPermanently(providerId, modelId);
|
||||
db.purgeRequestDetailBuffer(result.providerAliases, result.modelId);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
deleted: true,
|
||||
removedAliases: 1,
|
||||
removedCustomModels: 1,
|
||||
removedPricingEntries: 1,
|
||||
removedDisabledModels: 1,
|
||||
removedRequestDetails: 1,
|
||||
updatedCliToolConfigs: 2,
|
||||
updatedComboIds: [mixedCombo.id],
|
||||
deletedComboIds: [aliasCombo.id],
|
||||
});
|
||||
expect(result.providerAliases).toEqual(expect.arrayContaining([providerId, providerPrefix]));
|
||||
expect(await db.isDeletedModel(providerId, modelId)).toBe(true);
|
||||
expect(await db.isDeletedModel(providerId, `${modelId}(high)`)).toBe(true);
|
||||
expect((await db.getDeletedModels())[providerId]).toContain(modelId);
|
||||
|
||||
expect(await db.getModelAliases()).toEqual({ "keep-alias": `${providerPrefix}/gpt-keep` });
|
||||
expect(await db.getCustomModels()).toEqual([
|
||||
expect.objectContaining({ providerAlias: providerPrefix, id: "gpt-keep" }),
|
||||
]);
|
||||
expect(await db.getDisabledByProvider(providerPrefix)).toEqual([]);
|
||||
expect((await db.getPricing())[providerPrefix]).toEqual({
|
||||
"gpt-keep": { prompt: 3, completion: 4 },
|
||||
});
|
||||
|
||||
expect(await db.getComboById(mixedCombo.id)).toMatchObject({
|
||||
models: [`${providerPrefix}/gpt-keep`],
|
||||
});
|
||||
expect(await db.getComboById(aliasCombo.id)).toBeNull();
|
||||
expect((await db.getSettings()).comboStrategies).toEqual({
|
||||
[mixedCombo.id]: { fallbackStrategy: "fusion" },
|
||||
});
|
||||
expect((await db.getCliToolConfig(cliUser.id, "codex")).config).toMatchObject({
|
||||
codexModel: "",
|
||||
codexThinking: "",
|
||||
});
|
||||
expect((await db.getCliToolConfig(cliUser.id, "cowork")).config).toMatchObject({
|
||||
selectedModels: [`${providerPrefix}/gpt-keep`],
|
||||
coworkThinking: { [`${providerPrefix}/gpt-keep`]: "low" },
|
||||
});
|
||||
|
||||
const history = await db.getUsageHistory({});
|
||||
expect(history).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ provider: providerId, model: modelId }),
|
||||
expect.objectContaining({ provider: providerPrefix, model: `${modelId}(high)` }),
|
||||
expect.objectContaining({ provider: providerId, model: "gpt-keep" }),
|
||||
]));
|
||||
expect(history).toHaveLength(3);
|
||||
const stats = await db.getUsageStats("all");
|
||||
expect(stats.totalRequests).toBe(3);
|
||||
expect(stats.byModel).toMatchObject({
|
||||
[`${modelId} (${providerId})`]: expect.objectContaining({ requests: 1, promptTokens: 10, completionTokens: 5 }),
|
||||
[`${modelId}(high) (${providerPrefix})`]: expect.objectContaining({ requests: 1, promptTokens: 20, completionTokens: 10 }),
|
||||
[`gpt-keep (${providerId})`]: expect.objectContaining({ requests: 1 }),
|
||||
});
|
||||
|
||||
expect(await db.getRequestDetailById("cascade-deleted-detail")).toBeNull();
|
||||
expect(await db.getRequestDetailById("cascade-keep-detail")).toMatchObject({ id: "cascade-keep-detail" });
|
||||
|
||||
await db.saveRequestUsage({
|
||||
timestamp: "2026-07-18T10:03:00.000Z",
|
||||
provider: providerId,
|
||||
model: modelId,
|
||||
tokens: { prompt_tokens: 100, completion_tokens: 100 },
|
||||
});
|
||||
await db.saveRequestDetail({
|
||||
id: "cascade-deleted-detail-after-tombstone",
|
||||
provider: providerId,
|
||||
model: modelId,
|
||||
status: "ok",
|
||||
request: {},
|
||||
response: {},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const historyAfterTombstone = await db.getUsageHistory({});
|
||||
expect(historyAfterTombstone).toHaveLength(4);
|
||||
expect(historyAfterTombstone).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
provider: providerId,
|
||||
model: modelId,
|
||||
tokens: expect.objectContaining({ prompt_tokens: 100, completion_tokens: 100 }),
|
||||
}),
|
||||
]));
|
||||
const statsAfterTombstone = await db.getUsageStats("all");
|
||||
expect(statsAfterTombstone.totalRequests).toBe(4);
|
||||
expect(statsAfterTombstone.byModel[`${modelId} (${providerId})`]).toMatchObject({
|
||||
requests: 2,
|
||||
promptTokens: 110,
|
||||
completionTokens: 105,
|
||||
});
|
||||
expect(await db.getRequestDetailById("cascade-deleted-detail-after-tombstone")).toBeNull();
|
||||
|
||||
const backup = await db.exportDb();
|
||||
expect(backup.deletedModels).toMatchObject({ [providerId]: [modelId] });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user