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:
@@ -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")
|
||||
}
|
||||
|
||||
1
apps/api/src/workspace-settings/dto/index.ts
Normal file
1
apps/api/src/workspace-settings/dto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { UpdateWorkspaceSettingsDto } from "./update-workspace-settings.dto";
|
||||
@@ -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>;
|
||||
}
|
||||
4
apps/api/src/workspace-settings/index.ts
Normal file
4
apps/api/src/workspace-settings/index.ts
Normal 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";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/workspace-settings/workspace-settings.module.ts
Normal file
12
apps/api/src/workspace-settings/workspace-settings.module.ts
Normal 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 {}
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
187
apps/api/src/workspace-settings/workspace-settings.service.ts
Normal file
187
apps/api/src/workspace-settings/workspace-settings.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user