fix(llm): changing providers works now

This commit is contained in:
perf3ct 2025-06-07 23:57:35 +00:00
parent 414781936b
commit c6062f453a
No known key found for this signature in database
GPG Key ID: 569C4EEC436F5232
5 changed files with 244 additions and 172 deletions

View File

@ -48,7 +48,7 @@ export default class AiSettingsWidget extends OptionsWidget {
if (optionName === 'aiEnabled') { if (optionName === 'aiEnabled') {
try { try {
const isEnabled = value === 'true'; const isEnabled = value === 'true';
if (isEnabled) { if (isEnabled) {
toastService.showMessage(t("ai_llm.ai_enabled") || "AI features enabled"); toastService.showMessage(t("ai_llm.ai_enabled") || "AI features enabled");
} else { } else {

View File

@ -40,8 +40,8 @@ interface NoteContext {
} }
export class AIServiceManager implements IAIServiceManager { export class AIServiceManager implements IAIServiceManager {
private services: Partial<Record<ServiceProviders, AIService>> = {}; private currentService: AIService | null = null;
private currentProvider: ServiceProviders | null = null;
private initialized = false; private initialized = false;
constructor() { constructor() {
@ -50,9 +50,8 @@ export class AIServiceManager implements IAIServiceManager {
log.error(`Error initializing LLM tools during AIServiceManager construction: ${error.message || String(error)}`); log.error(`Error initializing LLM tools during AIServiceManager construction: ${error.message || String(error)}`);
}); });
// Set up event listener for provider changes // Removed complex provider change listener - we'll read options fresh each time
this.setupProviderChangeListener();
this.initialized = true; this.initialized = true;
} }
@ -140,15 +139,15 @@ export class AIServiceManager implements IAIServiceManager {
*/ */
async getOrCreateAnyService(): Promise<AIService> { async getOrCreateAnyService(): Promise<AIService> {
this.ensureInitialized(); this.ensureInitialized();
// Get the selected provider using the new configuration system // Get the selected provider using the new configuration system
const selectedProvider = await this.getSelectedProviderAsync(); const selectedProvider = await this.getSelectedProviderAsync();
if (!selectedProvider) { if (!selectedProvider) {
throw new Error('No AI provider is selected. Please select a provider (OpenAI, Anthropic, or Ollama) in your AI settings.'); throw new Error('No AI provider is selected. Please select a provider (OpenAI, Anthropic, or Ollama) in your AI settings.');
} }
try { try {
const service = await this.getOrCreateChatProvider(selectedProvider); const service = await this.getOrCreateChatProvider(selectedProvider);
if (service) { if (service) {
@ -166,7 +165,7 @@ export class AIServiceManager implements IAIServiceManager {
*/ */
isAnyServiceAvailable(): boolean { isAnyServiceAvailable(): boolean {
this.ensureInitialized(); this.ensureInitialized();
// Check if we have the selected provider available // Check if we have the selected provider available
return this.getAvailableProviders().length > 0; return this.getAvailableProviders().length > 0;
} }
@ -174,43 +173,37 @@ export class AIServiceManager implements IAIServiceManager {
/** /**
* Get list of available providers * Get list of available providers
*/ */
getAvailableProviders(): ServiceProviders[] { getAvailableProviders(): ServiceProviders[] {
this.ensureInitialized(); this.ensureInitialized();
const allProviders: ServiceProviders[] = ['openai', 'anthropic', 'ollama']; const allProviders: ServiceProviders[] = ['openai', 'anthropic', 'ollama'];
const availableProviders: ServiceProviders[] = []; const availableProviders: ServiceProviders[] = [];
for (const providerName of allProviders) { for (const providerName of allProviders) {
// Use a sync approach - check if we can create the provider // Check configuration to see if provider would be available
const service = this.services[providerName]; try {
if (service && service.isAvailable()) { switch (providerName) {
availableProviders.push(providerName); case 'openai':
} else { if (options.getOption('openaiApiKey') || options.getOption('openaiBaseUrl')) {
// For providers not yet created, check configuration to see if they would be available availableProviders.push(providerName);
try { }
switch (providerName) { break;
case 'openai': case 'anthropic':
if (options.getOption('openaiApiKey')) { if (options.getOption('anthropicApiKey')) {
availableProviders.push(providerName); availableProviders.push(providerName);
} }
break; break;
case 'anthropic': case 'ollama':
if (options.getOption('anthropicApiKey')) { if (options.getOption('ollamaBaseUrl')) {
availableProviders.push(providerName); availableProviders.push(providerName);
} }
break; break;
case 'ollama':
if (options.getOption('ollamaBaseUrl')) {
availableProviders.push(providerName);
}
break;
}
} catch (error) {
// Ignore configuration errors, provider just won't be available
} }
} catch (error) {
// Ignore configuration errors, provider just won't be available
} }
} }
return availableProviders; return availableProviders;
} }
@ -234,11 +227,11 @@ export class AIServiceManager implements IAIServiceManager {
// Get the selected provider // Get the selected provider
const selectedProvider = await this.getSelectedProviderAsync(); const selectedProvider = await this.getSelectedProviderAsync();
if (!selectedProvider) { if (!selectedProvider) {
throw new Error('No AI provider is selected. Please select a provider in your AI settings.'); throw new Error('No AI provider is selected. Please select a provider in your AI settings.');
} }
// Check if the selected provider is available // Check if the selected provider is available
const availableProviders = this.getAvailableProviders(); const availableProviders = this.getAvailableProviders();
if (!availableProviders.includes(selectedProvider)) { 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<AIService | null> { private async getOrCreateChatProvider(providerName: ServiceProviders): Promise<AIService | null> {
// Return existing provider if already created // If provider type changed, clear the old one
if (this.services[providerName]) { if (this.currentProvider && this.currentProvider !== providerName) {
return this.services[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 { try {
let service: AIService | null = null; let service: AIService | null = null;
switch (providerName) { switch (providerName) {
case 'openai': { case 'openai': {
const apiKey = options.getOption('openaiApiKey'); const apiKey = options.getOption('openaiApiKey');
const baseUrl = options.getOption('openaiBaseUrl'); const baseUrl = options.getOption('openaiBaseUrl');
if (!apiKey && !baseUrl) return null; if (!apiKey && !baseUrl) return null;
service = new OpenAIService(); service = new OpenAIService();
// Validate by checking if it's available
if (!service.isAvailable()) { if (!service.isAvailable()) {
throw new Error('OpenAI service not available'); throw new Error('OpenAI service not available');
} }
break; break;
} }
case 'anthropic': { case 'anthropic': {
const apiKey = options.getOption('anthropicApiKey'); const apiKey = options.getOption('anthropicApiKey');
if (!apiKey) return null; if (!apiKey) return null;
service = new AnthropicService(); service = new AnthropicService();
if (!service.isAvailable()) { if (!service.isAvailable()) {
throw new Error('Anthropic service not available'); throw new Error('Anthropic service not available');
} }
break; break;
} }
case 'ollama': { case 'ollama': {
const baseUrl = options.getOption('ollamaBaseUrl'); const baseUrl = options.getOption('ollamaBaseUrl');
if (!baseUrl) return null; if (!baseUrl) return null;
service = new OllamaService(); service = new OllamaService();
if (!service.isAvailable()) { if (!service.isAvailable()) {
throw new Error('Ollama service not available'); throw new Error('Ollama service not available');
@ -427,9 +441,12 @@ export class AIServiceManager implements IAIServiceManager {
break; break;
} }
} }
if (service) { 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; return service;
} }
} catch (error: any) { } catch (error: any) {
@ -630,28 +647,47 @@ export class AIServiceManager implements IAIServiceManager {
* Check if a specific provider is available * Check if a specific provider is available
*/ */
isProviderAvailable(provider: string): boolean { 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 * Get metadata about a provider
*/ */
getProviderMetadata(provider: string): ProviderMetadata | null { getProviderMetadata(provider: string): ProviderMetadata | null {
const service = this.services[provider as ServiceProviders]; // Only return metadata if this is the current active provider
if (!service) { if (this.currentProvider === provider && this.currentService) {
return null; 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 { return null;
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'
};
} }
@ -665,67 +701,8 @@ export class AIServiceManager implements IAIServiceManager {
return String(error); return String(error);
} }
/** // Removed complex event listener and cache invalidation logic
* Set up event listener for provider changes // Services will be created fresh when needed by reading current options
*/
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<void> {
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)}`);
}
}
} }

View File

@ -1,4 +1,3 @@
import configurationManager from './configuration_manager.js';
import optionService from '../../options.js'; import optionService from '../../options.js';
import log from '../../log.js'; import log from '../../log.js';
import type { 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<ProviderType | null> { export async function getSelectedProvider(): Promise<ProviderType | null> {
const providerOption = optionService.getOption('aiSelectedProvider'); const providerOption = optionService.getOption('aiSelectedProvider');
@ -25,38 +24,100 @@ export async function getSelectedProvider(): Promise<ProviderType | null> {
* Parse a model identifier (handles "provider:model" format) * Parse a model identifier (handles "provider:model" format)
*/ */
export function parseModelIdentifier(modelString: string): ModelIdentifier { 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 * Create a model configuration from a model string
*/ */
export function createModelConfig(modelString: string, defaultProvider?: ProviderType): ModelConfig { 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<string | undefined> { export async function getDefaultModelForProvider(provider: ProviderType): Promise<string | undefined> {
const config = await configurationManager.getAIConfig(); const optionKey = `${provider}DefaultModel` as const;
return config.defaultModels[provider]; // This can now be undefined 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) { export async function getProviderSettings(provider: ProviderType) {
const config = await configurationManager.getAIConfig(); switch (provider) {
return config.providerSettings[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<boolean> { export async function isAIEnabled(): Promise<boolean> {
const config = await configurationManager.getAIConfig(); return optionService.getOptionBool('aiEnabled');
return config.enabled;
} }
/** /**
@ -82,7 +143,7 @@ export async function isProviderConfigured(provider: ProviderType): Promise<bool
*/ */
export async function getAvailableSelectedProvider(): Promise<ProviderType | null> { export async function getAvailableSelectedProvider(): Promise<ProviderType | null> {
const selectedProvider = await getSelectedProvider(); const selectedProvider = await getSelectedProvider();
if (!selectedProvider) { if (!selectedProvider) {
return null; // No provider selected return null; // No provider selected
} }
@ -95,17 +156,51 @@ export async function getAvailableSelectedProvider(): Promise<ProviderType | nul
} }
/** /**
* Validate the current AI configuration * Validate the current AI configuration - simplified validation
*/ */
export async function validateConfiguration() { export async function validateConfiguration() {
return configurationManager.validateConfig(); const result = {
isValid: true,
errors: [] as string[],
warnings: [] as string[]
};
const aiEnabled = await isAIEnabled();
if (!aiEnabled) {
result.warnings.push('AI features are disabled');
return result;
}
const selectedProvider = await getSelectedProvider();
if (!selectedProvider) {
result.errors.push('No AI provider selected');
result.isValid = false;
return result;
}
// Validate provider-specific settings
const settings = await getProviderSettings(selectedProvider);
if (selectedProvider === 'openai' && !(settings as any)?.apiKey) {
result.warnings.push('OpenAI API key is not configured');
}
if (selectedProvider === 'anthropic' && !(settings as any)?.apiKey) {
result.warnings.push('Anthropic API key is not configured');
}
if (selectedProvider === 'ollama' && !(settings as any)?.baseUrl) {
result.warnings.push('Ollama base URL is not configured');
}
return result;
} }
/** /**
* Clear cached configuration (use when settings change) * Clear cached configuration (no-op since we removed caching)
*/ */
export function clearConfigurationCache(): void { export function clearConfigurationCache(): void {
configurationManager.clearCache(); // No caching anymore, so nothing to clear
} }
/** /**
@ -136,7 +231,7 @@ export async function getValidModelConfig(provider: ProviderType): Promise<{ mod
*/ */
export async function getSelectedModelConfig(): Promise<{ model: string; provider: ProviderType } | null> { export async function getSelectedModelConfig(): Promise<{ model: string; provider: ProviderType } | null> {
const selectedProvider = await getSelectedProvider(); const selectedProvider = await getSelectedProvider();
if (!selectedProvider) { if (!selectedProvider) {
return null; // No provider selected return null; // No provider selected
} }

View File

@ -21,11 +21,6 @@ import type {
*/ */
export class ConfigurationManager { export class ConfigurationManager {
private static instance: ConfigurationManager | null = null; 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() {} 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<AIConfig> { public async getAIConfig(): Promise<AIConfig> {
const now = Date.now();
if (this.cachedConfig && (now - this.lastConfigUpdate) < ConfigurationManager.CACHE_DURATION) {
return this.cachedConfig;
}
try { try {
const config: AIConfig = { const config: AIConfig = {
enabled: await this.getAIEnabled(), enabled: await this.getAIEnabled(),
@ -53,8 +43,6 @@ export class ConfigurationManager {
providerSettings: await this.getProviderSettings() providerSettings: await this.getProviderSettings()
}; };
this.cachedConfig = config;
this.lastConfigUpdate = now;
return config; return config;
} catch (error) { } catch (error) {
log.error(`Error loading AI configuration: ${error}`); log.error(`Error loading AI configuration: ${error}`);
@ -263,14 +251,6 @@ export class ConfigurationManager {
return result; return result;
} }
/**
* Clear cached configuration (force reload on next access)
*/
public clearCache(): void {
this.cachedConfig = null;
this.lastConfigUpdate = 0;
}
// Private helper methods // Private helper methods
private async getAIEnabled(): Promise<boolean> { private async getAIEnabled(): Promise<boolean> {

View File

@ -82,6 +82,26 @@ function setOption<T extends OptionNames>(name: T, value: string | OptionDefinit
} else { } else {
createOption(name, value, false); 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}`);
}
});
}
} }
/** /**