From c6062f453a289060513b799dd9e5bd20e7ca9ede Mon Sep 17 00:00:00 2001 From: perf3ct Date: Sat, 7 Jun 2025 23:57:35 +0000 Subject: [PATCH] fix(llm): changing providers works now --- .../options/ai_settings/ai_settings_widget.ts | 2 +- .../src/services/llm/ai_service_manager.ts | 239 ++++++++---------- .../llm/config/configuration_helpers.ts | 133 ++++++++-- .../llm/config/configuration_manager.ts | 22 +- apps/server/src/services/options.ts | 20 ++ 5 files changed, 244 insertions(+), 172 deletions(-) diff --git a/apps/client/src/widgets/type_widgets/options/ai_settings/ai_settings_widget.ts b/apps/client/src/widgets/type_widgets/options/ai_settings/ai_settings_widget.ts index 583f24f9c..3359f0c37 100644 --- a/apps/client/src/widgets/type_widgets/options/ai_settings/ai_settings_widget.ts +++ b/apps/client/src/widgets/type_widgets/options/ai_settings/ai_settings_widget.ts @@ -48,7 +48,7 @@ export default class AiSettingsWidget extends OptionsWidget { if (optionName === 'aiEnabled') { try { const isEnabled = value === 'true'; - + if (isEnabled) { toastService.showMessage(t("ai_llm.ai_enabled") || "AI features enabled"); } else { diff --git a/apps/server/src/services/llm/ai_service_manager.ts b/apps/server/src/services/llm/ai_service_manager.ts index cbb0a8755..bd47b4327 100644 --- a/apps/server/src/services/llm/ai_service_manager.ts +++ b/apps/server/src/services/llm/ai_service_manager.ts @@ -40,8 +40,8 @@ interface NoteContext { } export class AIServiceManager implements IAIServiceManager { - private services: Partial> = {}; - + private currentService: AIService | null = null; + private currentProvider: ServiceProviders | null = null; private initialized = false; constructor() { @@ -50,9 +50,8 @@ export class AIServiceManager implements IAIServiceManager { log.error(`Error initializing LLM tools during AIServiceManager construction: ${error.message || String(error)}`); }); - // Set up event listener for provider changes - this.setupProviderChangeListener(); - + // Removed complex provider change listener - we'll read options fresh each time + this.initialized = true; } @@ -140,15 +139,15 @@ export class AIServiceManager implements IAIServiceManager { */ async getOrCreateAnyService(): Promise { this.ensureInitialized(); - + // Get the selected provider using the new configuration system const selectedProvider = await this.getSelectedProviderAsync(); - - + + if (!selectedProvider) { throw new Error('No AI provider is selected. Please select a provider (OpenAI, Anthropic, or Ollama) in your AI settings.'); } - + try { const service = await this.getOrCreateChatProvider(selectedProvider); if (service) { @@ -166,7 +165,7 @@ export class AIServiceManager implements IAIServiceManager { */ isAnyServiceAvailable(): boolean { this.ensureInitialized(); - + // Check if we have the selected provider available return this.getAvailableProviders().length > 0; } @@ -174,43 +173,37 @@ export class AIServiceManager implements IAIServiceManager { /** * Get list of available providers */ - getAvailableProviders(): ServiceProviders[] { + getAvailableProviders(): ServiceProviders[] { this.ensureInitialized(); - + const allProviders: ServiceProviders[] = ['openai', 'anthropic', 'ollama']; const availableProviders: ServiceProviders[] = []; - + for (const providerName of allProviders) { - // Use a sync approach - check if we can create the provider - const service = this.services[providerName]; - if (service && service.isAvailable()) { - availableProviders.push(providerName); - } else { - // For providers not yet created, check configuration to see if they would be available - try { - switch (providerName) { - case 'openai': - if (options.getOption('openaiApiKey')) { - availableProviders.push(providerName); - } - break; - case 'anthropic': - if (options.getOption('anthropicApiKey')) { - availableProviders.push(providerName); - } - break; - case 'ollama': - if (options.getOption('ollamaBaseUrl')) { - availableProviders.push(providerName); - } - break; - } - } catch (error) { - // Ignore configuration errors, provider just won't be available + // Check configuration to see if provider would be available + try { + switch (providerName) { + case 'openai': + if (options.getOption('openaiApiKey') || options.getOption('openaiBaseUrl')) { + availableProviders.push(providerName); + } + break; + case 'anthropic': + if (options.getOption('anthropicApiKey')) { + availableProviders.push(providerName); + } + break; + case 'ollama': + if (options.getOption('ollamaBaseUrl')) { + availableProviders.push(providerName); + } + break; } + } catch (error) { + // Ignore configuration errors, provider just won't be available } } - + return availableProviders; } @@ -234,11 +227,11 @@ export class AIServiceManager implements IAIServiceManager { // Get the selected provider const selectedProvider = await this.getSelectedProviderAsync(); - + if (!selectedProvider) { throw new Error('No AI provider is selected. Please select a provider in your AI settings.'); } - + // Check if the selected provider is available const availableProviders = this.getAvailableProviders(); if (!availableProviders.includes(selectedProvider)) { @@ -379,47 +372,68 @@ export class AIServiceManager implements IAIServiceManager { } /** - * Get or create a chat provider on-demand with inline validation + * Clear the current provider (forces recreation on next access) + */ + public clearCurrentProvider(): void { + this.currentService = null; + this.currentProvider = null; + log.info('Cleared current provider - will be recreated on next access'); + } + + /** + * Get or create the current provider instance - only one instance total */ private async getOrCreateChatProvider(providerName: ServiceProviders): Promise { - // Return existing provider if already created - if (this.services[providerName]) { - return this.services[providerName]; + // If provider type changed, clear the old one + if (this.currentProvider && this.currentProvider !== providerName) { + log.info(`Provider changed from ${this.currentProvider} to ${providerName}, clearing old service`); + this.currentService = null; + this.currentProvider = null; } - // Create and validate provider on-demand + // Return existing service if it matches and is available + if (this.currentService && this.currentProvider === providerName && this.currentService.isAvailable()) { + return this.currentService; + } + + // Clear invalid service + if (this.currentService) { + this.currentService = null; + this.currentProvider = null; + } + + // Create new service for the requested provider try { let service: AIService | null = null; - + switch (providerName) { case 'openai': { const apiKey = options.getOption('openaiApiKey'); const baseUrl = options.getOption('openaiBaseUrl'); if (!apiKey && !baseUrl) return null; - + service = new OpenAIService(); - // Validate by checking if it's available if (!service.isAvailable()) { throw new Error('OpenAI service not available'); } break; } - + case 'anthropic': { const apiKey = options.getOption('anthropicApiKey'); if (!apiKey) return null; - + service = new AnthropicService(); if (!service.isAvailable()) { throw new Error('Anthropic service not available'); } break; } - + case 'ollama': { const baseUrl = options.getOption('ollamaBaseUrl'); if (!baseUrl) return null; - + service = new OllamaService(); if (!service.isAvailable()) { throw new Error('Ollama service not available'); @@ -427,9 +441,12 @@ export class AIServiceManager implements IAIServiceManager { break; } } - + if (service) { - this.services[providerName] = service; + // Cache the new service + this.currentService = service; + this.currentProvider = providerName; + log.info(`Created and cached new ${providerName} service`); return service; } } catch (error: any) { @@ -630,28 +647,47 @@ export class AIServiceManager implements IAIServiceManager { * Check if a specific provider is available */ isProviderAvailable(provider: string): boolean { - return this.services[provider as ServiceProviders]?.isAvailable() ?? false; + // Check if this is the current provider and if it's available + if (this.currentProvider === provider && this.currentService) { + return this.currentService.isAvailable(); + } + + // For other providers, check configuration + try { + switch (provider) { + case 'openai': + return !!(options.getOption('openaiApiKey') || options.getOption('openaiBaseUrl')); + case 'anthropic': + return !!options.getOption('anthropicApiKey'); + case 'ollama': + return !!options.getOption('ollamaBaseUrl'); + default: + return false; + } + } catch { + return false; + } } /** * Get metadata about a provider */ getProviderMetadata(provider: string): ProviderMetadata | null { - const service = this.services[provider as ServiceProviders]; - if (!service) { - return null; + // Only return metadata if this is the current active provider + if (this.currentProvider === provider && this.currentService) { + return { + name: provider, + capabilities: { + chat: true, + streaming: true, + functionCalling: provider === 'openai' // Only OpenAI has function calling + }, + models: ['default'], // Placeholder, could be populated from the service + defaultModel: 'default' + }; } - return { - name: provider, - capabilities: { - chat: true, - streaming: true, - functionCalling: provider === 'openai' // Only OpenAI has function calling - }, - models: ['default'], // Placeholder, could be populated from the service - defaultModel: 'default' - }; + return null; } @@ -665,67 +701,8 @@ export class AIServiceManager implements IAIServiceManager { return String(error); } - /** - * Set up event listener for provider changes - */ - private setupProviderChangeListener(): void { - // List of AI-related options that should trigger service recreation - const aiRelatedOptions = [ - 'aiEnabled', - 'aiSelectedProvider', - 'openaiApiKey', - 'openaiBaseUrl', - 'openaiDefaultModel', - 'anthropicApiKey', - 'anthropicBaseUrl', - 'anthropicDefaultModel', - 'ollamaBaseUrl', - 'ollamaDefaultModel' - ]; - - eventService.subscribe(['entityChanged'], async ({ entityName, entity }) => { - if (entityName === 'options' && entity && aiRelatedOptions.includes(entity.name)) { - log.info(`AI-related option '${entity.name}' changed, recreating LLM services`); - - // Special handling for aiEnabled toggle - if (entity.name === 'aiEnabled') { - const isEnabled = entity.value === 'true'; - - if (isEnabled) { - log.info('AI features enabled, initializing AI service'); - // Initialize the AI service - await this.initialize(); - } else { - log.info('AI features disabled, clearing providers'); - // Clear chat providers - this.services = {}; - } - } else { - // For other AI-related options, recreate services on-demand - await this.recreateServices(); - } - } - }); - } - - /** - * Recreate LLM services when provider settings change - */ - private async recreateServices(): Promise { - try { - log.info('Recreating LLM services due to configuration change'); - - // Clear configuration cache first - clearConfigurationCache(); - - // Clear existing chat providers (they will be recreated on-demand) - this.services = {}; - - log.info('LLM services recreated successfully'); - } catch (error) { - log.error(`Error recreating LLM services: ${this.handleError(error)}`); - } - } + // Removed complex event listener and cache invalidation logic + // Services will be created fresh when needed by reading current options } diff --git a/apps/server/src/services/llm/config/configuration_helpers.ts b/apps/server/src/services/llm/config/configuration_helpers.ts index d48504bd2..9f92164a2 100644 --- a/apps/server/src/services/llm/config/configuration_helpers.ts +++ b/apps/server/src/services/llm/config/configuration_helpers.ts @@ -1,4 +1,3 @@ -import configurationManager from './configuration_manager.js'; import optionService from '../../options.js'; import log from '../../log.js'; import type { @@ -13,7 +12,7 @@ import type { */ /** - * Get the selected AI provider + * Get the selected AI provider - always fresh from options */ export async function getSelectedProvider(): Promise { const providerOption = optionService.getOption('aiSelectedProvider'); @@ -25,38 +24,100 @@ export async function getSelectedProvider(): Promise { * Parse a model identifier (handles "provider:model" format) */ export function parseModelIdentifier(modelString: string): ModelIdentifier { - return configurationManager.parseModelIdentifier(modelString); + 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 a model configuration from a model string */ export function createModelConfig(modelString: string, defaultProvider?: ProviderType): ModelConfig { - return configurationManager.createModelConfig(modelString, defaultProvider); + const identifier = parseModelIdentifier(modelString); + const provider = identifier.provider || defaultProvider || 'openai'; // fallback to openai if no provider specified + + return { + provider, + modelId: identifier.modelId, + displayName: identifier.fullIdentifier + }; } /** - * Get the default model for a specific provider + * Get the default model for a specific provider - always fresh from options */ export async function getDefaultModelForProvider(provider: ProviderType): Promise { - const config = await configurationManager.getAIConfig(); - return config.defaultModels[provider]; // This can now be undefined + const optionKey = `${provider}DefaultModel` as const; + return optionService.getOption(optionKey) || undefined; } /** - * Get provider settings for a specific provider + * Get provider settings for a specific provider - always fresh from options */ export async function getProviderSettings(provider: ProviderType) { - const config = await configurationManager.getAIConfig(); - return config.providerSettings[provider]; + switch (provider) { + case 'openai': + return { + apiKey: optionService.getOption('openaiApiKey'), + baseUrl: optionService.getOption('openaiBaseUrl'), + defaultModel: optionService.getOption('openaiDefaultModel') + }; + case 'anthropic': + return { + apiKey: optionService.getOption('anthropicApiKey'), + baseUrl: optionService.getOption('anthropicBaseUrl'), + defaultModel: optionService.getOption('anthropicDefaultModel') + }; + case 'ollama': + return { + baseUrl: optionService.getOption('ollamaBaseUrl'), + defaultModel: optionService.getOption('ollamaDefaultModel') + }; + default: + return {}; + } } /** - * Check if AI is enabled + * Check if AI is enabled - always fresh from options */ export async function isAIEnabled(): Promise { - const config = await configurationManager.getAIConfig(); - return config.enabled; + return optionService.getOptionBool('aiEnabled'); } /** @@ -82,7 +143,7 @@ export async function isProviderConfigured(provider: ProviderType): Promise { const selectedProvider = await getSelectedProvider(); - + if (!selectedProvider) { return null; // No provider selected } @@ -95,17 +156,51 @@ export async function getAvailableSelectedProvider(): Promise { const selectedProvider = await getSelectedProvider(); - + if (!selectedProvider) { return null; // No provider selected } diff --git a/apps/server/src/services/llm/config/configuration_manager.ts b/apps/server/src/services/llm/config/configuration_manager.ts index 8dfd8df05..6b879eed6 100644 --- a/apps/server/src/services/llm/config/configuration_manager.ts +++ b/apps/server/src/services/llm/config/configuration_manager.ts @@ -21,11 +21,6 @@ import type { */ export class ConfigurationManager { private static instance: ConfigurationManager | null = null; - private cachedConfig: AIConfig | null = null; - private lastConfigUpdate: number = 0; - - // Cache for 5 minutes to avoid excessive option reads - private static readonly CACHE_DURATION = 5 * 60 * 1000; private constructor() {} @@ -37,14 +32,9 @@ export class ConfigurationManager { } /** - * Get the complete AI configuration + * Get the complete AI configuration - always fresh, no caching */ public async getAIConfig(): Promise { - const now = Date.now(); - if (this.cachedConfig && (now - this.lastConfigUpdate) < ConfigurationManager.CACHE_DURATION) { - return this.cachedConfig; - } - try { const config: AIConfig = { enabled: await this.getAIEnabled(), @@ -53,8 +43,6 @@ export class ConfigurationManager { providerSettings: await this.getProviderSettings() }; - this.cachedConfig = config; - this.lastConfigUpdate = now; return config; } catch (error) { log.error(`Error loading AI configuration: ${error}`); @@ -263,14 +251,6 @@ export class ConfigurationManager { return result; } - /** - * Clear cached configuration (force reload on next access) - */ - public clearCache(): void { - this.cachedConfig = null; - this.lastConfigUpdate = 0; - } - // Private helper methods private async getAIEnabled(): Promise { diff --git a/apps/server/src/services/options.ts b/apps/server/src/services/options.ts index f6e575c19..1cc67df5a 100644 --- a/apps/server/src/services/options.ts +++ b/apps/server/src/services/options.ts @@ -82,6 +82,26 @@ function setOption(name: T, value: string | OptionDefinit } else { createOption(name, value, false); } + + // Clear current AI provider when AI-related options change + const aiOptions = [ + 'aiSelectedProvider', 'openaiApiKey', 'openaiBaseUrl', 'openaiDefaultModel', + 'anthropicApiKey', 'anthropicBaseUrl', 'anthropicDefaultModel', + 'ollamaBaseUrl', 'ollamaDefaultModel' + ]; + + if (aiOptions.includes(name)) { + // Import dynamically to avoid circular dependencies + setImmediate(async () => { + try { + const aiServiceManager = (await import('./llm/ai_service_manager.js')).default; + aiServiceManager.getInstance().clearCurrentProvider(); + console.log(`Cleared AI provider after ${name} option changed`); + } catch (error) { + console.log(`Could not clear AI provider: ${error}`); + } + }); + } } /**