From da62814b2f643881fb75bc0ccecf335d91bdaae3 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Tue, 14 Apr 2026 13:39:11 +0200 Subject: [PATCH] Improved error handling for model loading (#732) --- .../Assistants/I18N/allTexts.lua | 15 +++ app/MindWork AI Studio/Chat/ContentText.cs | 15 ++- .../Dialogs/EmbeddingProviderDialog.razor.cs | 6 +- .../Dialogs/ProviderDialog.razor.cs | 6 +- .../TranscriptionProviderDialog.razor.cs | 6 +- .../plugin.lua | 15 +++ .../plugin.lua | 15 +++ .../AlibabaCloud/ProviderAlibabaCloud.cs | 59 ++++----- .../Provider/Anthropic/ProviderAnthropic.cs | 70 +++++------ .../Provider/BaseProvider.cs | 83 +++++++++++-- .../Provider/DeepSeek/ProviderDeepSeek.cs | 47 +++----- .../Provider/Fireworks/ProviderFireworks.cs | 25 ++-- .../Provider/GWDG/ProviderGWDG.cs | 67 +++++----- .../Provider/Google/ProviderGoogle.cs | 99 +++++++-------- .../Provider/Groq/ProviderGroq.cs | 53 +++----- .../Provider/Helmholtz/ProviderHelmholtz.cs | 88 +++++++++----- .../HuggingFace/ProviderHuggingFace.cs | 16 +-- app/MindWork AI Studio/Provider/IProvider.cs | 8 +- .../Provider/Mistral/ProviderMistral.cs | 81 ++++++------- .../Provider/ModelLoadFailureReason.cs | 11 ++ .../ModelLoadFailureReasonExtensions.cs | 19 +++ .../Provider/ModelLoadResult.cs | 19 +++ app/MindWork AI Studio/Provider/NoProvider.cs | 8 +- .../Provider/OpenAI/ProviderOpenAI.cs | 70 +++++------ .../Provider/OpenRouter/ProviderOpenRouter.cs | 114 +++++++----------- .../Provider/Perplexity/ProviderPerplexity.cs | 18 +-- .../Provider/SelfHosted/ProviderSelfHosted.cs | 56 ++++----- .../Provider/X/ProviderX.cs | 73 +++++------ .../wwwroot/changelog/v26.3.1.md | 1 + 29 files changed, 606 insertions(+), 557 deletions(-) create mode 100644 app/MindWork AI Studio/Provider/ModelLoadFailureReason.cs create mode 100644 app/MindWork AI Studio/Provider/ModelLoadFailureReasonExtensions.cs create mode 100644 app/MindWork AI Studio/Provider/ModelLoadResult.cs diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index c1234d7c..3ec3a063 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -6205,6 +6205,21 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::LLMPROVIDERSEXTENSIONS::T3424652889"] = "Un -- no model selected UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODEL::T2234274832"] = "no model selected" +-- We could not load models from '{0}'. The account or API key does not have the required permissions. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T1143085203"] = "We could not load models from '{0}'. The account or API key does not have the required permissions." + +-- We could not load models from '{0}'. The API key is probably missing, invalid, or expired. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T2041046579"] = "We could not load models from '{0}'. The API key is probably missing, invalid, or expired." + +-- We could not load models from '{0}' because the provider is currently unavailable or could not be reached. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T2115688703"] = "We could not load models from '{0}' because the provider is currently unavailable or could not be reached." + +-- We could not load models from '{0}' because the provider returned an unexpected response. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T2186844789"] = "We could not load models from '{0}' because the provider returned an unexpected response." + +-- We could not load models from '{0}' due to an unknown error. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T3907712809"] = "We could not load models from '{0}' due to an unknown error." + -- Model as configured by whisper.cpp UI_TEXT_CONTENT["AISTUDIO::PROVIDER::SELFHOSTED::PROVIDERSELFHOSTED::T3313940770"] = "Model as configured by whisper.cpp" diff --git a/app/MindWork AI Studio/Chat/ContentText.cs b/app/MindWork AI Studio/Chat/ContentText.cs index 9daeec49..eeeeda00 100644 --- a/app/MindWork AI Studio/Chat/ContentText.cs +++ b/app/MindWork AI Studio/Chat/ContentText.cs @@ -174,10 +174,21 @@ public sealed class ContentText : IContent return false; } - IEnumerable loadedModels; + IReadOnlyList loadedModels; try { - loadedModels = await provider.GetTextModels(token: token); + var modelLoadResult = await provider.GetTextModels(token: token); + if (!modelLoadResult.Success) + { + var userMessage = modelLoadResult.FailureReason.ToUserMessage(provider.InstanceName); + if (!string.IsNullOrWhiteSpace(userMessage)) + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, userMessage)); + + LOGGER.LogWarning("Skipping selected model availability check for '{ProviderInstanceName}' (provider={ProviderType}) because loading the model list failed with reason {FailureReason}.", provider.InstanceName, provider.Provider, modelLoadResult.FailureReason); + return false; + } + + loadedModels = modelLoadResult.Models; } catch (OperationCanceledException) { diff --git a/app/MindWork AI Studio/Dialogs/EmbeddingProviderDialog.razor.cs b/app/MindWork AI Studio/Dialogs/EmbeddingProviderDialog.razor.cs index 6520b7ee..dec348b2 100644 --- a/app/MindWork AI Studio/Dialogs/EmbeddingProviderDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/EmbeddingProviderDialog.razor.cs @@ -285,10 +285,12 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId try { - var models = await provider.GetEmbeddingModels(this.dataAPIKey); + var result = await provider.GetEmbeddingModels(this.dataAPIKey); + if (!result.Success) + this.dataLoadingModelsIssue = result.FailureReason.ToUserMessage(provider.InstanceName); // Order descending by ID means that the newest models probably come first: - var orderedModels = models.OrderByDescending(n => n.Id); + var orderedModels = result.Models.OrderByDescending(n => n.Id); this.availableModels.Clear(); this.availableModels.AddRange(orderedModels); diff --git a/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs index 9e84bea8..0e395324 100644 --- a/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs @@ -312,10 +312,12 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId try { - var models = await provider.GetTextModels(this.dataAPIKey); + var result = await provider.GetTextModels(this.dataAPIKey); + if (!result.Success) + this.dataLoadingModelsIssue = result.FailureReason.ToUserMessage(provider.InstanceName); // Order descending by ID means that the newest models probably come first: - var orderedModels = models.OrderByDescending(n => n.Id); + var orderedModels = result.Models.OrderByDescending(n => n.Id); this.availableModels.Clear(); this.availableModels.AddRange(orderedModels); diff --git a/app/MindWork AI Studio/Dialogs/TranscriptionProviderDialog.razor.cs b/app/MindWork AI Studio/Dialogs/TranscriptionProviderDialog.razor.cs index 75ad00a7..faa3d3be 100644 --- a/app/MindWork AI Studio/Dialogs/TranscriptionProviderDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/TranscriptionProviderDialog.razor.cs @@ -300,10 +300,12 @@ public partial class TranscriptionProviderDialog : MSGComponentBase, ISecretId try { - var models = await provider.GetTranscriptionModels(this.dataAPIKey); + var result = await provider.GetTranscriptionModels(this.dataAPIKey); + if (!result.Success) + this.dataLoadingModelsIssue = result.FailureReason.ToUserMessage(provider.InstanceName); // Order descending by ID means that the newest models probably come first: - var orderedModels = models.OrderByDescending(n => n.Id); + var orderedModels = result.Models.OrderByDescending(n => n.Id); this.availableModels.Clear(); this.availableModels.AddRange(orderedModels); diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index b6a62c82..210b09d1 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -6207,6 +6207,21 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::LLMPROVIDERSEXTENSIONS::T3424652889"] = "Un -- no model selected UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODEL::T2234274832"] = "Kein Modell ausgewählt" +-- We could not load models from '{0}'. The account or API key does not have the required permissions. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T1143085203"] = "Wir konnten keine Modelle von '{0}' laden. Das Konto oder der API-Schlüssel verfügt nicht über die erforderlichen Berechtigungen." + +-- We could not load models from '{0}'. The API key is probably missing, invalid, or expired. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T2041046579"] = "Modelle aus '{0}' konnten nicht geladen werden. Wahrscheinlich fehlt der API-Schlüssel, ist ungültig oder abgelaufen." + +-- We could not load models from '{0}' because the provider is currently unavailable or could not be reached. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T2115688703"] = "Wir konnten keine Modelle von '{0}' laden, da der Anbieter derzeit nicht verfügbar oder nicht erreichbar ist." + +-- We could not load models from '{0}' because the provider returned an unexpected response. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T2186844789"] = "Wir konnten keine Modelle von '{0}' laden, da der Anbieter eine unerwartete Antwort zurückgegeben hat." + +-- We could not load models from '{0}' due to an unknown error. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T3907712809"] = "Wir konnten die Modelle aus '{0}' aufgrund eines unbekannten Fehlers nicht laden." + -- Model as configured by whisper.cpp UI_TEXT_CONTENT["AISTUDIO::PROVIDER::SELFHOSTED::PROVIDERSELFHOSTED::T3313940770"] = "Modell wie in whisper.cpp konfiguriert" diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index fdf1acf3..88abbc3c 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -6207,6 +6207,21 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::LLMPROVIDERSEXTENSIONS::T3424652889"] = "Un -- no model selected UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODEL::T2234274832"] = "no model selected" +-- We could not load models from '{0}'. The account or API key does not have the required permissions. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T1143085203"] = "We could not load models from '{0}'. The account or API key does not have the required permissions." + +-- We could not load models from '{0}'. The API key is probably missing, invalid, or expired. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T2041046579"] = "We could not load models from '{0}'. The API key is probably missing, invalid, or expired." + +-- We could not load models from '{0}' because the provider is currently unavailable or could not be reached. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T2115688703"] = "We could not load models from '{0}' because the provider is currently unavailable or could not be reached." + +-- We could not load models from '{0}' because the provider returned an unexpected response. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T2186844789"] = "We could not load models from '{0}' because the provider returned an unexpected response." + +-- We could not load models from '{0}' due to an unknown error. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T3907712809"] = "We could not load models from '{0}' due to an unknown error." + -- Model as configured by whisper.cpp UI_TEXT_CONTENT["AISTUDIO::PROVIDER::SELFHOSTED::PROVIDERSELFHOSTED::T3313940770"] = "Model as configured by whisper.cpp" diff --git a/app/MindWork AI Studio/Provider/AlibabaCloud/ProviderAlibabaCloud.cs b/app/MindWork AI Studio/Provider/AlibabaCloud/ProviderAlibabaCloud.cs index 7f2bf792..22ae6868 100644 --- a/app/MindWork AI Studio/Provider/AlibabaCloud/ProviderAlibabaCloud.cs +++ b/app/MindWork AI Studio/Provider/AlibabaCloud/ProviderAlibabaCloud.cs @@ -1,5 +1,4 @@ -using System.Net.Http.Headers; -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; using AIStudio.Chat; using AIStudio.Provider.OpenAI; @@ -71,7 +70,7 @@ public sealed class ProviderAlibabaCloud() : BaseProvider(LLMProviders.ALIBABA_C } /// - public override Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override async Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { var additionalModels = new[] { @@ -100,17 +99,21 @@ public sealed class ProviderAlibabaCloud() : BaseProvider(LLMProviders.ALIBABA_C new Model("qwen2.5-vl-3b-instruct", "Qwen2.5-VL 3b"), }; - return this.LoadModels(["q"], SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional).ContinueWith(t => t.Result.Concat(additionalModels).OrderBy(x => x.Id).AsEnumerable(), token); + var result = await this.LoadModels(["q"], SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional); + return result with + { + Models = [..result.Models.Concat(additionalModels).OrderBy(x => x.Id)] + }; } /// - public override Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } /// - public override Task> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override async Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) { var additionalModels = new[] @@ -118,45 +121,33 @@ public sealed class ProviderAlibabaCloud() : BaseProvider(LLMProviders.ALIBABA_C new Model("text-embedding-v3", "text-embedding-v3"), }; - return this.LoadModels(["text-embedding-"], SecretStoreType.EMBEDDING_PROVIDER, token, apiKeyProvisional).ContinueWith(t => t.Result.Concat(additionalModels).OrderBy(x => x.Id).AsEnumerable(), token); + var result = await this.LoadModels(["text-embedding-"], SecretStoreType.EMBEDDING_PROVIDER, token, apiKeyProvisional); + return result with + { + Models = [..result.Models.Concat(additionalModels).OrderBy(x => x.Id)] + }; } #region Overrides of BaseProvider /// - public override Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } #endregion #endregion - private async Task> LoadModels(string[] prefixes, SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null) + private Task LoadModels(string[] prefixes, SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null) { - var secretKey = apiKeyProvisional switch - { - not null => apiKeyProvisional, - _ => await RUST_SERVICE.GetAPIKey(this, storeType) switch - { - { Success: true } result => await result.Secret.Decrypt(ENCRYPTION), - _ => null, - } - }; - - if (secretKey is null) - return []; - - using var request = new HttpRequestMessage(HttpMethod.Get, "models"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); - - using var response = await this.httpClient.SendAsync(request, token); - if(!response.IsSuccessStatusCode) - return []; - - var modelResponse = await response.Content.ReadFromJsonAsync(token); - return modelResponse.Data.Where(model => prefixes.Any(prefix => model.Id.StartsWith(prefix, StringComparison.InvariantCulture))); + return this.LoadModelsResponse( + storeType, + "models", + modelResponse => modelResponse.Data.Where(model => prefixes.Any(prefix => model.Id.StartsWith(prefix, StringComparison.InvariantCulture))), + token, + apiKeyProvisional); } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs b/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs index 49a0e6ea..ea5b807e 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs @@ -1,4 +1,3 @@ -using System.Net.Http.Headers; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; @@ -124,7 +123,7 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, " } /// - public override Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override async Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { var additionalModels = new[] { @@ -136,59 +135,52 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, " new Model("claude-3-opus-latest", "Claude 3 Opus (Latest)"), }; - return this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional).ContinueWith(t => t.Result.Concat(additionalModels).OrderBy(x => x.Id).AsEnumerable(), token); + var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional); + return result with + { + Models = [..result.Models.Concat(additionalModels).OrderBy(x => x.Id)] + }; } /// - public override Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } /// - public override Task> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } /// - public override Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } #endregion - private async Task> LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null) + private Task LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null) { - var secretKey = apiKeyProvisional switch - { - not null => apiKeyProvisional, - _ => await RUST_SERVICE.GetAPIKey(this, storeType) switch + return this.LoadModelsResponse( + storeType, + "models?limit=100", + modelResponse => modelResponse.Data, + token, + apiKeyProvisional, + failureReasonSelector: (response, _) => response.StatusCode switch { - { Success: true } result => await result.Secret.Decrypt(ENCRYPTION), - _ => null, - } - }; - - if (secretKey is null) - return []; - - using var request = new HttpRequestMessage(HttpMethod.Get, "models?limit=100"); - - // Set the authorization header: - request.Headers.Add("x-api-key", secretKey); - - // Set the Anthropic version: - request.Headers.Add("anthropic-version", "2023-06-01"); - - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); - - using var response = await this.httpClient.SendAsync(request, token); - if(!response.IsSuccessStatusCode) - return []; - - var modelResponse = await response.Content.ReadFromJsonAsync(JSON_SERIALIZER_OPTIONS, token); - return modelResponse.Data; + System.Net.HttpStatusCode.Unauthorized => ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY, + System.Net.HttpStatusCode.Forbidden => ModelLoadFailureReason.AUTHENTICATION_OR_PERMISSION_ERROR, + _ => ModelLoadFailureReason.PROVIDER_UNAVAILABLE, + }, + requestConfigurator: (request, secretKey) => + { + request.Headers.Add("x-api-key", secretKey); + request.Headers.Add("anthropic-version", "2023-06-01"); + }, + jsonSerializerOptions: JSON_SERIALIZER_OPTIONS); } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index 46e43843..c414596c 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -29,7 +29,7 @@ public abstract class BaseProvider : IProvider, ISecretId /// /// The HTTP client to use it for all requests. /// - protected readonly HttpClient httpClient = new(); + protected readonly HttpClient HttpClient = new(); /// /// The logger to use. @@ -73,7 +73,7 @@ public abstract class BaseProvider : IProvider, ISecretId this.Provider = provider; // Set the base URL: - this.httpClient.BaseAddress = new(url); + this.HttpClient.BaseAddress = new(url); } #region Handling of IProvider, which all providers must implement @@ -103,16 +103,16 @@ public abstract class BaseProvider : IProvider, ISecretId public abstract Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts); /// - public abstract Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default); + public abstract Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default); /// - public abstract Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default); + public abstract Task GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default); /// - public abstract Task> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default); + public abstract Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default); /// - public abstract Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default); + public abstract Task GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default); #endregion @@ -128,6 +128,71 @@ public abstract class BaseProvider : IProvider, ISecretId public string SecretName => this.InstanceName; #endregion + + protected static ModelLoadResult SuccessfulModelLoadResult(IEnumerable models) => ModelLoadResult.FromModels(models); + + protected static ModelLoadResult FailedModelLoadResult(ModelLoadFailureReason failureReason, string? technicalDetails = null) => ModelLoadResult.Failure(failureReason, technicalDetails); + + protected async Task GetModelLoadingSecretKey(SecretStoreType storeType, string? apiKeyProvisional = null, bool isTryingSecret = false) => apiKeyProvisional switch + { + not null => apiKeyProvisional, + _ => await RUST_SERVICE.GetAPIKey(this, storeType, isTrying: isTryingSecret) switch + { + { Success: true } result => await result.Secret.Decrypt(ENCRYPTION), + _ => null, + } + }; + + protected static ModelLoadFailureReason GetDefaultModelLoadFailureReason(HttpResponseMessage response) => response.StatusCode switch + { + HttpStatusCode.Unauthorized => ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY, + HttpStatusCode.Forbidden => ModelLoadFailureReason.AUTHENTICATION_OR_PERMISSION_ERROR, + + _ => ModelLoadFailureReason.PROVIDER_UNAVAILABLE, + }; + + protected async Task LoadModelsResponse( + SecretStoreType storeType, + string requestPath, + Func> modelFactory, + CancellationToken token, + string? apiKeyProvisional = null, + Func? failureReasonSelector = null, + Action? requestConfigurator = null, + JsonSerializerOptions? jsonSerializerOptions = null, + bool isTryingSecret = false) + { + var secretKey = await this.GetModelLoadingSecretKey(storeType, apiKeyProvisional, isTryingSecret); + if (string.IsNullOrWhiteSpace(secretKey) && !isTryingSecret) + return FailedModelLoadResult(ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY, "No API key available for model loading."); + + using var request = new HttpRequestMessage(HttpMethod.Get, requestPath); + if (requestConfigurator is not null) + requestConfigurator(request, secretKey ?? string.Empty); + else if (!string.IsNullOrWhiteSpace(secretKey)) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); + + using var response = await this.HttpClient.SendAsync(request, token); + var responseBody = await response.Content.ReadAsStringAsync(token); + if (!response.IsSuccessStatusCode) + { + var failureReason = failureReasonSelector?.Invoke(response, responseBody) ?? GetDefaultModelLoadFailureReason(response); + return FailedModelLoadResult(failureReason, $"Status={(int)response.StatusCode} {response.ReasonPhrase}; Body='{responseBody}'"); + } + + try + { + var parsedResponse = JsonSerializer.Deserialize(responseBody, jsonSerializerOptions ?? JSON_SERIALIZER_OPTIONS); + if (parsedResponse is null) + return FailedModelLoadResult(ModelLoadFailureReason.INVALID_RESPONSE, "Model list response could not be deserialized."); + + return SuccessfulModelLoadResult(modelFactory(parsedResponse)); + } + catch (Exception e) + { + return FailedModelLoadResult(ModelLoadFailureReason.INVALID_RESPONSE, e.Message); + } + } /// /// Sends a request and handles rate limiting by exponential backoff. @@ -155,7 +220,7 @@ public abstract class BaseProvider : IProvider, ISecretId // Please notice: We do not dispose the response here. The caller is responsible // for disposing the response object. This is important because the response // object is used to read the stream. - var nextResponse = await this.httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token); + var nextResponse = await this.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token); if (nextResponse.IsSuccessStatusCode) { response = nextResponse; @@ -696,7 +761,7 @@ public abstract class BaseProvider : IProvider, ISecretId break; } - using var response = await this.httpClient.SendAsync(request, token); + using var response = await this.HttpClient.SendAsync(request, token); var responseBody = response.Content.ReadAsStringAsync(token).Result; if (!response.IsSuccessStatusCode) @@ -766,7 +831,7 @@ public abstract class BaseProvider : IProvider, ISecretId // Set the content: request.Content = new StringContent(embeddingRequest, Encoding.UTF8, "application/json"); - using var response = await this.httpClient.SendAsync(request, token); + using var response = await this.HttpClient.SendAsync(request, token); var responseBody = response.Content.ReadAsStringAsync(token).Result; if (!response.IsSuccessStatusCode) diff --git a/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs b/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs index bc1e0806..6d49affc 100644 --- a/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs +++ b/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs @@ -1,4 +1,3 @@ -using System.Net.Http.Headers; using System.Runtime.CompilerServices; using AIStudio.Chat; @@ -70,54 +69,38 @@ public sealed class ProviderDeepSeek() : BaseProvider(LLMProviders.DEEP_SEEK, "h } /// - public override Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { return this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional); } /// - public override Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } /// - public override Task> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } /// - public override Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } #endregion - private async Task> LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null) + private Task LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null) { - var secretKey = apiKeyProvisional switch - { - not null => apiKeyProvisional, - _ => await RUST_SERVICE.GetAPIKey(this, storeType) switch - { - { Success: true } result => await result.Secret.Decrypt(ENCRYPTION), - _ => null, - } - }; - - if (secretKey is null) - return []; - - using var request = new HttpRequestMessage(HttpMethod.Get, "models"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); - - using var response = await this.httpClient.SendAsync(request, token); - if(!response.IsSuccessStatusCode) - return []; - - var modelResponse = await response.Content.ReadFromJsonAsync(token); - return modelResponse.Data; + return this.LoadModelsResponse( + storeType, + "models", + modelResponse => modelResponse.Data, + token, + apiKeyProvisional); } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs b/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs index 0091e7a1..fae3ac62 100644 --- a/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs +++ b/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs @@ -71,33 +71,32 @@ public class ProviderFireworks() : BaseProvider(LLMProviders.FIREWORKS, "https:/ } /// - public override Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } /// - public override Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } /// - public override Task> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } /// - public override Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) { // Source: https://docs.fireworks.ai/api-reference/audio-transcriptions#param-model - return Task.FromResult>( - new List - { - new("whisper-v3", "Whisper v3"), - // new("whisper-v3-turbo", "Whisper v3 Turbo"), // does not work - }); + return Task.FromResult(ModelLoadResult.FromModels( + [ + new Model("whisper-v3", "Whisper v3"), + // new("whisper-v3-turbo", "Whisper v3 Turbo"), // does not work + ])); } #endregion diff --git a/app/MindWork AI Studio/Provider/GWDG/ProviderGWDG.cs b/app/MindWork AI Studio/Provider/GWDG/ProviderGWDG.cs index edae7ae9..3d4d7e01 100644 --- a/app/MindWork AI Studio/Provider/GWDG/ProviderGWDG.cs +++ b/app/MindWork AI Studio/Provider/GWDG/ProviderGWDG.cs @@ -1,5 +1,4 @@ -using System.Net.Http.Headers; -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; using AIStudio.Chat; using AIStudio.Provider.OpenAI; @@ -71,61 +70,55 @@ public sealed class ProviderGWDG() : BaseProvider(LLMProviders.GWDG, "https://ch } /// - public override async Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override async Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { - var models = await this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional); - return models.Where(model => !model.Id.StartsWith("e5-mistral-7b-instruct", StringComparison.InvariantCultureIgnoreCase)); + var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional); + return result with + { + Models = [..result.Models.Where(model => !model.Id.StartsWith("e5-mistral-7b-instruct", StringComparison.InvariantCultureIgnoreCase))] + }; } /// - public override Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } /// - public override async Task> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override async Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) { - var models = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, token, apiKeyProvisional); - return models.Where(model => model.Id.StartsWith("e5-", StringComparison.InvariantCultureIgnoreCase)); + var result = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, token, apiKeyProvisional); + return result with + { + Models = [..result.Models.Where(model => model.Id.StartsWith("e5-", StringComparison.InvariantCultureIgnoreCase))] + }; } /// - public override Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) { // Source: https://docs.hpc.gwdg.de/services/saia/index.html#voice-to-text - return Task.FromResult>( - new List - { - new("whisper-large-v2", "Whisper v2 Large"), - }); + return Task.FromResult(ModelLoadResult.FromModels( + [ + new Model("whisper-large-v2", "Whisper v2 Large"), + ])); } #endregion - private async Task> LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null) + private async Task LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null) { - var secretKey = apiKeyProvisional switch - { - not null => apiKeyProvisional, - _ => await RUST_SERVICE.GetAPIKey(this, storeType) switch - { - { Success: true } result => await result.Secret.Decrypt(ENCRYPTION), - _ => null, - } - }; + var result = await this.LoadModelsResponse( + storeType, + "models", + modelResponse => modelResponse.Data, + token, + apiKeyProvisional); - if (secretKey is null) - return []; - - using var request = new HttpRequestMessage(HttpMethod.Get, "models"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); + if (!result.Success) + LOGGER.LogWarning("Failed to load models for provider {ProviderId}. FailureReason: {FailureReason}. TechnicalDetails: {TechnicalDetails}", this.Id, result.FailureReason, result.TechnicalDetails); - using var response = await this.httpClient.SendAsync(request, token); - if(!response.IsSuccessStatusCode) - return []; - - var modelResponse = await response.Content.ReadFromJsonAsync(token); - return modelResponse.Data; + return result; } } diff --git a/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs b/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs index 0caf7b05..91a942d8 100644 --- a/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs +++ b/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs @@ -1,4 +1,3 @@ -using System.Net.Http.Headers; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; @@ -107,7 +106,7 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, "https://gener // Set the content: request.Content = new StringContent(embeddingRequest, Encoding.UTF8, "application/json"); - using var response = await this.httpClient.SendAsync(request, token); + using var response = await this.HttpClient.SendAsync(request, token); var responseBody = await response.Content.ReadAsStringAsync(token); if (!response.IsSuccessStatusCode) @@ -139,80 +138,64 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, "https://gener } /// - public override async Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override async Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { - var models = await this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional); - return models.Where(model => - model.Id.StartsWith("gemini-", StringComparison.OrdinalIgnoreCase) && - !this.IsEmbeddingModel(model.Id)) - .Select(this.WithDisplayNameFallback); + var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional); + return result with + { + Models = + [ + ..result.Models.Where(model => + model.Id.StartsWith("gemini-", StringComparison.OrdinalIgnoreCase) && + !this.IsEmbeddingModel(model.Id)) + .Select(this.WithDisplayNameFallback) + ] + }; } /// - public override Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } - public override async Task> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override async Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) { - var models = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, token, apiKeyProvisional); - return models.Where(model => this.IsEmbeddingModel(model.Id)) - .Select(this.WithDisplayNameFallback); + var result = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, token, apiKeyProvisional); + return result with + { + Models = + [ + ..result.Models.Where(model => this.IsEmbeddingModel(model.Id)) + .Select(this.WithDisplayNameFallback) + ] + }; } /// - public override Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } #endregion - private async Task> LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null) + private Task LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null) { - var secretKey = apiKeyProvisional switch - { - not null => apiKeyProvisional, - _ => await RUST_SERVICE.GetAPIKey(this, storeType) switch - { - { Success: true } result => await result.Secret.Decrypt(ENCRYPTION), - _ => null, - } - }; - - if (string.IsNullOrWhiteSpace(secretKey)) - return []; - - using var request = new HttpRequestMessage(HttpMethod.Get, "models"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); - - using var response = await this.httpClient.SendAsync(request, token); - if(!response.IsSuccessStatusCode) - { - LOGGER.LogError("Failed to load models with status code {ResponseStatusCode} and body: '{ResponseBody}'.", response.StatusCode, await response.Content.ReadAsStringAsync(token)); - return []; - } - - try - { - var modelResponse = await response.Content.ReadFromJsonAsync(token); - if (modelResponse == default || modelResponse.Data.Count is 0) - { - LOGGER.LogError("Google model list response did not contain a valid data array."); - return []; - } - - return modelResponse.Data + return this.LoadModelsResponse( + storeType, + "models", + modelResponse => modelResponse.Data .Where(model => !string.IsNullOrWhiteSpace(model.Id)) - .Select(model => new Model(this.NormalizeModelId(model.Id), model.DisplayName)) - .ToArray(); - } - catch (Exception e) - { - LOGGER.LogError("Failed to parse Google model list response: '{Message}'.", e.Message); - return []; - } + .Select(model => new Model(this.NormalizeModelId(model.Id), model.DisplayName)), + token, + apiKeyProvisional, + failureReasonSelector: (response, _) => response.StatusCode switch + { + System.Net.HttpStatusCode.Forbidden => ModelLoadFailureReason.AUTHENTICATION_OR_PERMISSION_ERROR, + System.Net.HttpStatusCode.Unauthorized => ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY, + _ => ModelLoadFailureReason.PROVIDER_UNAVAILABLE, + }); } private bool IsEmbeddingModel(string modelId) diff --git a/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs b/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs index d36951f0..6d9c53d7 100644 --- a/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs +++ b/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs @@ -1,4 +1,3 @@ -using System.Net.Http.Headers; using System.Runtime.CompilerServices; using AIStudio.Chat; @@ -74,57 +73,41 @@ public class ProviderGroq() : BaseProvider(LLMProviders.GROQ, "https://api.groq. } /// - public override Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { return this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional); } /// - public override Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult>([]); + return Task.FromResult(ModelLoadResult.FromModels([])); } /// - public override Task> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } /// - public override Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } #endregion - private async Task> LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null) + private Task LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null) { - var secretKey = apiKeyProvisional switch - { - not null => apiKeyProvisional, - _ => await RUST_SERVICE.GetAPIKey(this, storeType) switch - { - { Success: true } result => await result.Secret.Decrypt(ENCRYPTION), - _ => null, - } - }; - - if (secretKey is null) - return []; - - using var request = new HttpRequestMessage(HttpMethod.Get, "models"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); - - using var response = await this.httpClient.SendAsync(request, token); - if(!response.IsSuccessStatusCode) - return []; - - var modelResponse = await response.Content.ReadFromJsonAsync(token); - return modelResponse.Data.Where(n => - !n.Id.StartsWith("whisper-", StringComparison.OrdinalIgnoreCase) && - !n.Id.StartsWith("distil-", StringComparison.OrdinalIgnoreCase) && - !n.Id.Contains("-tts", StringComparison.OrdinalIgnoreCase)); + return this.LoadModelsResponse( + storeType, + "models", + modelResponse => modelResponse.Data.Where(n => + !n.Id.StartsWith("whisper-", StringComparison.OrdinalIgnoreCase) && + !n.Id.StartsWith("distil-", StringComparison.OrdinalIgnoreCase) && + !n.Id.Contains("-tts", StringComparison.OrdinalIgnoreCase)), + token, + apiKeyProvisional); } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs b/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs index bfa7a758..2b80b60f 100644 --- a/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs +++ b/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs @@ -1,5 +1,6 @@ using System.Net.Http.Headers; using System.Runtime.CompilerServices; +using System.Text.Json; using AIStudio.Chat; using AIStudio.Provider.OpenAI; @@ -71,60 +72,81 @@ public sealed class ProviderHelmholtz() : BaseProvider(LLMProviders.HELMHOLTZ, " } /// - public override async Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override async Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { - var models = await this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional); - return models.Where(model => !model.Id.StartsWith("text-", StringComparison.InvariantCultureIgnoreCase) && - !model.Id.StartsWith("alias-embedding", StringComparison.InvariantCultureIgnoreCase)); + var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional); + return result with + { + Models = + [ + ..result.Models.Where(model => !model.Id.StartsWith("text-", StringComparison.InvariantCultureIgnoreCase) && + !model.Id.Contains("-embedding", StringComparison.InvariantCultureIgnoreCase) + ) + ] + }; } /// - public override Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } /// - public override async Task> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override async Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) { - var models = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, token, apiKeyProvisional); - return models.Where(model => - model.Id.StartsWith("alias-embedding", StringComparison.InvariantCultureIgnoreCase) || - model.Id.StartsWith("text-", StringComparison.InvariantCultureIgnoreCase) || - model.Id.Contains("gritlm", StringComparison.InvariantCultureIgnoreCase)); + var result = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, token, apiKeyProvisional); + return result with + { + Models = + [ + ..result.Models.Where(model => + model.Id.Contains("-embedding", StringComparison.InvariantCultureIgnoreCase) || + model.Id.StartsWith("text-", StringComparison.InvariantCultureIgnoreCase) || + model.Id.Contains("gritlm", StringComparison.InvariantCultureIgnoreCase)) + ] + }; } /// - public override Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } #endregion - private async Task> LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null) + private async Task LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null) { - var secretKey = apiKeyProvisional switch - { - not null => apiKeyProvisional, - _ => await RUST_SERVICE.GetAPIKey(this, storeType) switch - { - { Success: true } result => await result.Secret.Decrypt(ENCRYPTION), - _ => null, - } - }; + var secretKey = await this.GetModelLoadingSecretKey(storeType, apiKeyProvisional); + if (string.IsNullOrWhiteSpace(secretKey)) + return FailedModelLoadResult(ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY, "No API key available for model loading."); - if (secretKey is null) - return []; - using var request = new HttpRequestMessage(HttpMethod.Get, "models"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); - using var response = await this.httpClient.SendAsync(request, token); - if(!response.IsSuccessStatusCode) - return []; + using var response = await this.HttpClient.SendAsync(request, token); + var body = await response.Content.ReadAsStringAsync(token); + if (!response.IsSuccessStatusCode) + return FailedModelLoadResult(GetDefaultModelLoadFailureReason(response), $"Status={(int)response.StatusCode} {response.ReasonPhrase}; Body='{body}'"); - var modelResponse = await response.Content.ReadFromJsonAsync(token); - return modelResponse.Data; + try + { + var modelResponse = JsonSerializer.Deserialize(body, JSON_SERIALIZER_OPTIONS); + return SuccessfulModelLoadResult(modelResponse.Data); + } + catch (JsonException e) + { + if (body.Contains("API key", StringComparison.InvariantCultureIgnoreCase)) + return FailedModelLoadResult(ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY, body); + + LOGGER.LogError(e, "Unexpected error while parsing models from Helmholtz API response. Status Code: {StatusCode}. Reason: {ReasonPhrase}. Response Body: '{ResponseBody}'", response.StatusCode, response.ReasonPhrase, body); + return FailedModelLoadResult(ModelLoadFailureReason.INVALID_RESPONSE, body); + } + catch (Exception e) + { + LOGGER.LogError(e, "Unexpected error while loading models from Helmholtz API. Status Code: {StatusCode}. Reason: {ReasonPhrase}", response.StatusCode, response.ReasonPhrase); + return FailedModelLoadResult(ModelLoadFailureReason.UNKNOWN, e.Message); + } } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs b/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs index c22b5c50..2cb591b2 100644 --- a/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs +++ b/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs @@ -74,27 +74,27 @@ public sealed class ProviderHuggingFace : BaseProvider } /// - public override Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } /// - public override Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } /// - public override Task> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } /// - public override Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } #endregion diff --git a/app/MindWork AI Studio/Provider/IProvider.cs b/app/MindWork AI Studio/Provider/IProvider.cs index ef15dd21..c337ec71 100644 --- a/app/MindWork AI Studio/Provider/IProvider.cs +++ b/app/MindWork AI Studio/Provider/IProvider.cs @@ -76,7 +76,7 @@ public interface IProvider /// The provisional API key to use. Useful when the user is adding a new provider. When null, the stored API key is used. /// The cancellation token. /// The list of text models. - public Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default); + public Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default); /// /// Load all possible image models that can be used with this provider. @@ -84,7 +84,7 @@ public interface IProvider /// The provisional API key to use. Useful when the user is adding a new provider. When null, the stored API key is used. /// The cancellation token. /// The list of image models. - public Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default); + public Task GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default); /// /// Load all possible embedding models that can be used with this provider. @@ -92,7 +92,7 @@ public interface IProvider /// The provisional API key to use. Useful when the user is adding a new provider. When null, the stored API key is used. /// The cancellation token. /// The list of embedding models. - public Task> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default); + public Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default); /// /// Load all possible transcription models that can be used with this provider. @@ -100,5 +100,5 @@ public interface IProvider /// The provisional API key to use. Useful when the user is adding a new provider. When null, the stored API key is used. /// >The cancellation token. /// >The list of transcription models. - public Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default); + public Task GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs b/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs index e4445300..c011375b 100644 --- a/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs +++ b/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs @@ -1,4 +1,3 @@ -using System.Net.Http.Headers; using System.Runtime.CompilerServices; using AIStudio.Chat; @@ -77,72 +76,62 @@ public sealed class ProviderMistral() : BaseProvider(LLMProviders.MISTRAL, "http } /// - public override async Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override async Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { var modelResponse = await this.LoadModelList(SecretStoreType.LLM_PROVIDER, apiKeyProvisional, token); - if(modelResponse == default) - return []; + if(!modelResponse.Success) + return modelResponse; - return modelResponse.Data.Where(n => - !n.Id.StartsWith("code", StringComparison.OrdinalIgnoreCase) && - !n.Id.Contains("embed", StringComparison.OrdinalIgnoreCase) && - !n.Id.Contains("moderation", StringComparison.OrdinalIgnoreCase)) - .Select(n => new Provider.Model(n.Id, null)); + return modelResponse with + { + Models = + [ + ..modelResponse.Models.Where(n => + !n.Id.StartsWith("code", StringComparison.OrdinalIgnoreCase) && + !n.Id.Contains("embed", StringComparison.OrdinalIgnoreCase) && + !n.Id.Contains("moderation", StringComparison.OrdinalIgnoreCase)) + ] + }; } /// - public override async Task> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override async Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) { var modelResponse = await this.LoadModelList(SecretStoreType.EMBEDDING_PROVIDER, apiKeyProvisional, token); - if(modelResponse == default) - return []; + if(!modelResponse.Success) + return modelResponse; - return modelResponse.Data.Where(n => n.Id.Contains("embed", StringComparison.InvariantCulture)) - .Select(n => new Provider.Model(n.Id, null)); + return modelResponse with + { + Models = [..modelResponse.Models.Where(n => n.Id.Contains("embed", StringComparison.InvariantCulture))] + }; } /// - public override Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } /// - public override Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) { // Source: https://docs.mistral.ai/capabilities/audio_transcription - return Task.FromResult>( - new List - { - new("voxtral-mini-latest", "Voxtral Mini Latest"), - }); + return Task.FromResult(ModelLoadResult.FromModels( + [ + new Provider.Model("voxtral-mini-latest", "Voxtral Mini Latest"), + ])); } #endregion - private async Task LoadModelList(SecretStoreType storeType, string? apiKeyProvisional, CancellationToken token) + private Task LoadModelList(SecretStoreType storeType, string? apiKeyProvisional, CancellationToken token) { - var secretKey = apiKeyProvisional switch - { - not null => apiKeyProvisional, - _ => await RUST_SERVICE.GetAPIKey(this, storeType) switch - { - { Success: true } result => await result.Secret.Decrypt(ENCRYPTION), - _ => null, - } - }; - - if (secretKey is null) - return default; - - using var request = new HttpRequestMessage(HttpMethod.Get, "models"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); - - using var response = await this.httpClient.SendAsync(request, token); - if(!response.IsSuccessStatusCode) - return default; - - var modelResponse = await response.Content.ReadFromJsonAsync(token); - return modelResponse; + return this.LoadModelsResponse( + storeType, + "models", + modelResponse => modelResponse.Data.Select(n => new Provider.Model(n.Id, null)), + token, + apiKeyProvisional); } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/ModelLoadFailureReason.cs b/app/MindWork AI Studio/Provider/ModelLoadFailureReason.cs new file mode 100644 index 00000000..b24ce1d4 --- /dev/null +++ b/app/MindWork AI Studio/Provider/ModelLoadFailureReason.cs @@ -0,0 +1,11 @@ +namespace AIStudio.Provider; + +public enum ModelLoadFailureReason +{ + NONE, + INVALID_OR_MISSING_API_KEY, + AUTHENTICATION_OR_PERMISSION_ERROR, + PROVIDER_UNAVAILABLE, + INVALID_RESPONSE, + UNKNOWN, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/ModelLoadFailureReasonExtensions.cs b/app/MindWork AI Studio/Provider/ModelLoadFailureReasonExtensions.cs new file mode 100644 index 00000000..eaf7dcb7 --- /dev/null +++ b/app/MindWork AI Studio/Provider/ModelLoadFailureReasonExtensions.cs @@ -0,0 +1,19 @@ +using AIStudio.Tools.PluginSystem; + +namespace AIStudio.Provider; + +public static class ModelLoadFailureReasonExtensions +{ + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ModelLoadFailureReasonExtensions).Namespace, nameof(ModelLoadFailureReasonExtensions)); + + public static string ToUserMessage(this ModelLoadFailureReason failureReason, string providerName) => failureReason switch + { + ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY => string.Format(TB("We could not load models from '{0}'. The API key is probably missing, invalid, or expired."), providerName), + ModelLoadFailureReason.AUTHENTICATION_OR_PERMISSION_ERROR => string.Format(TB("We could not load models from '{0}'. The account or API key does not have the required permissions."), providerName), + ModelLoadFailureReason.PROVIDER_UNAVAILABLE => string.Format(TB("We could not load models from '{0}' because the provider is currently unavailable or could not be reached."), providerName), + ModelLoadFailureReason.INVALID_RESPONSE => string.Format(TB("We could not load models from '{0}' because the provider returned an unexpected response."), providerName), + ModelLoadFailureReason.UNKNOWN => string.Format(TB("We could not load models from '{0}' due to an unknown error."), providerName), + + _ => string.Empty, + }; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/ModelLoadResult.cs b/app/MindWork AI Studio/Provider/ModelLoadResult.cs new file mode 100644 index 00000000..9bc7caa8 --- /dev/null +++ b/app/MindWork AI Studio/Provider/ModelLoadResult.cs @@ -0,0 +1,19 @@ +namespace AIStudio.Provider; + +public sealed record ModelLoadResult( + IReadOnlyList Models, + ModelLoadFailureReason FailureReason = ModelLoadFailureReason.NONE, + string? TechnicalDetails = null) +{ + public bool Success => this.FailureReason is ModelLoadFailureReason.NONE; + + public static ModelLoadResult FromModels(IEnumerable models) + { + return new([..models]); + } + + public static ModelLoadResult Failure(ModelLoadFailureReason failureReason, string? technicalDetails = null) + { + return new([], failureReason, technicalDetails); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/NoProvider.cs b/app/MindWork AI Studio/Provider/NoProvider.cs index 3fc8459c..d9f3f578 100644 --- a/app/MindWork AI Studio/Provider/NoProvider.cs +++ b/app/MindWork AI Studio/Provider/NoProvider.cs @@ -18,13 +18,13 @@ public class NoProvider : IProvider /// public string AdditionalJsonApiParameters { get; init; } = string.Empty; - public Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) => Task.FromResult>([]); + public Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) => Task.FromResult(ModelLoadResult.FromModels([])); - public Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) => Task.FromResult>([]); + public Task GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) => Task.FromResult(ModelLoadResult.FromModels([])); - public Task> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) => Task.FromResult>([]); + public Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) => Task.FromResult(ModelLoadResult.FromModels([])); - public Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) => Task.FromResult>([]); + public Task GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) => Task.FromResult(ModelLoadResult.FromModels([])); public async IAsyncEnumerable StreamChatCompletion(Model chatModel, ChatThread chatChatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default) { diff --git a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs index d0c211bb..26a0d27a 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs @@ -233,61 +233,57 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, "https } /// - public override async Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override async Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { - var models = await this.LoadModels(SecretStoreType.LLM_PROVIDER, ["chatgpt-", "gpt-", "o1-", "o3-", "o4-"], token, apiKeyProvisional); - return models.Where(model => !model.Id.Contains("image", StringComparison.OrdinalIgnoreCase) && - !model.Id.Contains("realtime", StringComparison.OrdinalIgnoreCase) && - !model.Id.Contains("audio", StringComparison.OrdinalIgnoreCase) && - !model.Id.Contains("tts", StringComparison.OrdinalIgnoreCase) && - !model.Id.Contains("transcribe", StringComparison.OrdinalIgnoreCase)); + var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, ["chatgpt-", "gpt-", "o1-", "o3-", "o4-"], token, apiKeyProvisional); + return result with + { + Models = + [ + ..result.Models.Where(model => !model.Id.Contains("image", StringComparison.OrdinalIgnoreCase) && + !model.Id.Contains("realtime", StringComparison.OrdinalIgnoreCase) && + !model.Id.Contains("audio", StringComparison.OrdinalIgnoreCase) && + !model.Id.Contains("tts", StringComparison.OrdinalIgnoreCase) && + !model.Id.Contains("transcribe", StringComparison.OrdinalIgnoreCase)) + ] + }; } /// - public override Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { return this.LoadModels(SecretStoreType.IMAGE_PROVIDER, ["dall-e-", "gpt-image"], token, apiKeyProvisional); } /// - public override Task> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) { return this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, ["text-embedding-"], token, apiKeyProvisional); } /// - public override async Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override async Task GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) { - var models = await this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, ["whisper-", "gpt-"], token, apiKeyProvisional); - return models.Where(model => model.Id.StartsWith("whisper-", StringComparison.InvariantCultureIgnoreCase) || - model.Id.Contains("-transcribe", StringComparison.InvariantCultureIgnoreCase)); + var result = await this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, ["whisper-", "gpt-"], token, apiKeyProvisional); + return result with + { + Models = + [ + ..result.Models.Where(model => model.Id.StartsWith("whisper-", StringComparison.InvariantCultureIgnoreCase) || + model.Id.Contains("-transcribe", StringComparison.InvariantCultureIgnoreCase)) + ] + }; } #endregion - private async Task> LoadModels(SecretStoreType storeType, string[] prefixes, CancellationToken token, string? apiKeyProvisional = null) + private Task LoadModels(SecretStoreType storeType, string[] prefixes, CancellationToken token, string? apiKeyProvisional = null) { - var secretKey = apiKeyProvisional switch - { - not null => apiKeyProvisional, - _ => await RUST_SERVICE.GetAPIKey(this, storeType) switch - { - { Success: true } result => await result.Secret.Decrypt(ENCRYPTION), - _ => null, - } - }; - - if (secretKey is null) - return []; - - using var request = new HttpRequestMessage(HttpMethod.Get, "models"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); - - using var response = await this.httpClient.SendAsync(request, token); - if(!response.IsSuccessStatusCode) - return []; - - var modelResponse = await response.Content.ReadFromJsonAsync(token); - return modelResponse.Data.Where(model => prefixes.Any(prefix => model.Id.StartsWith(prefix, StringComparison.InvariantCulture))); + return this.LoadModelsResponse( + storeType, + "models", + modelResponse => modelResponse.Data.Where(model => prefixes.Any(prefix => model.Id.StartsWith(prefix, StringComparison.InvariantCulture))), + token, + apiKeyProvisional); } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs b/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs index 9f2c1b13..9ee8b736 100644 --- a/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs +++ b/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs @@ -81,102 +81,70 @@ public sealed class ProviderOpenRouter() : BaseProvider(LLMProviders.OPEN_ROUTER } /// - public override Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { return this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional); } /// - public override Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } /// - public override Task> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) { return this.LoadEmbeddingModels(token, apiKeyProvisional); } /// - public override Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } #endregion - private async Task> LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null) + private Task LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null) { - var secretKey = apiKeyProvisional switch - { - not null => apiKeyProvisional, - _ => await RUST_SERVICE.GetAPIKey(this, storeType) switch + return this.LoadModelsResponse( + storeType, + "models", + modelResponse => modelResponse.Data + .Where(n => + !n.Id.Contains("whisper", StringComparison.OrdinalIgnoreCase) && + !n.Id.Contains("dall-e", StringComparison.OrdinalIgnoreCase) && + !n.Id.Contains("tts", StringComparison.OrdinalIgnoreCase) && + !n.Id.Contains("embedding", StringComparison.OrdinalIgnoreCase) && + !n.Id.Contains("moderation", StringComparison.OrdinalIgnoreCase) && + !n.Id.Contains("stable-diffusion", StringComparison.OrdinalIgnoreCase) && + !n.Id.Contains("flux", StringComparison.OrdinalIgnoreCase) && + !n.Id.Contains("midjourney", StringComparison.OrdinalIgnoreCase)) + .Select(n => new Model(n.Id, n.Name)), + token, + apiKeyProvisional, + requestConfigurator: (request, secretKey) => { - { Success: true } result => await result.Secret.Decrypt(ENCRYPTION), - _ => null, - } - }; - - if (secretKey is null) - return []; - - using var request = new HttpRequestMessage(HttpMethod.Get, "models"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); - - // Set custom headers for project identification: - request.Headers.Add("HTTP-Referer", PROJECT_WEBSITE); - request.Headers.Add("X-Title", PROJECT_NAME); - - using var response = await this.httpClient.SendAsync(request, token); - if(!response.IsSuccessStatusCode) - return []; - - var modelResponse = await response.Content.ReadFromJsonAsync(token); - - // Filter out non-text models (image, audio, embedding models) and convert to Model - return modelResponse.Data - .Where(n => - !n.Id.Contains("whisper", StringComparison.OrdinalIgnoreCase) && - !n.Id.Contains("dall-e", StringComparison.OrdinalIgnoreCase) && - !n.Id.Contains("tts", StringComparison.OrdinalIgnoreCase) && - !n.Id.Contains("embedding", StringComparison.OrdinalIgnoreCase) && - !n.Id.Contains("moderation", StringComparison.OrdinalIgnoreCase) && - !n.Id.Contains("stable-diffusion", StringComparison.OrdinalIgnoreCase) && - !n.Id.Contains("flux", StringComparison.OrdinalIgnoreCase) && - !n.Id.Contains("midjourney", StringComparison.OrdinalIgnoreCase)) - .Select(n => new Model(n.Id, n.Name)); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); + request.Headers.Add("HTTP-Referer", PROJECT_WEBSITE); + request.Headers.Add("X-Title", PROJECT_NAME); + }); } - private async Task> LoadEmbeddingModels(CancellationToken token, string? apiKeyProvisional = null) + private Task LoadEmbeddingModels(CancellationToken token, string? apiKeyProvisional = null) { - var secretKey = apiKeyProvisional switch - { - not null => apiKeyProvisional, - _ => await RUST_SERVICE.GetAPIKey(this, SecretStoreType.EMBEDDING_PROVIDER) switch + return this.LoadModelsResponse( + SecretStoreType.EMBEDDING_PROVIDER, + "embeddings/models", + modelResponse => modelResponse.Data.Select(n => new Model(n.Id, n.Name)), + token, + apiKeyProvisional, + requestConfigurator: (request, secretKey) => { - { Success: true } result => await result.Secret.Decrypt(ENCRYPTION), - _ => null, - } - }; - - if (secretKey is null) - return []; - - using var request = new HttpRequestMessage(HttpMethod.Get, "embeddings/models"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); - - // Set custom headers for project identification: - request.Headers.Add("HTTP-Referer", PROJECT_WEBSITE); - request.Headers.Add("X-Title", PROJECT_NAME); - - using var response = await this.httpClient.SendAsync(request, token); - if(!response.IsSuccessStatusCode) - return []; - - var modelResponse = await response.Content.ReadFromJsonAsync(token); - - // Convert all embedding models to Model - return modelResponse.Data.Select(n => new Model(n.Id, n.Name)); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); + request.Headers.Add("HTTP-Referer", PROJECT_WEBSITE); + request.Headers.Add("X-Title", PROJECT_NAME); + }); } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Perplexity/ProviderPerplexity.cs b/app/MindWork AI Studio/Provider/Perplexity/ProviderPerplexity.cs index 745dd974..d371cf50 100644 --- a/app/MindWork AI Studio/Provider/Perplexity/ProviderPerplexity.cs +++ b/app/MindWork AI Studio/Provider/Perplexity/ProviderPerplexity.cs @@ -77,30 +77,30 @@ public sealed class ProviderPerplexity() : BaseProvider(LLMProviders.PERPLEXITY, } /// - public override Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { return this.LoadModels(); } /// - public override Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } /// - public override Task> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } /// - public override Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } #endregion - private Task> LoadModels() => Task.FromResult>(KNOWN_MODELS); -} + private Task LoadModels() => Task.FromResult(ModelLoadResult.FromModels(KNOWN_MODELS)); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs index 01e86cc3..86e00a26 100644 --- a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs +++ b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs @@ -81,7 +81,7 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide return await this.PerformStandardTextEmbeddingRequest(requestedSecret, embeddingModel, host, token: token, texts: texts); } - public override async Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override async Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { try { @@ -90,7 +90,7 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide case Host.LLAMA_CPP: // Right now, llama.cpp only supports one model. // There is no API to list the model(s). - return [ new Provider.Model("as configured by llama.cpp", null) ]; + return ModelLoadResult.FromModels([ new Provider.Model("as configured by llama.cpp", null) ]); case Host.LM_STUDIO: case Host.OLLAMA: @@ -98,22 +98,22 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide return await this.LoadModels( SecretStoreType.LLM_PROVIDER, ["embed"], [], token, apiKeyProvisional); } - return []; + return ModelLoadResult.FromModels([]); } catch(Exception e) { LOGGER.LogError($"Failed to load text models from self-hosted provider: {e.Message}"); - return []; + return ModelLoadResult.Failure(ModelLoadFailureReason.UNKNOWN, e.Message); } } /// - public override Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } - public override async Task> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override async Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) { try { @@ -125,69 +125,61 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide return await this.LoadModels( SecretStoreType.EMBEDDING_PROVIDER, [], ["embed"], token, apiKeyProvisional); } - return []; + return ModelLoadResult.FromModels([]); } catch(Exception e) { LOGGER.LogError($"Failed to load text models from self-hosted provider: {e.Message}"); - return []; + return ModelLoadResult.Failure(ModelLoadFailureReason.UNKNOWN, e.Message); } } /// - public override async Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override async Task GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) { try { switch (host) { case Host.WHISPER_CPP: - return new List - { - new("loaded-model", TB("Model as configured by whisper.cpp")), - }; + return ModelLoadResult.FromModels( + [ + new Provider.Model("loaded-model", TB("Model as configured by whisper.cpp")), + ]); case Host.OLLAMA: case Host.VLLM: return await this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, [], [], token, apiKeyProvisional); default: - return []; + return ModelLoadResult.FromModels([]); } } catch (Exception e) { LOGGER.LogError($"Failed to load transcription models from self-hosted provider: {e.Message}"); - return []; + return ModelLoadResult.Failure(ModelLoadFailureReason.UNKNOWN, e.Message); } } #endregion - private async Task> LoadModels(SecretStoreType storeType, string[] ignorePhrases, string[] filterPhrases, CancellationToken token, string? apiKeyProvisional = null) + private async Task LoadModels(SecretStoreType storeType, string[] ignorePhrases, string[] filterPhrases, CancellationToken token, string? apiKeyProvisional = null) { - var secretKey = apiKeyProvisional switch - { - not null => apiKeyProvisional, - _ => await RUST_SERVICE.GetAPIKey(this, storeType, isTrying: true) switch - { - { Success: true } result => await result.Secret.Decrypt(ENCRYPTION), - _ => null, - } - }; + var secretKey = await this.GetModelLoadingSecretKey(storeType, apiKeyProvisional, true); using var lmStudioRequest = new HttpRequestMessage(HttpMethod.Get, "models"); if(secretKey is not null) - lmStudioRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKeyProvisional); + lmStudioRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); - using var lmStudioResponse = await this.httpClient.SendAsync(lmStudioRequest, token); + using var lmStudioResponse = await this.HttpClient.SendAsync(lmStudioRequest, token); if(!lmStudioResponse.IsSuccessStatusCode) - return []; + return FailedModelLoadResult(GetDefaultModelLoadFailureReason(lmStudioResponse), $"Status={(int)lmStudioResponse.StatusCode} {lmStudioResponse.ReasonPhrase}"); var lmStudioModelResponse = await lmStudioResponse.Content.ReadFromJsonAsync(token); - return lmStudioModelResponse.Data. + return SuccessfulModelLoadResult(lmStudioModelResponse.Data. Where(model => !ignorePhrases.Any(ignorePhrase => model.Id.Contains(ignorePhrase, StringComparison.InvariantCulture)) && filterPhrases.All( filter => model.Id.Contains(filter, StringComparison.InvariantCulture))) - .Select(n => new Provider.Model(n.Id, null)); + .Select(n => new Provider.Model(n.Id, null))); } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/X/ProviderX.cs b/app/MindWork AI Studio/Provider/X/ProviderX.cs index 8c1685ee..e73781ad 100644 --- a/app/MindWork AI Studio/Provider/X/ProviderX.cs +++ b/app/MindWork AI Studio/Provider/X/ProviderX.cs @@ -1,4 +1,3 @@ -using System.Net.Http.Headers; using System.Runtime.CompilerServices; using AIStudio.Chat; @@ -71,67 +70,49 @@ public sealed class ProviderX() : BaseProvider(LLMProviders.X, "https://api.x.ai } /// - public override async Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override async Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { - var models = await this.LoadModels(SecretStoreType.LLM_PROVIDER, ["grok-"], token, apiKeyProvisional); - return models.Where(n => !n.Id.Contains("-image", StringComparison.OrdinalIgnoreCase)); + var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, ["grok-"], token, apiKeyProvisional); + return result with + { + Models = [..result.Models.Where(n => !n.Id.Contains("-image", StringComparison.OrdinalIgnoreCase))] + }; } /// - public override Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult>([]); + return Task.FromResult(ModelLoadResult.FromModels([])); } /// - public override Task> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult>([]); + return Task.FromResult(ModelLoadResult.FromModels([])); } /// - public override Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(ModelLoadResult.FromModels([])); } #endregion - private async Task> LoadModels(SecretStoreType storeType, string[] prefixes, CancellationToken token, string? apiKeyProvisional = null) + private Task LoadModels(SecretStoreType storeType, string[] prefixes, CancellationToken token, string? apiKeyProvisional = null) { - var secretKey = apiKeyProvisional switch - { - not null => apiKeyProvisional, - _ => await RUST_SERVICE.GetAPIKey(this, storeType) switch - { - { Success: true } result => await result.Secret.Decrypt(ENCRYPTION), - _ => null, - } - }; - - if (secretKey is null) - return []; - - using var request = new HttpRequestMessage(HttpMethod.Get, "models"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); - - using var response = await this.httpClient.SendAsync(request, token); - if(!response.IsSuccessStatusCode) - return []; - - var modelResponse = await response.Content.ReadFromJsonAsync(token); - - // - // The API does not return the alias model names, so we have to add them manually: - // Right now, the only alias to add is `grok-2-latest`. - // - return modelResponse.Data.Where(model => prefixes.Any(prefix => model.Id.StartsWith(prefix, StringComparison.InvariantCulture))) - .Concat([ - new Model - { - Id = "grok-2-latest", - DisplayName = "Grok 2.0 (latest)", - } - ]); + return this.LoadModelsResponse( + storeType, + "models", + modelResponse => modelResponse.Data.Where(model => prefixes.Any(prefix => model.Id.StartsWith(prefix, StringComparison.InvariantCulture))) + .Concat([ + new Model + { + Id = "grok-2-latest", + DisplayName = "Grok 2.0 (latest)", + } + ]), + token, + apiKeyProvisional); } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md index cc2043b4..bc14c4c7 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md @@ -24,6 +24,7 @@ - Improved the logbook reliability by significantly reducing duplicate log entries. - Improved file attachments in chats: configuration and project files such as `Dockerfile`, `Caddyfile`, `Makefile`, or `Jenkinsfile` are now included more reliably when you send them to the AI. - Improved the validation of additional API parameters in the advanced provider settings to help catch formatting mistakes earlier. +- Improved the model checks and model list loading by showing clearer error messages when AI Studio cannot access a provider because the API key is missing, invalid, expired, or lacks the required permissions. - Improved the app startup resilience by allowing AI Studio to continue without Qdrant if it fails to initialize. - Improved the translation assistant by updating the system and user prompts. - Improved OpenAI-compatible providers by refactoring their streaming request handling to be more consistent and reliable.