fix: update the logic for remove model in provider

This commit is contained in:
2026-07-19 18:32:14 +07:00
parent fa7cd62d58
commit 62975ab15b
25 changed files with 1209 additions and 172 deletions
+82 -9
View File
@@ -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" });
+17
View File
@@ -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"] });
});
});
+225
View File
@@ -0,0 +1,225 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const originalDataDir = process.env.DATA_DIR;
let tempDir;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-model-delete-"));
process.env.DATA_DIR = tempDir;
delete global._dbAdapter;
delete global._pendingRequests;
delete global._pendingTimers;
delete global._recentRing;
vi.resetModules();
});
afterEach(() => {
try { global._dbAdapter?.instance?.close?.(); } catch {}
delete global._dbAdapter;
fs.rmSync(tempDir, { recursive: true, force: true });
if (originalDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = originalDataDir;
});
describe("permanent model deletion", () => {
it("removes dependent catalog, combo, pricing, and observability data while retaining usage history", async () => {
const db = await import("@/lib/db/index.js");
const providerId = "openai-compatible-cascade-test";
const providerPrefix = "cascade";
const modelId = "gpt-delete";
await db.createProviderNode({
id: providerId,
type: "openai-compatible",
name: "Cascade Test Provider",
prefix: providerPrefix,
baseUrl: "https://example.invalid/v1",
});
await db.setModelAlias("deleted-alias", `${providerPrefix}/${modelId}`);
await db.setModelAlias("keep-alias", `${providerPrefix}/gpt-keep`);
await db.addCustomModel({ providerAlias: providerPrefix, id: modelId, type: "llm" });
await db.addCustomModel({ providerAlias: providerPrefix, id: "gpt-keep", type: "llm" });
await db.disableModels(providerPrefix, [modelId]);
await db.updatePricing({
[providerPrefix]: {
[modelId]: { prompt: 1, completion: 2 },
"gpt-keep": { prompt: 3, completion: 4 },
},
});
const mixedCombo = await db.createCombo({
name: "cascade-mixed",
models: [`${providerPrefix}/${modelId}`, `${providerPrefix}/gpt-keep`],
});
const aliasCombo = await db.createCombo({
name: "cascade-alias-only",
models: ["deleted-alias"],
});
await db.updateSettings({
comboStrategies: {
[mixedCombo.id]: {
fallbackStrategy: "fusion",
judgeModel: "deleted-alias",
},
[aliasCombo.id]: { fallbackStrategy: "round-robin" },
},
});
const cliUser = await db.createUser({ username: "cascade-cli-user", password: "password", role: "user" });
await db.upsertCliToolConfig(cliUser.id, "codex", {
baseUrl: "http://127.0.0.1:20127",
codexModel: "deleted-alias",
codexThinking: "high",
});
await db.upsertCliToolConfig(cliUser.id, "cowork", {
baseUrl: "http://127.0.0.1:20127",
selectedModels: ["deleted-alias", `${providerPrefix}/gpt-keep`],
coworkThinking: { "deleted-alias": "high", [`${providerPrefix}/gpt-keep`]: "low" },
});
await db.saveRequestUsage({
timestamp: "2026-07-18T10:00:00.000Z",
provider: providerId,
model: modelId,
connectionId: "cascade-connection",
tokens: { prompt_tokens: 10, completion_tokens: 5 },
endpoint: "/v1/chat/completions",
});
await db.saveRequestUsage({
timestamp: "2026-07-18T10:01:00.000Z",
provider: providerPrefix,
model: `${modelId}(high)`,
connectionId: "cascade-connection",
tokens: { prompt_tokens: 20, completion_tokens: 10 },
endpoint: "/v1/chat/completions",
});
await db.saveRequestUsage({
timestamp: "2026-07-18T10:02:00.000Z",
provider: providerId,
model: "gpt-keep",
connectionId: "cascade-connection",
tokens: { prompt_tokens: 30, completion_tokens: 15 },
endpoint: "/v1/chat/completions",
});
await db.updateSettings({ enableObservability: true, observabilityBatchSize: 1 });
await db.saveRequestDetail({
id: "cascade-deleted-detail",
provider: providerPrefix,
model: modelId,
status: "ok",
request: {},
response: {},
});
await db.saveRequestDetail({
id: "cascade-keep-detail",
provider: providerId,
model: "gpt-keep",
status: "ok",
request: {},
response: {},
});
await new Promise((resolve) => setTimeout(resolve, 100));
const result = await db.deleteModelPermanently(providerId, modelId);
db.purgeRequestDetailBuffer(result.providerAliases, result.modelId);
expect(result).toMatchObject({
deleted: true,
removedAliases: 1,
removedCustomModels: 1,
removedPricingEntries: 1,
removedDisabledModels: 1,
removedRequestDetails: 1,
updatedCliToolConfigs: 2,
updatedComboIds: [mixedCombo.id],
deletedComboIds: [aliasCombo.id],
});
expect(result.providerAliases).toEqual(expect.arrayContaining([providerId, providerPrefix]));
expect(await db.isDeletedModel(providerId, modelId)).toBe(true);
expect(await db.isDeletedModel(providerId, `${modelId}(high)`)).toBe(true);
expect((await db.getDeletedModels())[providerId]).toContain(modelId);
expect(await db.getModelAliases()).toEqual({ "keep-alias": `${providerPrefix}/gpt-keep` });
expect(await db.getCustomModels()).toEqual([
expect.objectContaining({ providerAlias: providerPrefix, id: "gpt-keep" }),
]);
expect(await db.getDisabledByProvider(providerPrefix)).toEqual([]);
expect((await db.getPricing())[providerPrefix]).toEqual({
"gpt-keep": { prompt: 3, completion: 4 },
});
expect(await db.getComboById(mixedCombo.id)).toMatchObject({
models: [`${providerPrefix}/gpt-keep`],
});
expect(await db.getComboById(aliasCombo.id)).toBeNull();
expect((await db.getSettings()).comboStrategies).toEqual({
[mixedCombo.id]: { fallbackStrategy: "fusion" },
});
expect((await db.getCliToolConfig(cliUser.id, "codex")).config).toMatchObject({
codexModel: "",
codexThinking: "",
});
expect((await db.getCliToolConfig(cliUser.id, "cowork")).config).toMatchObject({
selectedModels: [`${providerPrefix}/gpt-keep`],
coworkThinking: { [`${providerPrefix}/gpt-keep`]: "low" },
});
const history = await db.getUsageHistory({});
expect(history).toEqual(expect.arrayContaining([
expect.objectContaining({ provider: providerId, model: modelId }),
expect.objectContaining({ provider: providerPrefix, model: `${modelId}(high)` }),
expect.objectContaining({ provider: providerId, model: "gpt-keep" }),
]));
expect(history).toHaveLength(3);
const stats = await db.getUsageStats("all");
expect(stats.totalRequests).toBe(3);
expect(stats.byModel).toMatchObject({
[`${modelId} (${providerId})`]: expect.objectContaining({ requests: 1, promptTokens: 10, completionTokens: 5 }),
[`${modelId}(high) (${providerPrefix})`]: expect.objectContaining({ requests: 1, promptTokens: 20, completionTokens: 10 }),
[`gpt-keep (${providerId})`]: expect.objectContaining({ requests: 1 }),
});
expect(await db.getRequestDetailById("cascade-deleted-detail")).toBeNull();
expect(await db.getRequestDetailById("cascade-keep-detail")).toMatchObject({ id: "cascade-keep-detail" });
await db.saveRequestUsage({
timestamp: "2026-07-18T10:03:00.000Z",
provider: providerId,
model: modelId,
tokens: { prompt_tokens: 100, completion_tokens: 100 },
});
await db.saveRequestDetail({
id: "cascade-deleted-detail-after-tombstone",
provider: providerId,
model: modelId,
status: "ok",
request: {},
response: {},
});
await new Promise((resolve) => setTimeout(resolve, 100));
const historyAfterTombstone = await db.getUsageHistory({});
expect(historyAfterTombstone).toHaveLength(4);
expect(historyAfterTombstone).toEqual(expect.arrayContaining([
expect.objectContaining({
provider: providerId,
model: modelId,
tokens: expect.objectContaining({ prompt_tokens: 100, completion_tokens: 100 }),
}),
]));
const statsAfterTombstone = await db.getUsageStats("all");
expect(statsAfterTombstone.totalRequests).toBe(4);
expect(statsAfterTombstone.byModel[`${modelId} (${providerId})`]).toMatchObject({
requests: 2,
promptTokens: 110,
completionTokens: 105,
});
expect(await db.getRequestDetailById("cascade-deleted-detail-after-tombstone")).toBeNull();
const backup = await db.exportDb();
expect(backup.deletedModels).toMatchObject({ [providerId]: [modelId] });
});
});