Notes/apps/server/src/services/llm/config/configuration_manager.ts
2025-06-07 23:57:35 +00:00

310 lines
9.9 KiB
TypeScript

import options from '../../options.js';
import log from '../../log.js';
import type {
AIConfig,
ProviderPrecedenceConfig,
ModelIdentifier,
ModelConfig,
ProviderType,
ConfigValidationResult,
ProviderSettings,
OpenAISettings,
AnthropicSettings,
OllamaSettings
} from '../interfaces/configuration_interfaces.js';
/**
* Configuration manager that handles conversion from string-based options
* to proper typed configuration objects.
*
* This is the ONLY place where string parsing should happen for LLM configurations.
*/
export class ConfigurationManager {
private static instance: ConfigurationManager | null = null;
private constructor() {}
public static getInstance(): ConfigurationManager {
if (!ConfigurationManager.instance) {
ConfigurationManager.instance = new ConfigurationManager();
}
return ConfigurationManager.instance;
}
/**
* Get the complete AI configuration - always fresh, no caching
*/
public async getAIConfig(): Promise<AIConfig> {
try {
const config: AIConfig = {
enabled: await this.getAIEnabled(),
selectedProvider: await this.getSelectedProvider(),
defaultModels: await this.getDefaultModels(),
providerSettings: await this.getProviderSettings()
};
return config;
} catch (error) {
log.error(`Error loading AI configuration: ${error}`);
return this.getDefaultConfig();
}
}
/**
* Get the selected AI provider
*/
public async getSelectedProvider(): Promise<ProviderType | null> {
try {
const selectedProvider = options.getOption('aiSelectedProvider');
return selectedProvider as ProviderType || null;
} catch (error) {
log.error(`Error getting selected provider: ${error}`);
return null;
}
}
/**
* Parse model identifier with optional provider prefix
* Handles formats like "gpt-4", "openai:gpt-4", "ollama:llama2:7b"
*/
public parseModelIdentifier(modelString: string): ModelIdentifier {
if (!modelString) {
return {
modelId: '',
fullIdentifier: ''
};
}
const parts = modelString.split(':');
if (parts.length === 1) {
// No provider prefix, just model name
return {
modelId: modelString,
fullIdentifier: modelString
};
}
// Check if first part is a known provider
const potentialProvider = parts[0].toLowerCase();
const knownProviders: ProviderType[] = ['openai', 'anthropic', 'ollama'];
if (knownProviders.includes(potentialProvider as ProviderType)) {
// Provider prefix format
const provider = potentialProvider as ProviderType;
const modelId = parts.slice(1).join(':'); // Rejoin in case model has colons
return {
provider,
modelId,
fullIdentifier: modelString
};
}
// Not a provider prefix, treat whole string as model name
return {
modelId: modelString,
fullIdentifier: modelString
};
}
/**
* Create model configuration from string
*/
public createModelConfig(modelString: string, defaultProvider?: ProviderType): ModelConfig {
const identifier = this.parseModelIdentifier(modelString);
const provider = identifier.provider || defaultProvider || 'openai';
return {
provider,
modelId: identifier.modelId,
displayName: identifier.fullIdentifier
};
}
/**
* Get default models for each provider - ONLY from user configuration
*/
public async getDefaultModels(): Promise<Record<ProviderType, string | undefined>> {
try {
const openaiModel = options.getOption('openaiDefaultModel');
const anthropicModel = options.getOption('anthropicDefaultModel');
const ollamaModel = options.getOption('ollamaDefaultModel');
return {
openai: openaiModel || undefined,
anthropic: anthropicModel || undefined,
ollama: ollamaModel || undefined
};
} catch (error) {
log.error(`Error loading default models: ${error}`);
// Return undefined for all providers if we can't load config
return {
openai: undefined,
anthropic: undefined,
ollama: undefined
};
}
}
/**
* Get provider-specific settings
*/
public async getProviderSettings(): Promise<ProviderSettings> {
try {
const openaiApiKey = options.getOption('openaiApiKey');
const openaiBaseUrl = options.getOption('openaiBaseUrl');
const openaiDefaultModel = options.getOption('openaiDefaultModel');
const anthropicApiKey = options.getOption('anthropicApiKey');
const anthropicBaseUrl = options.getOption('anthropicBaseUrl');
const anthropicDefaultModel = options.getOption('anthropicDefaultModel');
const ollamaBaseUrl = options.getOption('ollamaBaseUrl');
const ollamaDefaultModel = options.getOption('ollamaDefaultModel');
const settings: ProviderSettings = {};
if (openaiApiKey || openaiBaseUrl || openaiDefaultModel) {
settings.openai = {
apiKey: openaiApiKey,
baseUrl: openaiBaseUrl,
defaultModel: openaiDefaultModel
};
}
if (anthropicApiKey || anthropicBaseUrl || anthropicDefaultModel) {
settings.anthropic = {
apiKey: anthropicApiKey,
baseUrl: anthropicBaseUrl,
defaultModel: anthropicDefaultModel
};
}
if (ollamaBaseUrl || ollamaDefaultModel) {
settings.ollama = {
baseUrl: ollamaBaseUrl,
defaultModel: ollamaDefaultModel
};
}
return settings;
} catch (error) {
log.error(`Error loading provider settings: ${error}`);
return {};
}
}
/**
* Validate configuration
*/
public async validateConfig(): Promise<ConfigValidationResult> {
const result: ConfigValidationResult = {
isValid: true,
errors: [],
warnings: []
};
try {
const config = await this.getAIConfig();
if (!config.enabled) {
result.warnings.push('AI features are disabled');
return result;
}
// Validate selected provider
if (!config.selectedProvider) {
result.errors.push('No AI provider selected');
result.isValid = false;
} else {
// Validate selected provider settings
const providerConfig = config.providerSettings[config.selectedProvider];
if (config.selectedProvider === 'openai') {
const openaiConfig = providerConfig as OpenAISettings | undefined;
if (!openaiConfig?.apiKey) {
result.warnings.push('OpenAI API key is not configured');
}
}
if (config.selectedProvider === 'anthropic') {
const anthropicConfig = providerConfig as AnthropicSettings | undefined;
if (!anthropicConfig?.apiKey) {
result.warnings.push('Anthropic API key is not configured');
}
}
if (config.selectedProvider === 'ollama') {
const ollamaConfig = providerConfig as OllamaSettings | undefined;
if (!ollamaConfig?.baseUrl) {
result.warnings.push('Ollama base URL is not configured');
}
}
}
} catch (error) {
result.errors.push(`Configuration validation error: ${error}`);
result.isValid = false;
}
return result;
}
// Private helper methods
private async getAIEnabled(): Promise<boolean> {
try {
return options.getOptionBool('aiEnabled');
} catch {
return false;
}
}
private parseProviderList(precedenceOption: string | null): string[] {
if (!precedenceOption) {
// Don't assume any defaults - return empty array
return [];
}
try {
// Handle JSON array format
if (precedenceOption.startsWith('[') && precedenceOption.endsWith(']')) {
const parsed = JSON.parse(precedenceOption);
if (Array.isArray(parsed)) {
return parsed.map(p => String(p).trim());
}
}
// Handle comma-separated format
if (precedenceOption.includes(',')) {
return precedenceOption.split(',').map(p => p.trim());
}
// Handle single provider
return [precedenceOption.trim()];
} catch (error) {
log.error(`Error parsing provider list "${precedenceOption}": ${error}`);
// Don't assume defaults on parse error
return [];
}
}
private getDefaultConfig(): AIConfig {
return {
enabled: false,
selectedProvider: null,
defaultModels: {
openai: undefined,
anthropic: undefined,
ollama: undefined
},
providerSettings: {}
};
}
}
// Export singleton instance
export default ConfigurationManager.getInstance();