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