feat(#133): add workspace-scoped LLM configuration
Implement per-workspace LLM provider and personality configuration with proper hierarchy (workspace > user > system fallback). Schema: - Add WorkspaceLlmSettings model with provider/personality FKs - One-to-one relation with Workspace - JSON settings field for extensibility Service: - getSettings: Retrieves/creates workspace settings - updateSettings: Updates with null value support - getEffectiveLlmProvider: Hierarchy-based provider selection - getEffectivePersonality: Hierarchy-based personality selection Endpoints: - GET /workspaces/:id/settings/llm - Get settings - PATCH /workspaces/:id/settings/llm - Update settings - GET /workspaces/:id/settings/llm/effective-provider - GET /workspaces/:id/settings/llm/effective-personality Configuration hierarchy: 1. Workspace-configured provider/personality 2. User-specific provider (for providers) 3. System default fallback Tests: 34 passing with 100% coverage Fixes #133 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { WorkspaceSettingsService } from "./workspace-settings.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import type { WorkspaceLlmSettings, LlmProviderInstance, Personality } from "@prisma/client";
|
||||
|
||||
describe("WorkspaceSettingsService", () => {
|
||||
let service: WorkspaceSettingsService;
|
||||
let prisma: PrismaService;
|
||||
|
||||
const mockWorkspaceId = "workspace-123";
|
||||
const mockUserId = "user-123";
|
||||
|
||||
const mockSettings: WorkspaceLlmSettings = {
|
||||
id: "settings-123",
|
||||
workspaceId: mockWorkspaceId,
|
||||
defaultLlmProviderId: "provider-123",
|
||||
defaultPersonalityId: "personality-123",
|
||||
settings: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockProvider: LlmProviderInstance = {
|
||||
id: "provider-123",
|
||||
providerType: "ollama",
|
||||
displayName: "Test Provider",
|
||||
userId: null,
|
||||
config: { endpoint: "http://localhost:11434" },
|
||||
isDefault: true,
|
||||
isEnabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockUserProvider: LlmProviderInstance = {
|
||||
id: "user-provider-123",
|
||||
providerType: "ollama",
|
||||
displayName: "User Provider",
|
||||
userId: mockUserId,
|
||||
config: { endpoint: "http://user-ollama:11434" },
|
||||
isDefault: false,
|
||||
isEnabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockPersonality: Personality = {
|
||||
id: "personality-123",
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "default",
|
||||
displayName: "Default",
|
||||
description: "Default personality",
|
||||
systemPrompt: "You are a helpful assistant",
|
||||
temperature: null,
|
||||
maxTokens: null,
|
||||
llmProviderInstanceId: null,
|
||||
isDefault: true,
|
||||
isEnabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WorkspaceSettingsService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: {
|
||||
workspaceLlmSettings: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
llmProviderInstance: {
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
personality: {
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WorkspaceSettingsService>(WorkspaceSettingsService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getSettings", () => {
|
||||
it("should return existing settings for workspace", async () => {
|
||||
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(mockSettings);
|
||||
|
||||
const result = await service.getSettings(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockSettings);
|
||||
expect(prisma.workspaceLlmSettings.findUnique).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId },
|
||||
});
|
||||
});
|
||||
|
||||
it("should create default settings if not exists", async () => {
|
||||
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(null);
|
||||
vi.spyOn(prisma.workspaceLlmSettings, "create").mockResolvedValue(mockSettings);
|
||||
|
||||
const result = await service.getSettings(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockSettings);
|
||||
expect(prisma.workspaceLlmSettings.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
settings: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle workspace with no settings gracefully", async () => {
|
||||
const newSettings = {
|
||||
...mockSettings,
|
||||
defaultLlmProviderId: null,
|
||||
defaultPersonalityId: null,
|
||||
};
|
||||
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(null);
|
||||
vi.spyOn(prisma.workspaceLlmSettings, "create").mockResolvedValue(newSettings);
|
||||
|
||||
const result = await service.getSettings(mockWorkspaceId);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.workspaceId).toBe(mockWorkspaceId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSettings", () => {
|
||||
it("should update existing settings", async () => {
|
||||
const updateDto = {
|
||||
defaultLlmProviderId: "new-provider-123",
|
||||
defaultPersonalityId: "new-personality-123",
|
||||
};
|
||||
|
||||
const updatedSettings = { ...mockSettings, ...updateDto };
|
||||
vi.spyOn(prisma.workspaceLlmSettings, "update").mockResolvedValue(updatedSettings);
|
||||
|
||||
const result = await service.updateSettings(mockWorkspaceId, updateDto);
|
||||
|
||||
expect(result).toEqual(updatedSettings);
|
||||
expect(prisma.workspaceLlmSettings.update).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId },
|
||||
data: updateDto,
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow setting provider to null", async () => {
|
||||
const updateDto = {
|
||||
defaultLlmProviderId: null,
|
||||
};
|
||||
|
||||
const updatedSettings = { ...mockSettings, defaultLlmProviderId: null };
|
||||
vi.spyOn(prisma.workspaceLlmSettings, "update").mockResolvedValue(updatedSettings);
|
||||
|
||||
const result = await service.updateSettings(mockWorkspaceId, updateDto);
|
||||
|
||||
expect(result.defaultLlmProviderId).toBeNull();
|
||||
});
|
||||
|
||||
it("should allow setting personality to null", async () => {
|
||||
const updateDto = {
|
||||
defaultPersonalityId: null,
|
||||
};
|
||||
|
||||
const updatedSettings = { ...mockSettings, defaultPersonalityId: null };
|
||||
vi.spyOn(prisma.workspaceLlmSettings, "update").mockResolvedValue(updatedSettings);
|
||||
|
||||
const result = await service.updateSettings(mockWorkspaceId, updateDto);
|
||||
|
||||
expect(result.defaultPersonalityId).toBeNull();
|
||||
});
|
||||
|
||||
it("should update custom settings object", async () => {
|
||||
const updateDto = {
|
||||
settings: { customKey: "customValue" },
|
||||
};
|
||||
|
||||
const updatedSettings = { ...mockSettings, settings: updateDto.settings };
|
||||
vi.spyOn(prisma.workspaceLlmSettings, "update").mockResolvedValue(updatedSettings);
|
||||
|
||||
const result = await service.updateSettings(mockWorkspaceId, updateDto);
|
||||
|
||||
expect(result.settings).toEqual(updateDto.settings);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEffectiveLlmProvider", () => {
|
||||
it("should return workspace provider when set", async () => {
|
||||
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(mockSettings);
|
||||
vi.spyOn(prisma.llmProviderInstance, "findUnique").mockResolvedValue(mockProvider);
|
||||
|
||||
const result = await service.getEffectiveLlmProvider(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockProvider);
|
||||
expect(prisma.llmProviderInstance.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockSettings.defaultLlmProviderId! },
|
||||
});
|
||||
});
|
||||
|
||||
it("should return user provider when workspace provider not set and userId provided", async () => {
|
||||
const settingsWithoutProvider = { ...mockSettings, defaultLlmProviderId: null };
|
||||
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(
|
||||
settingsWithoutProvider
|
||||
);
|
||||
vi.spyOn(prisma.llmProviderInstance, "findFirst")
|
||||
.mockResolvedValueOnce(mockUserProvider)
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
const result = await service.getEffectiveLlmProvider(mockWorkspaceId, mockUserId);
|
||||
|
||||
expect(result).toEqual(mockUserProvider);
|
||||
expect(prisma.llmProviderInstance.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId: mockUserId,
|
||||
isEnabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should fall back to system default when workspace and user providers not set", async () => {
|
||||
const settingsWithoutProvider = { ...mockSettings, defaultLlmProviderId: null };
|
||||
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(
|
||||
settingsWithoutProvider
|
||||
);
|
||||
vi.spyOn(prisma.llmProviderInstance, "findFirst")
|
||||
.mockResolvedValueOnce(null) // No user provider
|
||||
.mockResolvedValueOnce(mockProvider); // System default
|
||||
|
||||
const result = await service.getEffectiveLlmProvider(mockWorkspaceId, mockUserId);
|
||||
|
||||
expect(result).toEqual(mockProvider);
|
||||
expect(prisma.llmProviderInstance.findFirst).toHaveBeenNthCalledWith(2, {
|
||||
where: {
|
||||
userId: null,
|
||||
isDefault: true,
|
||||
isEnabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw error when no provider available", async () => {
|
||||
const settingsWithoutProvider = { ...mockSettings, defaultLlmProviderId: null };
|
||||
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(
|
||||
settingsWithoutProvider
|
||||
);
|
||||
vi.spyOn(prisma.llmProviderInstance, "findFirst").mockResolvedValue(null);
|
||||
|
||||
await expect(service.getEffectiveLlmProvider(mockWorkspaceId)).rejects.toThrow(
|
||||
`No LLM provider available for workspace ${mockWorkspaceId}`
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error when workspace provider is set but not found", async () => {
|
||||
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(mockSettings);
|
||||
vi.spyOn(prisma.llmProviderInstance, "findUnique").mockResolvedValue(null);
|
||||
|
||||
await expect(service.getEffectiveLlmProvider(mockWorkspaceId)).rejects.toThrow(
|
||||
`LLM provider ${mockSettings.defaultLlmProviderId} not found`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEffectivePersonality", () => {
|
||||
it("should return workspace personality when set", async () => {
|
||||
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(mockSettings);
|
||||
vi.spyOn(prisma.personality, "findUnique").mockResolvedValue(mockPersonality);
|
||||
|
||||
const result = await service.getEffectivePersonality(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(prisma.personality.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: mockSettings.defaultPersonalityId!,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should fall back to default personality when workspace personality not set", async () => {
|
||||
const settingsWithoutPersonality = { ...mockSettings, defaultPersonalityId: null };
|
||||
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(
|
||||
settingsWithoutPersonality
|
||||
);
|
||||
vi.spyOn(prisma.personality, "findFirst").mockResolvedValue(mockPersonality);
|
||||
|
||||
const result = await service.getEffectivePersonality(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
isDefault: true,
|
||||
isEnabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should fall back to any enabled personality when no default exists", async () => {
|
||||
const settingsWithoutPersonality = { ...mockSettings, defaultPersonalityId: null };
|
||||
const nonDefaultPersonality = { ...mockPersonality, isDefault: false };
|
||||
|
||||
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(
|
||||
settingsWithoutPersonality
|
||||
);
|
||||
vi.spyOn(prisma.personality, "findFirst")
|
||||
.mockResolvedValueOnce(null) // No default personality
|
||||
.mockResolvedValueOnce(nonDefaultPersonality); // Any enabled personality
|
||||
|
||||
const result = await service.getEffectivePersonality(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(nonDefaultPersonality);
|
||||
expect(prisma.personality.findFirst).toHaveBeenNthCalledWith(2, {
|
||||
where: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
isEnabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw error when no personality available", async () => {
|
||||
const settingsWithoutPersonality = { ...mockSettings, defaultPersonalityId: null };
|
||||
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(
|
||||
settingsWithoutPersonality
|
||||
);
|
||||
vi.spyOn(prisma.personality, "findFirst").mockResolvedValue(null);
|
||||
|
||||
await expect(service.getEffectivePersonality(mockWorkspaceId)).rejects.toThrow(
|
||||
`No personality available for workspace ${mockWorkspaceId}`
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error when workspace personality is set but not found", async () => {
|
||||
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(mockSettings);
|
||||
vi.spyOn(prisma.personality, "findUnique").mockResolvedValue(null);
|
||||
|
||||
await expect(service.getEffectivePersonality(mockWorkspaceId)).rejects.toThrow(
|
||||
`Personality ${mockSettings.defaultPersonalityId} not found`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace isolation", () => {
|
||||
it("should only access settings for specified workspace", async () => {
|
||||
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(mockSettings);
|
||||
|
||||
await service.getSettings(mockWorkspaceId);
|
||||
|
||||
expect(prisma.workspaceLlmSettings.findUnique).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId },
|
||||
});
|
||||
});
|
||||
|
||||
it("should not allow cross-workspace settings access", async () => {
|
||||
const otherWorkspaceId = "other-workspace-123";
|
||||
|
||||
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(null);
|
||||
|
||||
const result1 = await service.getSettings(mockWorkspaceId);
|
||||
const result2 = await service.getSettings(otherWorkspaceId);
|
||||
|
||||
// Each workspace should have separate calls
|
||||
expect(prisma.workspaceLlmSettings.findUnique).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.workspaceLlmSettings.findUnique).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId },
|
||||
});
|
||||
expect(prisma.workspaceLlmSettings.findUnique).toHaveBeenCalledWith({
|
||||
where: { workspaceId: otherWorkspaceId },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user