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:
2026-01-31 13:15:36 -06:00
parent b8805cee50
commit 0c78923138
9 changed files with 959 additions and 3 deletions

View File

@@ -214,6 +214,7 @@ model Workspace {
knowledgeTags KnowledgeTag[]
cronSchedules CronSchedule[]
personalities Personality[]
llmSettings WorkspaceLlmSettings?
@@index([ownerId])
@@map("workspaces")
@@ -942,7 +943,8 @@ model Personality {
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
// Relations
llmProviderInstance LlmProviderInstance? @relation("PersonalityLlmProvider", fields: [llmProviderInstanceId], references: [id], onDelete: SetNull)
llmProviderInstance LlmProviderInstance? @relation("PersonalityLlmProvider", fields: [llmProviderInstanceId], references: [id], onDelete: SetNull)
workspaceLlmSettings WorkspaceLlmSettings[] @relation("WorkspacePersonality")
@@unique([id, workspaceId])
@@unique([workspaceId, name])
@@ -969,8 +971,9 @@ model LlmProviderInstance {
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
// Relations
user User? @relation("UserLlmProviders", fields: [userId], references: [id], onDelete: Cascade)
personalities Personality[] @relation("PersonalityLlmProvider")
user User? @relation("UserLlmProviders", fields: [userId], references: [id], onDelete: Cascade)
personalities Personality[] @relation("PersonalityLlmProvider")
workspaceLlmSettings WorkspaceLlmSettings[] @relation("WorkspaceLlmProvider")
@@index([userId])
@@index([providerType])
@@ -978,3 +981,25 @@ model LlmProviderInstance {
@@index([isEnabled])
@@map("llm_provider_instances")
}
// ============================================
// WORKSPACE LLM SETTINGS
// ============================================
model WorkspaceLlmSettings {
id String @id @default(uuid()) @db.Uuid
workspaceId String @unique @map("workspace_id") @db.Uuid
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
defaultLlmProviderId String? @map("default_llm_provider_id") @db.Uuid
defaultLlmProvider LlmProviderInstance? @relation("WorkspaceLlmProvider", fields: [defaultLlmProviderId], references: [id], onDelete: SetNull)
defaultPersonalityId String? @map("default_personality_id") @db.Uuid
defaultPersonality Personality? @relation("WorkspacePersonality", fields: [defaultPersonalityId], references: [id], onDelete: SetNull)
settings Json? @default("{}")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
@@index([workspaceId])
@@index([defaultLlmProviderId])
@@index([defaultPersonalityId])
@@map("workspace_llm_settings")
}

View File

@@ -0,0 +1 @@
export { UpdateWorkspaceSettingsDto } from "./update-workspace-settings.dto";

View File

@@ -0,0 +1,19 @@
import { IsOptional, IsUUID, IsObject } from "class-validator";
/**
* DTO for updating workspace LLM settings
* All fields are optional to support partial updates
*/
export class UpdateWorkspaceSettingsDto {
@IsOptional()
@IsUUID("4", { message: "defaultLlmProviderId must be a valid UUID" })
defaultLlmProviderId?: string | null;
@IsOptional()
@IsUUID("4", { message: "defaultPersonalityId must be a valid UUID" })
defaultPersonalityId?: string | null;
@IsOptional()
@IsObject({ message: "settings must be an object" })
settings?: Record<string, unknown>;
}

View File

@@ -0,0 +1,4 @@
export { WorkspaceSettingsModule } from "./workspace-settings.module";
export { WorkspaceSettingsService } from "./workspace-settings.service";
export { WorkspaceSettingsController } from "./workspace-settings.controller";
export * from "./dto";

View File

@@ -0,0 +1,268 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { ExecutionContext } from "@nestjs/common";
import { WorkspaceSettingsController } from "./workspace-settings.controller";
import { WorkspaceSettingsService } from "./workspace-settings.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import type { WorkspaceLlmSettings, LlmProviderInstance, Personality } from "@prisma/client";
import type { AuthenticatedRequest } from "../common/types/user.types";
describe("WorkspaceSettingsController", () => {
let controller: WorkspaceSettingsController;
let service: WorkspaceSettingsService;
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 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(),
};
const mockAuthGuard = {
canActivate: vi.fn((context: ExecutionContext) => {
const request = context.switchToHttp().getRequest();
request.user = {
id: mockUserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
image: null,
authProviderId: null,
preferences: {},
createdAt: new Date(),
updatedAt: new Date(),
};
return true;
}),
};
const mockAuthRequest: AuthenticatedRequest = {
user: {
id: mockUserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
image: null,
authProviderId: null,
preferences: {},
createdAt: new Date(),
updatedAt: new Date(),
},
} as AuthenticatedRequest;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [WorkspaceSettingsController],
providers: [
{
provide: WorkspaceSettingsService,
useValue: {
getSettings: vi.fn(),
updateSettings: vi.fn(),
getEffectiveLlmProvider: vi.fn(),
getEffectivePersonality: vi.fn(),
},
},
],
})
.overrideGuard(AuthGuard)
.useValue(mockAuthGuard)
.compile();
controller = module.get<WorkspaceSettingsController>(WorkspaceSettingsController);
service = module.get<WorkspaceSettingsService>(WorkspaceSettingsService);
});
afterEach(() => {
vi.clearAllMocks();
});
describe("getSettings", () => {
it("should return workspace settings", async () => {
vi.spyOn(service, "getSettings").mockResolvedValue(mockSettings);
const result = await controller.getSettings(mockWorkspaceId);
expect(result).toEqual(mockSettings);
expect(service.getSettings).toHaveBeenCalledWith(mockWorkspaceId);
});
it("should handle service errors", async () => {
vi.spyOn(service, "getSettings").mockRejectedValue(new Error("Service error"));
await expect(controller.getSettings(mockWorkspaceId)).rejects.toThrow("Service error");
});
it("should work with valid workspace ID", async () => {
vi.spyOn(service, "getSettings").mockResolvedValue(mockSettings);
const result = await controller.getSettings(mockWorkspaceId);
expect(result.workspaceId).toBe(mockWorkspaceId);
});
});
describe("updateSettings", () => {
it("should update workspace settings", async () => {
const updateDto = {
defaultLlmProviderId: "new-provider-123",
defaultPersonalityId: "new-personality-123",
};
const updatedSettings = { ...mockSettings, ...updateDto };
vi.spyOn(service, "updateSettings").mockResolvedValue(updatedSettings);
const result = await controller.updateSettings(mockWorkspaceId, updateDto);
expect(result).toEqual(updatedSettings);
expect(service.updateSettings).toHaveBeenCalledWith(mockWorkspaceId, updateDto);
});
it("should allow partial updates", async () => {
const updateDto = {
defaultLlmProviderId: "new-provider-123",
};
const updatedSettings = {
...mockSettings,
defaultLlmProviderId: updateDto.defaultLlmProviderId,
};
vi.spyOn(service, "updateSettings").mockResolvedValue(updatedSettings);
const result = await controller.updateSettings(mockWorkspaceId, updateDto);
expect(result.defaultLlmProviderId).toBe(updateDto.defaultLlmProviderId);
});
it("should handle null values", async () => {
const updateDto = {
defaultLlmProviderId: null,
};
const updatedSettings = { ...mockSettings, defaultLlmProviderId: null };
vi.spyOn(service, "updateSettings").mockResolvedValue(updatedSettings);
const result = await controller.updateSettings(mockWorkspaceId, updateDto);
expect(result.defaultLlmProviderId).toBeNull();
});
it("should handle service errors", async () => {
const updateDto = { defaultLlmProviderId: "invalid-id" };
vi.spyOn(service, "updateSettings").mockRejectedValue(new Error("Provider not found"));
await expect(controller.updateSettings(mockWorkspaceId, updateDto)).rejects.toThrow(
"Provider not found"
);
});
});
describe("getEffectiveProvider", () => {
it("should return effective provider with authenticated user", async () => {
vi.spyOn(service, "getEffectiveLlmProvider").mockResolvedValue(mockProvider);
const result = await controller.getEffectiveProvider(mockWorkspaceId, mockAuthRequest);
expect(result).toEqual(mockProvider);
expect(service.getEffectiveLlmProvider).toHaveBeenCalledWith(mockWorkspaceId, mockUserId);
});
it("should return effective provider without user ID when not authenticated", async () => {
const unauthRequest = { user: undefined } as AuthenticatedRequest;
vi.spyOn(service, "getEffectiveLlmProvider").mockResolvedValue(mockProvider);
const result = await controller.getEffectiveProvider(mockWorkspaceId, unauthRequest);
expect(result).toEqual(mockProvider);
expect(service.getEffectiveLlmProvider).toHaveBeenCalledWith(mockWorkspaceId, undefined);
});
it("should handle no provider available error", async () => {
vi.spyOn(service, "getEffectiveLlmProvider").mockRejectedValue(
new Error("No LLM provider available")
);
await expect(
controller.getEffectiveProvider(mockWorkspaceId, mockAuthRequest)
).rejects.toThrow("No LLM provider available");
});
it("should pass user ID to service when available", async () => {
vi.spyOn(service, "getEffectiveLlmProvider").mockResolvedValue(mockProvider);
await controller.getEffectiveProvider(mockWorkspaceId, mockAuthRequest);
expect(service.getEffectiveLlmProvider).toHaveBeenCalledWith(mockWorkspaceId, mockUserId);
});
});
describe("getEffectivePersonality", () => {
it("should return effective personality", async () => {
vi.spyOn(service, "getEffectivePersonality").mockResolvedValue(mockPersonality);
const result = await controller.getEffectivePersonality(mockWorkspaceId);
expect(result).toEqual(mockPersonality);
expect(service.getEffectivePersonality).toHaveBeenCalledWith(mockWorkspaceId);
});
it("should handle no personality available error", async () => {
vi.spyOn(service, "getEffectivePersonality").mockRejectedValue(
new Error("No personality available")
);
await expect(controller.getEffectivePersonality(mockWorkspaceId)).rejects.toThrow(
"No personality available"
);
});
it("should work with valid workspace ID", async () => {
vi.spyOn(service, "getEffectivePersonality").mockResolvedValue(mockPersonality);
const result = await controller.getEffectivePersonality(mockWorkspaceId);
expect(result.workspaceId).toBe(mockWorkspaceId);
});
});
describe("endpoint paths", () => {
it("should be accessible at /workspaces/:workspaceId/settings/llm", () => {
const metadata = Reflect.getMetadata("path", WorkspaceSettingsController);
expect(metadata).toBe("workspaces/:workspaceId/settings/llm");
});
});
});

