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:
@@ -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