Classify embedding failures at the provider level

This commit is contained in:
Thorsten Sommer 2026-09-06 15:44:01 +02:00
parent 9875fbc646
commit 4e67504bca
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
11 changed files with 230 additions and 36 deletions

View File

@ -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<IReadOnlyList<float>> 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)
{

View File

@ -194,7 +194,7 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, n
/// <inhertidoc />
public override Task<IReadOnlyList<IReadOnlyList<float>>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List<string> texts)
{
return Task.FromResult<IReadOnlyList<IReadOnlyList<float>>>([]);
throw this.CreateEmbeddingsNotSupportedException();
}
/// <inheritdoc />

View File

@ -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,
};
/// <summary>
/// Builds the failure a provider reports when it offers no embeddings at all.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
protected ProviderRequestException CreateEmbeddingsNotSupportedException() => new(ProviderRequestFailureReason.EMBEDDINGS_NOT_SUPPORTED,
this.GetProviderRequestFailureUserMessage(ProviderRequestFailureReason.EMBEDDINGS_NOT_SUPPORTED));
/// <summary>
/// Builds the failure of an embedding request the provider answered with an error.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
/// <summary>
/// Builds the failure of an embedding request which did not get an answer at all.
/// </summary>
/// <param name="exception">What went wrong while the request was on its way.</param>
/// <param name="isTimeout">Whether the provider took longer than we were willing to wait.</param>
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);
}
/// <summary>
/// Classifies why an embedding request failed.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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,
};
}
/// <summary>
/// Recognizes the answer a provider gives when the text was longer than the model accepts.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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
/// </remarks>
/// <param name="responseBody">The body of the failed response.</param>
/// <returns>The message, or an empty string when the body carries none.</returns>
private static string ReadProviderErrorMessage(string responseBody)
/// <summary>
/// Reads what the provider itself said about a failure out of its error response.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<EmbeddingResponse>(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);
}
}

View File

@ -69,7 +69,7 @@ public sealed class ProviderDeepSeek() : BaseProvider(LLMProviders.DEEP_SEEK, ne
/// <inhertidoc />
public override Task<IReadOnlyList<IReadOnlyList<float>>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List<string> texts)
{
return Task.FromResult<IReadOnlyList<IReadOnlyList<float>>>([]);
throw this.CreateEmbeddingsNotSupportedException();
}
/// <inheritdoc />

View File

@ -71,7 +71,7 @@ public class ProviderFireworks() : BaseProvider(LLMProviders.FIREWORKS, new Uri(
/// <inhertidoc />
public override Task<IReadOnlyList<IReadOnlyList<float>>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List<string> texts)
{
return Task.FromResult<IReadOnlyList<IReadOnlyList<float>>>([]);
throw this.CreateEmbeddingsNotSupportedException();
}
/// <inheritdoc />

View File

@ -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<GoogleEmbeddingResponse>(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);
}
}

View File

@ -74,7 +74,7 @@ public class ProviderGroq() : BaseProvider(LLMProviders.GROQ, new Uri("https://a
/// <inhertidoc />
public override Task<IReadOnlyList<IReadOnlyList<float>>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List<string> texts)
{
return Task.FromResult<IReadOnlyList<IReadOnlyList<float>>>([]);
throw this.CreateEmbeddingsNotSupportedException();
}
/// <inheritdoc />

View File

@ -63,7 +63,7 @@ public sealed class ProviderHetzner() : BaseProvider(LLMProviders.HETZNER, new U
/// <inheritdoc />
public override Task<IReadOnlyList<IReadOnlyList<float>>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List<string> texts)
{
return Task.FromResult<IReadOnlyList<IReadOnlyList<float>>>([]);
throw this.CreateEmbeddingsNotSupportedException();
}
/// <inheritdoc />

View File

@ -77,7 +77,7 @@ public sealed class ProviderPerplexity() : BaseProvider(LLMProviders.PERPLEXITY,
/// <inhertidoc />
public override Task<IReadOnlyList<IReadOnlyList<float>>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List<string> texts)
{
return Task.FromResult<IReadOnlyList<IReadOnlyList<float>>>([]);
throw this.CreateEmbeddingsNotSupportedException();
}
/// <inheritdoc />

View File

@ -14,4 +14,56 @@ public enum ProviderRequestFailureReason
/// meant to answer for it does not offer it.
/// </remarks>
MODEL_NOT_SUPPORTED_BY_PROVIDER,
/// <summary>
/// No usable API key was available, or the provider rejected the one we sent.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
INVALID_OR_MISSING_API_KEY,
/// <summary>
/// The key was accepted, but the account is not allowed to do what we asked for.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
AUTHENTICATION_OR_PERMISSION_ERROR,
/// <summary>
/// The provider could not be reached, or said that it cannot serve requests right now.
/// </summary>
PROVIDER_UNAVAILABLE,
/// <summary>
/// The provider does not know the requested model at all.
/// </summary>
MODEL_NOT_FOUND,
/// <summary>
/// The text we sent was longer than the model accepts.
/// </summary>
CONTEXT_LENGTH_EXCEEDED,
/// <summary>
/// The provider cannot create embeddings at all.
/// </summary>
EMBEDDINGS_NOT_SUPPORTED,
/// <summary>
/// The provider answered successfully, but with something we were not able to read.
/// </summary>
INVALID_RESPONSE,
/// <summary>
/// The request failed and we were not able to tell why.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
UNKNOWN,
}

View File

@ -70,7 +70,7 @@ public sealed class ProviderX() : BaseProvider(LLMProviders.X, new Uri("https://
/// <inhertidoc />
public override Task<IReadOnlyList<IReadOnlyList<float>>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List<string> texts)
{
return Task.FromResult<IReadOnlyList<IReadOnlyList<float>>>([]);
throw this.CreateEmbeddingsNotSupportedException();
}
/// <inheritdoc />