diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs index 09de943a..38999b40 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs @@ -215,7 +215,21 @@ public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase return; var embeddingProvider = provider.CreateProvider(); - var embeddings = await embeddingProvider.EmbedTextAsync(provider.Model, this.SettingsManager, CancellationToken.None, inputText); + IReadOnlyList> embeddings; + try + { + embeddings = await embeddingProvider.EmbedTextAsync(provider.Model, this.SettingsManager, CancellationToken.None, inputText); + } + catch (ProviderRequestException exception) + { + // + // The provider named what went wrong and what to do about it. Showing that beats the + // sentence below, which used to be the same one for a missing API key, an unreachable + // provider and a provider which cannot embed anything at all: + // + await this.DialogService.ShowMessageBox(T("Embedding Result"), exception.UserMessage, T("Close")); + return; + } if (embeddings.Count == 0) { diff --git a/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs b/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs index d7b014bc..df637c4d 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs @@ -194,7 +194,7 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, n /// public override Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) { - return Task.FromResult>>([]); + throw this.CreateEmbeddingsNotSupportedException(); } /// diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index 1750189f..3ff4de26 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -238,9 +238,106 @@ public abstract class BaseProvider : IProvider, ISecretId protected virtual string GetProviderRequestFailureUserMessage(ProviderRequestFailureReason failureReason) => failureReason switch { ProviderRequestFailureReason.TOO_MANY_REQUESTS => TB("The provider rejected the request because too many requests were sent. Please wait a moment and try again."), + ProviderRequestFailureReason.INVALID_OR_MISSING_API_KEY => string.Format(TB("The API key for the provider '{0}' is missing or was rejected. Please check the key in the settings."), this.InstanceName), + ProviderRequestFailureReason.AUTHENTICATION_OR_PERMISSION_ERROR => string.Format(TB("The provider '{0}' refused the request. Your account might not be allowed to use the selected model, or the provider might not serve your region."), this.InstanceName), + ProviderRequestFailureReason.PROVIDER_UNAVAILABLE => string.Format(TB("The provider '{0}' could not be reached. Please check whether it is running and reachable, then try again."), this.InstanceName), + ProviderRequestFailureReason.MODEL_NOT_FOUND => string.Format(TB("The provider '{0}' does not know the selected model. Please select another model."), this.InstanceName), + ProviderRequestFailureReason.CONTEXT_LENGTH_EXCEEDED => TB("The text was longer than the selected model accepts. Please select a model which takes longer texts, or reduce the chunk size of the data source."), + ProviderRequestFailureReason.EMBEDDINGS_NOT_SUPPORTED => string.Format(TB("The provider '{0}' cannot create embeddings. Please select a provider which offers an embedding model."), this.InstanceName), + ProviderRequestFailureReason.INVALID_RESPONSE => string.Format(TB("The provider '{0}' sent an answer AI Studio was not able to read."), this.InstanceName), _ => string.Empty, }; + /// + /// Builds the failure a provider reports when it offers no embeddings at all. + /// + /// + /// Such a provider used to answer with an empty list, which the caller was not able to tell + /// apart from a provider which simply produced nothing this time. Saying it outright is what + /// lets the user go and pick a provider which can do the job. + /// + protected ProviderRequestException CreateEmbeddingsNotSupportedException() => new(ProviderRequestFailureReason.EMBEDDINGS_NOT_SUPPORTED, + this.GetProviderRequestFailureUserMessage(ProviderRequestFailureReason.EMBEDDINGS_NOT_SUPPORTED)); + + /// + /// Builds the failure of an embedding request the provider answered with an error. + /// + /// + /// Shared with the providers which talk to an embedding endpoint of their own: what the user + /// needs to know does not depend on which route the request took. + /// + protected ProviderRequestException CreateEmbeddingRequestException(HttpStatusCode statusCode, string reasonPhrase, string responseBody) + { + var failureReason = this.ClassifyEmbeddingRequestFailure(statusCode, responseBody); + var userMessage = this.GetProviderRequestFailureUserMessage(failureReason); + + // We know nothing about this failure, so we pass on what the provider said about it: + if (string.IsNullOrWhiteSpace(userMessage)) + { + var providerMessage = ReadProviderErrorMessage(responseBody); + userMessage = string.IsNullOrWhiteSpace(providerMessage) + ? string.Format(TB("The provider '{0}' rejected the embedding request with the status code {1}."), this.InstanceName, (int)statusCode) + : string.Format(TB("The provider '{0}' reported an error: {1}"), this.InstanceName, providerMessage); + } + + return new(failureReason, userMessage, statusCode, reasonPhrase, responseBody); + } + + /// + /// Builds the failure of an embedding request which did not get an answer at all. + /// + /// What went wrong while the request was on its way. + /// Whether the provider took longer than we were willing to wait. + protected ProviderRequestException CreateEmbeddingRequestException(Exception exception, bool isTimeout) + { + if (isTimeout) + return new(ProviderRequestFailureReason.PROVIDER_UNAVAILABLE, this.GetProviderRequestFailureUserMessage(ProviderRequestFailureReason.PROVIDER_UNAVAILABLE), responseBody: exception.Message); + + return new(ProviderRequestFailureReason.UNKNOWN, string.Format(TB("The embedding request to the provider '{0}' failed: {1}"), this.InstanceName, exception.Message), responseBody: exception.Message); + } + + /// + /// Classifies why an embedding request failed. + /// + /// + /// Kept apart from the chat classification on purpose. The chat path turns most failures into + /// a message and carries on, so classifying more cases there would change what every user + /// sees. The embedding path has no such fallback: it either produces vectors or it fails, and + /// then the caller has to be able to say why. + /// + private ProviderRequestFailureReason ClassifyEmbeddingRequestFailure(HttpStatusCode statusCode, string responseBody) + { + // + // Whatever the shared classification recognizes wins: it knows what a provider says about + // quota and rate limits, and several providers refine it for their own error format. + // + var sharedFailureReason = this.ClassifyProviderRequestFailure(statusCode, responseBody); + if (sharedFailureReason is not ProviderRequestFailureReason.NONE) + return sharedFailureReason; + + return statusCode switch + { + HttpStatusCode.Unauthorized => ProviderRequestFailureReason.INVALID_OR_MISSING_API_KEY, + HttpStatusCode.Forbidden => ProviderRequestFailureReason.AUTHENTICATION_OR_PERMISSION_ERROR, + HttpStatusCode.NotFound => ProviderRequestFailureReason.MODEL_NOT_FOUND, + HttpStatusCode.RequestEntityTooLarge => ProviderRequestFailureReason.CONTEXT_LENGTH_EXCEEDED, + HttpStatusCode.BadRequest when IsContextLengthFailure(responseBody) => ProviderRequestFailureReason.CONTEXT_LENGTH_EXCEEDED, + HttpStatusCode.RequestTimeout or HttpStatusCode.InternalServerError or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout => ProviderRequestFailureReason.PROVIDER_UNAVAILABLE, + _ => ProviderRequestFailureReason.UNKNOWN, + }; + } + + /// + /// Recognizes the answer a provider gives when the text was longer than the model accepts. + /// + /// + /// There is no common error code for this. What the answers have in common is that they talk + /// about the context and about tokens, which is the same hint the chat path goes by. + /// + private static bool IsContextLengthFailure(string responseBody) => + responseBody.Contains("context", StringComparison.InvariantCultureIgnoreCase) && + responseBody.Contains("token", StringComparison.InvariantCultureIgnoreCase); + protected virtual ProviderRequestFailureReason ClassifyProviderRequestFailure(HttpStatusCode statusCode, string responseBody) { if (statusCode is not HttpStatusCode.TooManyRequests) @@ -401,7 +498,14 @@ public abstract class BaseProvider : IProvider, ISecretId /// /// The body of the failed response. /// The message, or an empty string when the body carries none. - private static string ReadProviderErrorMessage(string responseBody) + /// + /// Reads what the provider itself said about a failure out of its error response. + /// + /// + /// Available to the providers because some of them talk to an endpoint of their own rather + /// than through the shared request methods, and their users deserve the same explanation. + /// + protected static string ReadProviderErrorMessage(string responseBody) { if (string.IsNullOrWhiteSpace(responseBody)) return string.Empty; @@ -1339,9 +1443,9 @@ public abstract class BaseProvider : IProvider, ISecretId if(!requestedSecret.Success) { this.logger.LogError("No valid API key available for embedding request."); - return []; + throw new ProviderRequestException(ProviderRequestFailureReason.INVALID_OR_MISSING_API_KEY, this.GetProviderRequestFailureUserMessage(ProviderRequestFailureReason.INVALID_OR_MISSING_API_KEY)); } - + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(Program.ENCRYPTION)); break; } @@ -1354,21 +1458,13 @@ public abstract class BaseProvider : IProvider, ISecretId if (!response.IsSuccessStatusCode) { this.logger.LogError("Embedding request failed with status code {ResponseStatusCode} and body: '{ResponseBody}'.", response.StatusCode, responseBody); - var providerRequestFailure = this.ClassifyProviderRequestFailure(response.StatusCode, responseBody); - var userMessage = this.GetProviderRequestFailureUserMessage(providerRequestFailure); - // We know nothing about this failure, so we pass on what the provider said about it: - if (string.IsNullOrWhiteSpace(userMessage)) - { - var providerMessage = ReadProviderErrorMessage(responseBody); - if (!string.IsNullOrWhiteSpace(providerMessage)) - userMessage = string.Format(TB("The provider '{0}' reported an error: {1}"), this.InstanceName, providerMessage); - } - - if (!string.IsNullOrWhiteSpace(userMessage)) - await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, userMessage)); - - return []; + // + // Thrown instead of shown: the caller knows whether this is one file out of + // thousands being indexed in the background or the one thing the user just asked + // for, and only it can decide how often the user should hear about it. + // + throw this.CreateEmbeddingRequestException(response.StatusCode, response.ReasonPhrase ?? string.Empty, responseBody); } var embeddingResponse = JsonSerializer.Deserialize(responseBody, JSON_SERIALIZER_OPTIONS); @@ -1382,16 +1478,32 @@ public abstract class BaseProvider : IProvider, ISecretId else { this.logger.LogError("Was not able to deserialize the embedding response."); - return []; + throw new ProviderRequestException(ProviderRequestFailureReason.INVALID_RESPONSE, this.GetProviderRequestFailureUserMessage(ProviderRequestFailureReason.INVALID_RESPONSE)); } } + catch (ProviderRequestException) + { + // Already classified and carrying its user message. Wrapping it again would only + // replace what we know with the fact that something went wrong: + throw; + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // + // The caller stopped the work, e.g. because the user removed the data source while it + // was being indexed. That is not a failure of the provider and must not be recorded + // as one: + // + throw; + } catch (Exception e) { - if (this.IsTimeoutException(e, token)) + var isTimeout = this.IsTimeoutException(e, token); + if (isTimeout) await this.SendTimeoutError("creating embeddings"); this.logger.LogError("Failed to perform embedding request: '{Message}'.", e.Message); - return []; + throw this.CreateEmbeddingRequestException(e, isTimeout); } } diff --git a/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs b/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs index 3725aae0..787e4f98 100644 --- a/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs +++ b/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs @@ -69,7 +69,7 @@ public sealed class ProviderDeepSeek() : BaseProvider(LLMProviders.DEEP_SEEK, ne /// public override Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) { - return Task.FromResult>>([]); + throw this.CreateEmbeddingsNotSupportedException(); } /// diff --git a/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs b/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs index afdbae6b..9a124ae7 100644 --- a/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs +++ b/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs @@ -71,7 +71,7 @@ public class ProviderFireworks() : BaseProvider(LLMProviders.FIREWORKS, new Uri( /// public override Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) { - return Task.FromResult>>([]); + throw this.CreateEmbeddingsNotSupportedException(); } /// diff --git a/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs b/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs index d0ca0dd9..5866a44c 100644 --- a/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs +++ b/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs @@ -79,16 +79,16 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, new Uri("https if (string.IsNullOrWhiteSpace(modelName)) { LOGGER.LogError("No model name provided for embedding request."); - return []; + throw new ProviderRequestException(ProviderRequestFailureReason.MODEL_NOT_FOUND, this.GetProviderRequestFailureUserMessage(ProviderRequestFailureReason.MODEL_NOT_FOUND)); } if (modelName.StartsWith("models/", StringComparison.OrdinalIgnoreCase)) - modelName = modelName.Substring("models/".Length); + modelName = modelName["models/".Length..]; if (!requestedSecret.Success) { LOGGER.LogError("No valid API key available for embedding request."); - return []; + throw new ProviderRequestException(ProviderRequestFailureReason.INVALID_OR_MISSING_API_KEY, this.GetProviderRequestFailureUserMessage(ProviderRequestFailureReason.INVALID_OR_MISSING_API_KEY)); } // Prepare the Google Gemini embedding request: @@ -116,7 +116,7 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, new Uri("https if (!response.IsSuccessStatusCode) { LOGGER.LogError("Embedding request failed with status code {ResponseStatusCode} and body: '{ResponseBody}'.", response.StatusCode, responseBody); - return []; + throw this.CreateEmbeddingRequestException(response.StatusCode, response.ReasonPhrase ?? string.Empty, responseBody); } var embeddingResponse = JsonSerializer.Deserialize(responseBody, JSON_SERIALIZER_OPTIONS); @@ -130,17 +130,33 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, new Uri("https else { LOGGER.LogError("Was not able to deserialize the embedding response."); - return []; + throw new ProviderRequestException(ProviderRequestFailureReason.INVALID_RESPONSE, this.GetProviderRequestFailureUserMessage(ProviderRequestFailureReason.INVALID_RESPONSE)); } - + + } + catch (ProviderRequestException) + { + // Already classified and carrying its user message. Wrapping it again would only + // replace what we know with the fact that something went wrong: + throw; + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // + // The caller stopped the work, e.g. because the user removed the data source while it + // was being indexed. That is not a failure of the provider and must not be recorded + // as one: + // + throw; } catch (Exception e) { - if (this.IsTimeoutException(e, token)) + var isTimeout = this.IsTimeoutException(e, token); + if (isTimeout) await this.SendTimeoutError("creating embeddings"); LOGGER.LogError("Failed to perform embedding request: '{Message}'.", e.Message); - return []; + throw this.CreateEmbeddingRequestException(e, isTimeout); } } diff --git a/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs b/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs index f482feeb..5ce1f842 100644 --- a/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs +++ b/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs @@ -74,7 +74,7 @@ public class ProviderGroq() : BaseProvider(LLMProviders.GROQ, new Uri("https://a /// public override Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) { - return Task.FromResult>>([]); + throw this.CreateEmbeddingsNotSupportedException(); } /// diff --git a/app/MindWork AI Studio/Provider/Hetzner/ProviderHetzner.cs b/app/MindWork AI Studio/Provider/Hetzner/ProviderHetzner.cs index df517fa3..2bee06a6 100644 --- a/app/MindWork AI Studio/Provider/Hetzner/ProviderHetzner.cs +++ b/app/MindWork AI Studio/Provider/Hetzner/ProviderHetzner.cs @@ -63,7 +63,7 @@ public sealed class ProviderHetzner() : BaseProvider(LLMProviders.HETZNER, new U /// public override Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) { - return Task.FromResult>>([]); + throw this.CreateEmbeddingsNotSupportedException(); } /// diff --git a/app/MindWork AI Studio/Provider/Perplexity/ProviderPerplexity.cs b/app/MindWork AI Studio/Provider/Perplexity/ProviderPerplexity.cs index d3a10841..9374a4c9 100644 --- a/app/MindWork AI Studio/Provider/Perplexity/ProviderPerplexity.cs +++ b/app/MindWork AI Studio/Provider/Perplexity/ProviderPerplexity.cs @@ -77,7 +77,7 @@ public sealed class ProviderPerplexity() : BaseProvider(LLMProviders.PERPLEXITY, /// public override Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) { - return Task.FromResult>>([]); + throw this.CreateEmbeddingsNotSupportedException(); } /// diff --git a/app/MindWork AI Studio/Provider/ProviderRequestFailureReason.cs b/app/MindWork AI Studio/Provider/ProviderRequestFailureReason.cs index 4c5a33b4..0abd83cc 100644 --- a/app/MindWork AI Studio/Provider/ProviderRequestFailureReason.cs +++ b/app/MindWork AI Studio/Provider/ProviderRequestFailureReason.cs @@ -14,4 +14,56 @@ public enum ProviderRequestFailureReason /// meant to answer for it does not offer it. /// MODEL_NOT_SUPPORTED_BY_PROVIDER, + + /// + /// No usable API key was available, or the provider rejected the one we sent. + /// + /// + /// Both cases lead to the same place for the user: the key stored for this provider is not + /// one the provider works with, and the settings are where they fix it. + /// + INVALID_OR_MISSING_API_KEY, + + /// + /// The key was accepted, but the account is not allowed to do what we asked for. + /// + /// + /// Typical causes are a key without the required scope, a model the account has no access + /// to, and providers which refuse requests from the user's region. + /// + AUTHENTICATION_OR_PERMISSION_ERROR, + + /// + /// The provider could not be reached, or said that it cannot serve requests right now. + /// + PROVIDER_UNAVAILABLE, + + /// + /// The provider does not know the requested model at all. + /// + MODEL_NOT_FOUND, + + /// + /// The text we sent was longer than the model accepts. + /// + CONTEXT_LENGTH_EXCEEDED, + + /// + /// The provider cannot create embeddings at all. + /// + EMBEDDINGS_NOT_SUPPORTED, + + /// + /// The provider answered successfully, but with something we were not able to read. + /// + INVALID_RESPONSE, + + /// + /// The request failed and we were not able to tell why. + /// + /// + /// Deliberately without a user message of its own: what the provider itself said about the + /// failure tells the user more than a sentence which says nothing. + /// + UNKNOWN, } \ 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 668ed79e..28ecdde4 100644 --- a/app/MindWork AI Studio/Provider/X/ProviderX.cs +++ b/app/MindWork AI Studio/Provider/X/ProviderX.cs @@ -70,7 +70,7 @@ public sealed class ProviderX() : BaseProvider(LLMProviders.X, new Uri("https:// /// public override Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) { - return Task.FromResult>>([]); + throw this.CreateEmbeddingsNotSupportedException(); } ///