From 06d6873d52a9e95764b4e37a65ec8426497dd78c Mon Sep 17 00:00:00 2001 From: Nezumi-2711 Date: Tue, 21 Jul 2026 17:17:14 +0700 Subject: [PATCH] fix: update the logic for add model from admin side --- src/app/api/models/custom/route.js | 11 ++++---- src/lib/db/index.js | 2 +- src/lib/db/repos/deletedModelsRepo.js | 33 +++++++++++++++++++++++ tests/unit/custom-models-route.test.js | 24 +++++++++++------ tests/unit/permanent-model-delete.test.js | 5 ++++ 5 files changed, 61 insertions(+), 14 deletions(-) diff --git a/src/app/api/models/custom/route.js b/src/app/api/models/custom/route.js index 778c32a1..07b9c04c 100644 --- a/src/app/api/models/custom/route.js +++ b/src/app/api/models/custom/route.js @@ -4,7 +4,7 @@ import { addCustomModel, deleteCustomModel, } from "@/models"; -import { isDeletedModel } from "@/lib/db"; +import { restoreDeletedModel } from "@/lib/db"; import { requireAdminUser } from "@/lib/auth/currentUser"; export const dynamic = "force-dynamic"; @@ -45,11 +45,12 @@ 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 }); - } + // Adding a model is an explicit administrator restore action. Older + // tombstones otherwise reject the request and make the Test action return + // the misleading "deleted by an administrator" response. const added = await addCustomModel({ providerAlias, id, type: type || "llm", name }); - return NextResponse.json({ success: true, added }); + const restored = await restoreDeletedModel(providerAlias, id); + return NextResponse.json({ success: true, added, restored }); } catch (error) { const accessError = getAccessErrorResponse(error); if (accessError) return accessError; diff --git a/src/lib/db/index.js b/src/lib/db/index.js index b6910a14..87296473 100644 --- a/src/lib/db/index.js +++ b/src/lib/db/index.js @@ -81,7 +81,7 @@ export { // Permanently deleted models export { - getDeletedModels, isDeletedModel, isDeletedModelReference, deleteModelPermanently, + getDeletedModels, isDeletedModel, isDeletedModelReference, restoreDeletedModel, deleteModelPermanently, } from "./repos/deletedModelsRepo.js"; // Usage diff --git a/src/lib/db/repos/deletedModelsRepo.js b/src/lib/db/repos/deletedModelsRepo.js index 6d212979..c7e8992f 100644 --- a/src/lib/db/repos/deletedModelsRepo.js +++ b/src/lib/db/repos/deletedModelsRepo.js @@ -302,6 +302,39 @@ export async function isDeletedModelReference(reference) { }); } +/** + * Remove permanent-deletion tombstones for a model that an administrator adds + * back to the shared catalog. This deliberately does not restore dependencies + * that were removed when the model was deleted (combos, aliases, pricing, and + * request details); it only makes the newly added model routable again. + */ +export async function restoreDeletedModel(providerAlias, modelId) { + if (!providerAlias || !modelId) return false; + + const db = await getAdapter(); + let restored = false; + + db.transaction(() => { + const providerAliases = getProviderAliasesSync(db, providerAlias); + + for (const alias of providerAliases) { + const row = db.get(`SELECT value FROM kv WHERE scope = ? AND key = ?`, [SCOPE, alias]); + const current = normalizeIds(row ? parseJson(row.value, []) : []); + const next = current.filter((deletedModelId) => !matchesDeletedModelId(modelId, deletedModelId)); + if (next.length === current.length) continue; + + restored = true; + if (next.length === 0) { + db.run(`DELETE FROM kv WHERE scope = ? AND key = ?`, [SCOPE, alias]); + } else { + db.run(`UPDATE kv SET value = ? WHERE scope = ? AND key = ?`, [stringifyJson(next), SCOPE, alias]); + } + } + }); + + return restored; +} + export async function deleteModelPermanently(providerAlias, modelId) { if (!providerAlias || !modelId) return null; diff --git a/tests/unit/custom-models-route.test.js b/tests/unit/custom-models-route.test.js index 77f85aa0..81f3df49 100644 --- a/tests/unit/custom-models-route.test.js +++ b/tests/unit/custom-models-route.test.js @@ -3,7 +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 restoreDeletedModel = vi.fn(); const requireAdminUser = vi.fn(); vi.mock("@/models", () => ({ @@ -11,7 +11,7 @@ vi.mock("@/models", () => ({ addCustomModel, deleteCustomModel, })); -vi.mock("@/lib/db", () => ({ isDeletedModel })); +vi.mock("@/lib/db", () => ({ restoreDeletedModel })); vi.mock("@/lib/auth/currentUser", () => ({ requireAdminUser })); const { GET, POST, DELETE } = await import("../../src/app/api/models/custom/route.js"); @@ -21,9 +21,9 @@ describe("/api/models/custom", () => { getCustomModels.mockReset(); addCustomModel.mockReset(); deleteCustomModel.mockReset(); - isDeletedModel.mockReset(); + restoreDeletedModel.mockReset(); requireAdminUser.mockReset(); - isDeletedModel.mockResolvedValue(false); + restoreDeletedModel.mockResolvedValue(false); }); it("keeps the shared catalog readable to authenticated model selectors", async () => { @@ -68,17 +68,25 @@ describe("/api/models/custom", () => { }); }); - it("does not let an administrator re-add a permanently deleted model", async () => { + it("restores a permanently deleted model when an administrator adds it again", async () => { requireAdminUser.mockResolvedValue({ id: "admin", role: "admin" }); - isDeletedModel.mockResolvedValue(true); + restoreDeletedModel.mockResolvedValue(true); + addCustomModel.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(); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ success: true, added: true, restored: true }); + expect(restoreDeletedModel).toHaveBeenCalledWith("openai", "gpt-deleted"); + expect(addCustomModel).toHaveBeenCalledWith({ + providerAlias: "openai", + id: "gpt-deleted", + type: "llm", + name: undefined, + }); }); it("rejects a non-admin deleting a shared custom model", async () => { diff --git a/tests/unit/permanent-model-delete.test.js b/tests/unit/permanent-model-delete.test.js index 3fc5729e..f29ce007 100644 --- a/tests/unit/permanent-model-delete.test.js +++ b/tests/unit/permanent-model-delete.test.js @@ -218,5 +218,10 @@ describe("permanent model deletion", () => { const backup = await db.exportDb(); expect(backup.deletedModels).toMatchObject({ [providerId]: [modelId] }); + + expect(await db.restoreDeletedModel(providerPrefix, `${modelId}(high)`)).toBe(true); + expect(await db.isDeletedModel(providerId, modelId)).toBe(false); + expect(await db.isDeletedModel(providerPrefix, `${modelId}(high)`)).toBe(false); + expect((await db.getDeletedModels())[providerId]).toBeUndefined(); }); });