mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
fix: update the logic for add model from admin side
This commit is contained in:
@@ -4,7 +4,7 @@ import {
|
|||||||
addCustomModel,
|
addCustomModel,
|
||||||
deleteCustomModel,
|
deleteCustomModel,
|
||||||
} from "@/models";
|
} from "@/models";
|
||||||
import { isDeletedModel } from "@/lib/db";
|
import { restoreDeletedModel } from "@/lib/db";
|
||||||
import { requireAdminUser } from "@/lib/auth/currentUser";
|
import { requireAdminUser } from "@/lib/auth/currentUser";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -45,11 +45,12 @@ export async function POST(request) {
|
|||||||
return NextResponse.json({ error: "providerAlias and id required" }, { status: 400 });
|
return NextResponse.json({ error: "providerAlias and id required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
await requireCustomModelCatalogAdmin();
|
await requireCustomModelCatalogAdmin();
|
||||||
if (await isDeletedModel(providerAlias, id)) {
|
// Adding a model is an explicit administrator restore action. Older
|
||||||
return NextResponse.json({ error: "This model was permanently deleted" }, { status: 409 });
|
// 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 });
|
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) {
|
} catch (error) {
|
||||||
const accessError = getAccessErrorResponse(error);
|
const accessError = getAccessErrorResponse(error);
|
||||||
if (accessError) return accessError;
|
if (accessError) return accessError;
|
||||||
|
|||||||
+1
-1
@@ -81,7 +81,7 @@ export {
|
|||||||
|
|
||||||
// Permanently deleted models
|
// Permanently deleted models
|
||||||
export {
|
export {
|
||||||
getDeletedModels, isDeletedModel, isDeletedModelReference, deleteModelPermanently,
|
getDeletedModels, isDeletedModel, isDeletedModelReference, restoreDeletedModel, deleteModelPermanently,
|
||||||
} from "./repos/deletedModelsRepo.js";
|
} from "./repos/deletedModelsRepo.js";
|
||||||
|
|
||||||
// Usage
|
// Usage
|
||||||
|
|||||||
@@ -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) {
|
export async function deleteModelPermanently(providerAlias, modelId) {
|
||||||
if (!providerAlias || !modelId) return null;
|
if (!providerAlias || !modelId) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
const getCustomModels = vi.fn();
|
const getCustomModels = vi.fn();
|
||||||
const addCustomModel = vi.fn();
|
const addCustomModel = vi.fn();
|
||||||
const deleteCustomModel = vi.fn();
|
const deleteCustomModel = vi.fn();
|
||||||
const isDeletedModel = vi.fn();
|
const restoreDeletedModel = vi.fn();
|
||||||
const requireAdminUser = vi.fn();
|
const requireAdminUser = vi.fn();
|
||||||
|
|
||||||
vi.mock("@/models", () => ({
|
vi.mock("@/models", () => ({
|
||||||
@@ -11,7 +11,7 @@ vi.mock("@/models", () => ({
|
|||||||
addCustomModel,
|
addCustomModel,
|
||||||
deleteCustomModel,
|
deleteCustomModel,
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/db", () => ({ isDeletedModel }));
|
vi.mock("@/lib/db", () => ({ restoreDeletedModel }));
|
||||||
vi.mock("@/lib/auth/currentUser", () => ({ requireAdminUser }));
|
vi.mock("@/lib/auth/currentUser", () => ({ requireAdminUser }));
|
||||||
|
|
||||||
const { GET, POST, DELETE } = await import("../../src/app/api/models/custom/route.js");
|
const { GET, POST, DELETE } = await import("../../src/app/api/models/custom/route.js");
|
||||||
@@ -21,9 +21,9 @@ describe("/api/models/custom", () => {
|
|||||||
getCustomModels.mockReset();
|
getCustomModels.mockReset();
|
||||||
addCustomModel.mockReset();
|
addCustomModel.mockReset();
|
||||||
deleteCustomModel.mockReset();
|
deleteCustomModel.mockReset();
|
||||||
isDeletedModel.mockReset();
|
restoreDeletedModel.mockReset();
|
||||||
requireAdminUser.mockReset();
|
requireAdminUser.mockReset();
|
||||||
isDeletedModel.mockResolvedValue(false);
|
restoreDeletedModel.mockResolvedValue(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the shared catalog readable to authenticated model selectors", async () => {
|
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" });
|
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", {
|
const response = await POST(new Request("http://localhost/api/models/custom", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ providerAlias: "openai", id: "gpt-deleted", type: "llm" }),
|
body: JSON.stringify({ providerAlias: "openai", id: "gpt-deleted", type: "llm" }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect(response.status).toBe(409);
|
expect(response.status).toBe(200);
|
||||||
expect(addCustomModel).not.toHaveBeenCalled();
|
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 () => {
|
it("rejects a non-admin deleting a shared custom model", async () => {
|
||||||
|
|||||||
@@ -218,5 +218,10 @@ describe("permanent model deletion", () => {
|
|||||||
|
|
||||||
const backup = await db.exportDb();
|
const backup = await db.exportDb();
|
||||||
expect(backup.deletedModels).toMatchObject({ [providerId]: [modelId] });
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user