View File

@@ -0,0 +1,58 @@
import { Controller, Get, Patch, Body, Param, Request, UseGuards } from "@nestjs/common";
import { WorkspaceSettingsService } from "./workspace-settings.service";
import { UpdateWorkspaceSettingsDto } from "./dto";
import { AuthGuard } from "../auth/guards/auth.guard";
import type { AuthenticatedRequest } from "../common/types/user.types";
/**
* Controller for workspace LLM settings endpoints
* All endpoints require authentication
*/
@Controller("workspaces/:workspaceId/settings/llm")
@UseGuards(AuthGuard)
export class WorkspaceSettingsController {
constructor(private readonly workspaceSettingsService: WorkspaceSettingsService) {}
/**
* GET /api/workspaces/:workspaceId/settings/llm
* Get workspace LLM settings
*/
@Get()
async getSettings(@Param("workspaceId") workspaceId: string) {
return this.workspaceSettingsService.getSettings(workspaceId);
}
/**
* PATCH /api/workspaces/:workspaceId/settings/llm
* Update workspace LLM settings
*/
@Patch()
async updateSettings(
@Param("workspaceId") workspaceId: string,
@Body() dto: UpdateWorkspaceSettingsDto
) {
return this.workspaceSettingsService.updateSettings(workspaceId, dto);
}
/**
* GET /api/workspaces/:workspaceId/settings/llm/effective-provider
* Get effective LLM provider for workspace
*/
@Get("effective-provider")
async getEffectiveProvider(
@Param("workspaceId") workspaceId: string,
@Request() req: AuthenticatedRequest
) {
const userId = req.user?.id;
return this.workspaceSettingsService.getEffectiveLlmProvider(workspaceId, userId);
}
/**
* GET /api/workspaces/:workspaceId/settings/llm/effective-personality
* Get effective personality for workspace
*/
@Get("effective-personality")
async getEffectivePersonality(@Param("workspaceId") workspaceId: string) {
return this.workspaceSettingsService.getEffectivePersonality(workspaceId);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { WorkspaceSettingsController } from "./workspace-settings.controller";
import { WorkspaceSettingsService } from "./workspace-settings.service";
import { PrismaModule } from "../prisma/prisma.module";
@Module({
imports: [PrismaModule],
controllers: [WorkspaceSettingsController],
providers: [WorkspaceSettingsService],
exports: [WorkspaceSettingsService],
})
export class WorkspaceSettingsModule {}

View File

@@ -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 },
});
});
});
});

