Files
homarr/packages/api/src/router/test/assistant.spec.ts
T

308 lines
9.8 KiB
TypeScript

// @vitest-environment node
import { describe, expect, test, vi } from "vitest";
import { parse } from "superjson";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { eq } from "@homarr/db";
import {
apps,
assistantConfigurations,
assistantMessages,
assistantThreads,
boards,
integrations,
integrationUserPermissions,
items,
users,
} from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { assistantRouter } from "../assistant";
vi.mock("@homarr/auth", () => ({}));
vi.mock("@homarr/common/server", () => ({
decryptSecret: vi.fn(),
encryptSecret: vi.fn(),
}));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({
error: vi.fn(),
}),
}));
vi.mock("@homarr/core/infrastructure/db/env", () => ({
dbEnv: { DRIVER: "better-sqlite3" },
}));
const adminSession = {
user: {
id: createId(),
permissions: ["admin"],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
const createConfiguredAssistantAsync = async () => {
const db = createDb();
await db.insert(assistantConfigurations).values({
id: "default",
enabled: true,
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
modelDiscoveryPath: "/models",
encryptedApiKey: "encrypted.key",
encryptedHeaders: "encrypted.headers",
modelId: "example/model",
});
return db;
};
describe("assistantRouter.updateConnection", () => {
test("clears the persisted model when the discovery path changes without clearing credentials", async () => {
const db = await createConfiguredAssistantAsync();
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await caller.updateConnection({
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
modelDiscoveryPath: "/catalog",
clearApiKey: false,
clearCustomHeaders: false,
});
const [configuration] = await db.select().from(assistantConfigurations);
expect(configuration).toMatchObject({
enabled: false,
modelDiscoveryPath: "/catalog",
modelId: null,
encryptedApiKey: "encrypted.key",
encryptedHeaders: "encrypted.headers",
});
});
test("preserves the persisted model when the saved connection is unchanged", async () => {
const db = await createConfiguredAssistantAsync();
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await caller.updateConnection({
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
modelDiscoveryPath: "/models",
clearApiKey: false,
clearCustomHeaders: false,
});
const [configuration] = await db.select().from(assistantConfigurations);
expect(configuration).toMatchObject({
enabled: true,
modelDiscoveryPath: "/models",
modelId: "example/model",
});
});
});
describe("assistantRouter.updateConfiguration", () => {
test("normalizes legacy model labels to the discovered provider ID", async () => {
const db = await createConfiguredAssistantAsync();
await db
.update(assistantConfigurations)
.set({ encryptedHeaders: null })
.where(eq(assistantConfigurations.id, "default"));
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json({
data: [
{
id: "deepseek/deepseek-v4-pro",
name: "DeepSeek: DeepSeek V4 Pro",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
],
}),
),
);
try {
await caller.updateConfiguration({
enabled: true,
modelId: "DeepSeek: DeepSeek V4 Pro (deepseek/deepseek-v4-pro)",
});
} finally {
vi.unstubAllGlobals();
}
const [configuration] = await db.select().from(assistantConfigurations);
expect(configuration?.modelId).toBe("deepseek/deepseek-v4-pro");
});
});
describe("assistant conversation features", () => {
test("returns safe runtime model options to signed-in users", async () => {
const db = await createConfiguredAssistantAsync();
await db
.update(assistantConfigurations)
.set({
baseUrl: "https://models.example/v1",
encryptedHeaders: null,
updatedAt: new Date("2026-07-30T14:00:00.000Z"),
})
.where(eq(assistantConfigurations.id, "default"));
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json({
data: [
{
id: "example/model",
name: "Example Model",
supported_parameters: ["tools"],
architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] },
},
{
id: "example/no-tools",
name: "No tools",
supported_parameters: [],
architecture: { output_modalities: ["text"] },
},
{
id: "example/unnamed",
name: "",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
],
}),
),
);
try {
const options = await caller.getRuntimeOptions();
expect(options).toEqual({
provider: "openrouter",
defaultModelId: "example/model",
models: [
expect.objectContaining({
id: "example/model",
name: "Example Model",
inputModalities: ["text", "image"],
}),
expect.objectContaining({
id: "example/unnamed",
name: "example/unnamed",
}),
],
});
expect(JSON.stringify(options)).not.toContain("encrypted");
} finally {
vi.unstubAllGlobals();
}
});
test("returns permission-checked entities for composer mentions", async () => {
const db = await createConfiguredAssistantAsync();
await db.insert(users).values({ id: adminSession.user.id });
await db.insert(apps).values({ id: "app-1", name: "Plex", iconUrl: "https://example.com/plex.svg" });
await db.insert(boards).values({ id: "board-1", name: "Home", isPublic: true });
await db.insert(items).values({ id: "widget-1", boardId: "board-1", kind: "clock" });
await db.insert(integrations).values({
id: "integration-1",
name: "Media server",
kind: "plex",
url: "https://example.com",
});
await db.insert(integrationUserPermissions).values({
integrationId: "integration-1",
userId: adminSession.user.id,
permission: "use",
});
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const entities = await caller.getContextEntities();
expect(entities).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "app-1", type: "app", label: "Plex" }),
expect.objectContaining({ id: "board-1", type: "board", label: "Home" }),
expect.objectContaining({ id: "widget-1", type: "widget", boardId: "board-1" }),
expect.objectContaining({ id: "integration-1", type: "integration", label: "Media server" }),
]),
);
});
test("persists a discovered model choice on the owned conversation", async () => {
const db = await createConfiguredAssistantAsync();
await db.insert(users).values({ id: adminSession.user.id });
await db
.update(assistantConfigurations)
.set({
baseUrl: "https://thread-models.example/v1",
encryptedHeaders: null,
updatedAt: new Date("2026-07-30T15:00:00.000Z"),
})
.where(eq(assistantConfigurations.id, "default"));
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const thread = await caller.createThread();
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json({
data: [
{
id: "example/model",
name: "Default",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
{
id: "example/reasoning",
name: "Reasoning",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
],
}),
),
);
try {
await caller.updateThreadModel({ threadId: thread.id, modelId: "example/reasoning" });
} finally {
vi.unstubAllGlobals();
}
const storedThread = await db.query.assistantThreads.findFirst({
where: eq(assistantThreads.id, thread.id),
});
expect(storedThread?.modelId).toBe("example/reasoning");
});
test("persists feedback on an owned assistant message", async () => {
const db = await createConfiguredAssistantAsync();
await db.insert(users).values({ id: adminSession.user.id });
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const thread = await caller.createThread();
await caller.appendMessage({
threadId: thread.id,
id: "message-1",
parentId: null,
format: "ai-sdk/v6",
content: { role: "assistant", parts: [{ type: "text", text: "Hello" }], metadata: { custom: {} } },
});
await caller.submitFeedback({ threadId: thread.id, messageId: "message-1", type: "positive" });
const [message] = await db.select().from(assistantMessages);
expect(message).toBeDefined();
expect(parse(message?.content ?? "")).toMatchObject({
metadata: { submittedFeedback: { type: "positive" } },
});
});
});