Files
stack/apps/api/src/llm/llm-manager.service.ts
T
jason.woltjeandClaude Sonnet 4.5 be6c15116d feat(#126): create LLM Manager Service
Implemented centralized service for managing multiple LLM provider instances.

Architecture:
- LlmManagerService manages provider lifecycle and selection
- Loads provider instances from Prisma database on startup
- Maintains in-memory registry of active providers
- Factory pattern for provider instantiation

Core Features:
- Database integration via PrismaService
- Provider initialization on module startup (OnModuleInit)
- Get provider by ID
- Get all active providers
- Get system default provider
- Get user-specific provider with fallback to system default
- Health check all registered providers
- Dynamic registration/unregistration (hot reload)
- Reload from database without restart

Provider Selection Logic:
- User-level providers: userId matches, is enabled
- System-level providers: userId is NULL, is enabled
- Fallback: system default if no user provider found
- Graceful error handling with detailed logging

Integration:
- Added to LlmModule providers and exports
- Uses PrismaService for database queries
- Factory creates OllamaProvider from config
- Extensible for future providers (Claude, OpenAI)

Testing:
- 31 comprehensive unit tests
- 93.05% code coverage (exceeds 85% requirement)
- All error scenarios covered
- Proper mocking of dependencies

Quality Gates:
-  All 31 tests passing
-  93.05% coverage
-  Linting clean
-  Type checking passed
-  Code review approved

Fixes #126

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-01-31 12:22:14 -06:00

310 lines
9.5 KiB
TypeScript

import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import type { LlmProviderInstance } from "@prisma/client";
import type {
LlmProviderInterface,
LlmProviderHealthStatus,
} from "./providers/llm-provider.interface";
import { OllamaProvider, type OllamaProviderConfig } from "./providers/ollama.provider";
/**
* Provider information returned by getAllProviders
*/
export interface ProviderInfo {
id: string;
name: string;
provider: string;
endpoint?: string;
userId?: string | null;
isDefault: boolean;
}
/**
* Provider health status with instance ID
*/
export interface ProviderHealthInfo extends LlmProviderHealthStatus {
id: string;
}
/**
* LLM Manager Service
*
* Manages multiple LLM provider instances and routes requests.
* Supports hot reload, provider selection, and health monitoring.
*
* @example
* ```typescript
* // Get default provider
* const provider = await llmManager.getDefaultProvider();
*
* // Get user-specific provider
* const userProvider = await llmManager.getUserProvider("user-123");
*
* // Register new provider dynamically
* await llmManager.registerProvider(providerInstance);
*
* // Reload from database
* await llmManager.reloadFromDatabase();
* ```
*/
@Injectable()
export class LlmManagerService implements OnModuleInit {
private readonly logger = new Logger(LlmManagerService.name);
private readonly providers = new Map<string, LlmProviderInterface>();
private readonly instanceMetadata = new Map<string, LlmProviderInstance>();
constructor(private readonly prisma: PrismaService) {}
/**
* Initialize the service by loading all enabled providers from the database.
* Called automatically by NestJS during module initialization.
*
* @throws {Error} If database connection fails
*/
async onModuleInit(): Promise<void> {
this.logger.log("Initializing LLM Manager Service...");
try {
await this.loadProvidersFromDatabase();
this.logger.log(`Loaded ${String(this.providers.size)} provider(s)`);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to initialize LLM Manager: ${errorMessage}`);
throw new Error(`Failed to load providers from database: ${errorMessage}`);
}
}
/**
* Register a new provider instance or update an existing one.
* Supports hot reload - no restart required.
*
* @param instance - Provider instance from database
* @throws {Error} If provider type is unknown or initialization fails
*/
async registerProvider(instance: LlmProviderInstance): Promise<void> {
try {
this.logger.log(`Registering provider: ${instance.displayName} (${instance.id})`);
// Create provider instance based on type
const provider = this.createProvider(instance);
// Initialize the provider
await provider.initialize();
// Store in registry
this.providers.set(instance.id, provider);
this.instanceMetadata.set(instance.id, instance);
this.logger.log(`Provider registered successfully: ${instance.displayName}`);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to register provider ${instance.id}: ${errorMessage}`);
throw error;
}
}
/**
* Unregister a provider instance from the registry.
* Supports hot reload - no restart required.
*
* @param instanceId - Provider instance ID
*/
// eslint-disable-next-line @typescript-eslint/require-await
async unregisterProvider(instanceId: string): Promise<void> {
if (this.providers.has(instanceId)) {
this.logger.log(`Unregistering provider: ${instanceId}`);
this.providers.delete(instanceId);
this.instanceMetadata.delete(instanceId);
}
}
/**
* Get a provider by its instance ID.
*
* @param instanceId - Provider instance ID
* @returns Provider interface
* @throws {Error} If provider not found
*/
// eslint-disable-next-line @typescript-eslint/require-await
async getProviderById(instanceId: string): Promise<LlmProviderInterface> {
const provider = this.providers.get(instanceId);
if (!provider) {
throw new Error(`Provider with ID ${instanceId} not found`);
}
return provider;
}
/**
* Get all active provider instances.
*
* @returns Array of provider information
*/
// eslint-disable-next-line @typescript-eslint/require-await
async getAllProviders(): Promise<ProviderInfo[]> {
const providers: ProviderInfo[] = [];
for (const [id, provider] of this.providers.entries()) {
const metadata = this.instanceMetadata.get(id);
const config = provider.getConfig();
providers.push({
id,
name: metadata?.displayName ?? provider.name,
provider: provider.type,
endpoint: config.endpoint,
userId: metadata?.userId ?? null,
isDefault: metadata?.isDefault ?? false,
});
}
return providers;
}
/**
* Get the default system-level provider.
* Default provider is identified by isDefault=true and userId=null.
*
* @returns Default provider interface
* @throws {Error} If no default provider configured
*/
// eslint-disable-next-line @typescript-eslint/require-await
async getDefaultProvider(): Promise<LlmProviderInterface> {
// Find default system-level provider (userId = null, isDefault = true)
for (const [id, metadata] of this.instanceMetadata.entries()) {
if (metadata.isDefault && metadata.userId === null) {
const provider = this.providers.get(id);
if (provider) {
return provider;
}
}
}
throw new Error("No default provider configured");
}
/**
* Get a provider for a specific user.
* Prioritizes user-level providers over system default.
*
* Selection logic:
* 1. User-specific provider (userId matches)
* 2. System default provider (userId = null, isDefault = true)
*
* @param userId - User ID
* @returns Provider interface
* @throws {Error} If no provider available for user
*/
async getUserProvider(userId: string): Promise<LlmProviderInterface> {
// First, try to find user-specific provider
for (const [id, metadata] of this.instanceMetadata.entries()) {
if (metadata.userId === userId) {
const provider = this.providers.get(id);
if (provider) {
return provider;
}
}
}
// Fall back to default provider
try {
return await this.getDefaultProvider();
} catch {
throw new Error(`No provider available for user ${userId}`);
}
}
/**
* Check health of all registered providers.
*
* @returns Array of health status information
*/
async checkAllProvidersHealth(): Promise<ProviderHealthInfo[]> {
const healthStatuses: ProviderHealthInfo[] = [];
for (const [id, provider] of this.providers.entries()) {
try {
const health = await provider.checkHealth();
healthStatuses.push({ id, ...health });
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.warn(`Health check failed for provider ${id}: ${errorMessage}`);
// Include failed health check in results
healthStatuses.push({
id,
healthy: false,
provider: provider.type,
error: errorMessage,
});
}
}
return healthStatuses;
}
/**
* Reload all providers from the database.
* Clears existing providers and loads fresh state from database.
* Supports hot reload - no restart required.
*
* @throws {Error} If database connection fails
*/
async reloadFromDatabase(): Promise<void> {
this.logger.log("Reloading providers from database...");
// Clear existing providers
this.providers.clear();
this.instanceMetadata.clear();
// Reload from database
await this.loadProvidersFromDatabase();
this.logger.log(`Reloaded ${String(this.providers.size)} provider(s)`);
}
/**
* Load all enabled providers from the database.
* Private helper method called during initialization and reload.
*
* @throws {Error} If database query fails
*/
private async loadProvidersFromDatabase(): Promise<void> {
const instances = await this.prisma.llmProviderInstance.findMany({
where: { isEnabled: true },
});
// Register each provider instance
for (const instance of instances) {
try {
await this.registerProvider(instance);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.warn(`Skipping provider ${instance.id} due to error: ${errorMessage}`);
}
}
}
/**
* Create a provider instance based on provider type.
* Factory method for instantiating provider implementations.
*
* @param instance - Provider instance from database
* @returns Provider interface implementation
* @throws {Error} If provider type is unknown
*/
private createProvider(instance: LlmProviderInstance): LlmProviderInterface {
switch (instance.providerType) {
case "ollama":
return new OllamaProvider(instance.config as OllamaProviderConfig);
// Future providers:
// case "claude":
// return new ClaudeProvider(instance.config);
// case "openai":
// return new OpenAIProvider(instance.config);
default:
throw new Error(`Unknown provider type: ${instance.providerType}`);
}
}
}