View File

@@ -0,0 +1,187 @@
import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import type { WorkspaceLlmSettings, LlmProviderInstance, Personality } from "@prisma/client";
import type { UpdateWorkspaceSettingsDto } from "./dto";
/**
* Service for managing workspace LLM settings
* Handles configuration hierarchy: workspace > user > system
*/
@Injectable()
export class WorkspaceSettingsService {
constructor(private readonly prisma: PrismaService) {}
/**
* Get settings for a workspace (creates default if not exists)
*
* @param workspaceId - Workspace ID
* @returns Workspace LLM settings
*/
async getSettings(workspaceId: string): Promise<WorkspaceLlmSettings> {
let settings = await this.prisma.workspaceLlmSettings.findUnique({
where: { workspaceId },
});
// Create default settings if they don't exist
settings ??= await this.prisma.workspaceLlmSettings.create({
data: {
workspaceId,
settings: {} as unknown as Prisma.InputJsonValue,
},
});
return settings;
}
/**
* Update workspace LLM settings
*
* @param workspaceId - Workspace ID
* @param dto - Update data
* @returns Updated settings
*/
async updateSettings(
workspaceId: string,
dto: UpdateWorkspaceSettingsDto
): Promise<WorkspaceLlmSettings> {
const data: Prisma.WorkspaceLlmSettingsUncheckedUpdateInput = {};
if (dto.defaultLlmProviderId !== undefined) {
data.defaultLlmProviderId = dto.defaultLlmProviderId;
}
if (dto.defaultPersonalityId !== undefined) {
data.defaultPersonalityId = dto.defaultPersonalityId;
}
if (dto.settings !== undefined) {
data.settings = dto.settings as unknown as Prisma.InputJsonValue;
}
const settings = await this.prisma.workspaceLlmSettings.update({
where: { workspaceId },
data,
});
return settings;
}
/**
* Get effective LLM provider for a workspace
* Priority: workspace > user > system default
*
* @param workspaceId - Workspace ID
* @param userId - Optional user ID for user-level provider
* @returns Effective LLM provider instance
* @throws {Error} If no provider available
*/
async getEffectiveLlmProvider(
workspaceId: string,
userId?: string
): Promise<LlmProviderInstance> {
// Get workspace settings
const settings = await this.prisma.workspaceLlmSettings.findUnique({
where: { workspaceId },
});
// 1. Check workspace-level provider
if (settings?.defaultLlmProviderId) {
const provider = await this.prisma.llmProviderInstance.findUnique({
where: { id: settings.defaultLlmProviderId },
});
if (!provider) {
throw new Error(`LLM provider ${settings.defaultLlmProviderId} not found`);
}
return provider;
}
// 2. Check user-level provider
if (userId) {
const userProvider = await this.prisma.llmProviderInstance.findFirst({
where: {
userId,
isEnabled: true,
},
});
if (userProvider) {
return userProvider;
}
}
// 3. Fall back to system default
const systemDefault = await this.prisma.llmProviderInstance.findFirst({
where: {
userId: null,
isDefault: true,
isEnabled: true,
},
});
if (!systemDefault) {
throw new Error(`No LLM provider available for workspace ${workspaceId}`);
}
return systemDefault;
}
/**
* Get effective personality for a workspace
* Priority: workspace default > workspace enabled > any enabled
*
* @param workspaceId - Workspace ID
* @returns Effective personality
* @throws {Error} If no personality available
*/
async getEffectivePersonality(workspaceId: string): Promise<Personality> {
// Get workspace settings
const settings = await this.prisma.workspaceLlmSettings.findUnique({
where: { workspaceId },
});
// 1. Check workspace-configured personality
if (settings?.defaultPersonalityId) {
const personality = await this.prisma.personality.findUnique({
where: {
id: settings.defaultPersonalityId,
},
});
if (!personality) {
throw new Error(`Personality ${settings.defaultPersonalityId} not found`);
}
return personality;
}
// 2. Fall back to default personality in workspace
const defaultPersonality = await this.prisma.personality.findFirst({
where: {
workspaceId,
isDefault: true,
isEnabled: true,
},
});
if (defaultPersonality) {
return defaultPersonality;
}
// 3. Fall back to any enabled personality
const anyPersonality = await this.prisma.personality.findFirst({
where: {
workspaceId,
isEnabled: true,
},
});
if (!anyPersonality) {
throw new Error(`No personality available for workspace ${workspaceId}`);
}
return anyPersonality;
}
}