Files
stack/apps/api/src/workspace-settings/workspace-settings.service.ts
Jason Woltje 0c78923138 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>
2026-01-31 13:15:36 -06:00

188 lines
4.9 KiB
TypeScript

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;
}
}