using System.Net; using System.Net.Http.Headers; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using AIStudio.Chat; using AIStudio.Provider.Anthropic; using AIStudio.Provider.OpenAI; using AIStudio.Provider.SelfHosted; using AIStudio.Settings; using AIStudio.Tools.ToolCallingSystem; using AIStudio.Tools.ToolCallingSystem.Harness; using AIStudio.Tools.MIME; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.Rust; using Host = AIStudio.Provider.SelfHosted.Host; namespace AIStudio.Provider; /// /// The base class for all providers. /// public abstract class BaseProvider : IProvider, ISecretId { private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(BaseProvider).Namespace, nameof(BaseProvider)); /// /// The HTTP client to use it for all requests. /// protected readonly HttpClient HttpClient; /// /// The logger to use. /// private readonly ILogger logger; protected static readonly JsonSerializerOptions JSON_SERIALIZER_OPTIONS = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower), new AnnotationConverter(), new MessageBaseConverter(), new SubContentConverter(), new SubContentImageSourceConverter(), new SubContentImageUrlConverter(), }, AllowTrailingCommas = false }; /// /// Constructor for the base provider. /// /// The provider enum value. /// The base URI for the provider. /// The trust policy for external HTTPS requests to this provider. /// The logger to use. protected BaseProvider(LLMProviders provider, Uri baseUri, ExternalHttpTrustPolicy trustPolicy, ILogger logger) { this.logger = logger; this.Provider = provider; this.BaseUri = baseUri; this.HttpClient = ExternalHttpClientTimeout.CreateHttpClient(baseUri, trustPolicy); } #region Handling of IProvider, which all providers must implement /// public LLMProviders Provider { get; } /// /// The base URI for all relative provider requests. /// public Uri BaseUri { get; } /// public abstract string Id { get; } /// public string ConfiguredProviderId { get; init; } = string.Empty; /// public abstract string InstanceName { get; set; } /// public string AdditionalJsonApiParameters { get; init; } = string.Empty; internal ProviderCapabilityOverrides? CapabilityOverrides { get; set; } /// public string TokenizerPath { get; init; } = string.Empty; /// public abstract bool HasModelLoadingCapability { get; } /// public abstract IAsyncEnumerable StreamChatCompletion(Model chatModel, ChatThread chatThread, SettingsManager settingsManager, CancellationToken token = default); /// public abstract IAsyncEnumerable StreamImageCompletion(Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, CancellationToken token = default); /// public abstract Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default); /// 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 GetImageModels(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); #endregion /// /// Whether this provider was imported from an enterprise configuration plugin. /// public bool IsEnterpriseConfiguration { get; init; } #region Implementation of ISecretId public string SecretId => this.IsEnterpriseConfiguration ? $"{ISecretId.ENTERPRISE_KEY_PREFIX}::{this.Id}" : this.Id; 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 bool IsTimeoutException(Exception exception, CancellationToken token = default) { if (token.IsCancellationRequested) return false; return ExternalHttpClientTimeout.IsTimeoutException(exception, token); } protected Task SendTimeoutError(string action) => MessageBus.INSTANCE.SendError(new( Icons.Material.Filled.HourglassTop, string.Format( TB("The request to the LLM provider '{0}' (type={1}) timed out after {2} while {3}. Please try again or check whether the provider is still responding."), this.InstanceName, this.Provider, ExternalHttpClientTimeout.GetTimeoutDescription(), action))); protected async Task GetModelLoadingSecretKey(SecretStoreType storeType, string? apiKeyProvisional = null, bool isTryingSecret = false) => apiKeyProvisional switch { not null => apiKeyProvisional, _ => await Program.RUST_SERVICE.GetAPIKey(this, storeType, isTrying: isTryingSecret) switch { { Success: true } result => await result.Secret.Decrypt(Program.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, HttpStatusCode.TooManyRequests => ModelLoadFailureReason.TOO_MANY_REQUESTS, _ => ModelLoadFailureReason.PROVIDER_UNAVAILABLE, }; protected ModelLoadFailureReason GetModelLoadFailureReason(HttpResponseMessage response, string responseBody) => this.ClassifyProviderRequestFailure(response.StatusCode, responseBody) switch { ProviderRequestFailureReason.INSUFFICIENT_QUOTA => ModelLoadFailureReason.INSUFFICIENT_QUOTA, ProviderRequestFailureReason.TOO_MANY_REQUESTS => ModelLoadFailureReason.TOO_MANY_REQUESTS, _ => GetDefaultModelLoadFailureReason(response), }; protected async Task LoadModelsResponse(SecretStoreType storeType, string requestPath, Func> modelFactory, string? apiKeyProvisional = null, Func? failureReasonSelector = null, Action? requestConfigurator = null, JsonSerializerOptions? jsonSerializerOptions = null, bool isTryingSecret = false, CancellationToken token = default) { 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); try { 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) ?? this.GetModelLoadFailureReason(response, responseBody); this.logger.LogError("Model loading request failed with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", response.StatusCode, response.ReasonPhrase, responseBody); 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); } } catch (Exception e) when (this.IsTimeoutException(e, token)) { await this.SendTimeoutError("loading the available models"); this.logger.LogError(e, "Timed out while loading models from provider '{ProviderInstanceName}' (provider={ProviderType}).", this.InstanceName, this.Provider); return FailedModelLoadResult(ModelLoadFailureReason.PROVIDER_UNAVAILABLE, e.Message); } } 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) return ProviderRequestFailureReason.NONE; return ProviderRequestFailureReason.TOO_MANY_REQUESTS; } protected virtual ProviderRequestFailureReason ClassifyProviderRequestFailure(string? errorCode, string? errorType, string? errorMessage, string responseBody) { if (IsTooManyRequestsError(errorCode) || IsTooManyRequestsError(errorType) || IsTooManyRequestsError(errorMessage)) return ProviderRequestFailureReason.TOO_MANY_REQUESTS; return ProviderRequestFailureReason.NONE; } private static bool IsTooManyRequestsError(string? value) { if (string.IsNullOrWhiteSpace(value)) return false; return value.Equals("rate_limit_exceeded", StringComparison.OrdinalIgnoreCase) || value.Equals("too_many_requests", StringComparison.OrdinalIgnoreCase) || value.Equals("too_many_request", StringComparison.OrdinalIgnoreCase) || value.Contains("too many requests", StringComparison.OrdinalIgnoreCase) || value.Contains("rate limit", StringComparison.OrdinalIgnoreCase) || value.Contains("rate_limit", StringComparison.OrdinalIgnoreCase) || value.Contains("throttl", StringComparison.OrdinalIgnoreCase); } private bool TryCreateProviderRequestExceptionFromStreamLine(string providerName, string line, out ProviderRequestException exception) { exception = new(); if (!TryGetServerSentEventData(line, out var jsonData)) return false; jsonData = jsonData.Trim(); if (string.IsNullOrWhiteSpace(jsonData) || jsonData is "[DONE]") return false; try { using var document = JsonDocument.Parse(jsonData); var root = document.RootElement; if (!IsProviderStreamFailure(root)) return false; var eventType = TryGetString(root, "type"); TryGetProviderStreamError(root, out var errorCode, out var errorType, out var errorMessage); var failureReason = this.ClassifyProviderRequestFailure(errorCode, errorType, errorMessage, jsonData); var userMessage = this.GetProviderRequestFailureUserMessage(failureReason); if (string.IsNullOrWhiteSpace(userMessage)) { userMessage = string.IsNullOrWhiteSpace(errorMessage) ? string.Format(TB("The provider '{0}' reported an error while streaming the response."), this.InstanceName) : string.Format(TB("The provider '{0}' reported an error: {1}"), this.InstanceName, errorMessage); } this.logger.LogError("The {ProviderName} stream returned an error for provider '{ProviderInstanceName}' (provider={ProviderType}). EventType={StreamEventType}, ErrorCode={ErrorCode}, ErrorType={ErrorType}, ErrorMessage='{ErrorMessage}', Body='{ErrorBody}'", providerName, this.InstanceName, this.Provider, eventType, errorCode, errorType, errorMessage, jsonData); exception = new ProviderRequestException(failureReason, userMessage, responseBody: jsonData); return true; } catch (JsonException) { return false; } } private static bool TryGetServerSentEventData(string line, out string data) { const string DATA_PREFIX = "data:"; data = string.Empty; if (!line.StartsWith(DATA_PREFIX, StringComparison.InvariantCulture)) return false; data = line[DATA_PREFIX.Length..]; if (data.StartsWith(' ')) data = data[1..]; return true; } private static bool IsProviderStreamFailure(JsonElement root) { var eventType = TryGetString(root, "type"); if (eventType is not null && ( eventType.Equals("error", StringComparison.OrdinalIgnoreCase) || eventType.Equals("response.error", StringComparison.OrdinalIgnoreCase) || eventType.Equals("response.failed", StringComparison.OrdinalIgnoreCase))) return true; if (HasObjectProperty(root, "error")) return true; if (IsTooManyRequestsError(TryGetString(root, "code")) || IsTooManyRequestsError(TryGetString(root, "type")) || IsTooManyRequestsError(TryGetString(root, "message"))) return true; if (TryGetString(root, "message") is not null && (TryGetString(root, "code") is not null || TryGetString(root, "type") is not null) && !root.TryGetProperty("choices", out _) && !root.TryGetProperty("delta", out _)) return true; if (!root.TryGetProperty("response", out var responseElement) || responseElement.ValueKind is not JsonValueKind.Object) return false; if (HasObjectProperty(responseElement, "error")) return true; var responseStatus = TryGetString(responseElement, "status"); return responseStatus is not null && responseStatus.Equals("failed", StringComparison.OrdinalIgnoreCase); } private static bool HasObjectProperty(JsonElement element, string propertyName) { return element.ValueKind is JsonValueKind.Object && element.TryGetProperty(propertyName, out var propertyElement) && propertyElement.ValueKind is JsonValueKind.Object; } private static void TryGetProviderStreamError(JsonElement root, out string? errorCode, out string? errorType, out string? errorMessage) { errorCode = null; errorType = null; errorMessage = null; if (TryGetErrorElement(root, out var errorElement)) { errorCode = TryGetString(errorElement, "code"); errorType = TryGetString(errorElement, "type"); errorMessage = TryGetString(errorElement, "message"); return; } errorCode = TryGetString(root, "code"); errorType = TryGetString(root, "type"); // // Services built on FastAPI, such as Helmholtz Blablador, word their errors as "detail". // And some providers put the sentence straight into "error" instead of an object, e.g. // {"error": "Model not supported by provider novita"}. The object form was handled above, // so reading "error" here can only meet the plain sentence: // errorMessage = TryGetString(root, "message") ?? TryGetString(root, "detail") ?? TryGetString(root, "error"); } /// /// Reads the error message a provider sent in the body of a failed response. /// /// /// Providers word their errors differently, but they all put a sentence somewhere into the /// body. Passing that sentence on is what lets a user act on the problem instead of only /// learning that something went wrong. /// /// The body of the failed response. /// The message, or an empty string when the body carries none. /// /// 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; try { using var document = JsonDocument.Parse(responseBody); TryGetProviderStreamError(document.RootElement, out _, out _, out var errorMessage); return errorMessage ?? string.Empty; } catch (JsonException) { return string.Empty; } } private static bool TryGetErrorElement(JsonElement root, out JsonElement errorElement) { if (root.ValueKind is JsonValueKind.Object && root.TryGetProperty("error", out errorElement) && errorElement.ValueKind is JsonValueKind.Object) return true; if (root.ValueKind is JsonValueKind.Object && root.TryGetProperty("response", out var responseElement) && responseElement.ValueKind is JsonValueKind.Object && responseElement.TryGetProperty("error", out errorElement) && errorElement.ValueKind is JsonValueKind.Object) return true; errorElement = default; return false; } private static string? TryGetString(JsonElement element, string propertyName) { if (element.ValueKind is not JsonValueKind.Object || !element.TryGetProperty(propertyName, out var propertyElement) || propertyElement.ValueKind is not JsonValueKind.String) return null; return propertyElement.GetString(); } /// /// Sends a request and handles rate limiting by exponential backoff. /// /// /// Two cancellation tokens, so one of them cannot be the last parameter: the user token /// survives a retry, while the request token belongs to the single attempt being made. /// /// A function that builds the request. /// The user cancellation token. /// The token to use for the HTTP request. /// The status object of the request. private async Task SendRequest(Func> requestBuilder, CancellationToken userCancellationToken = default, CancellationToken requestCancellationToken = default) { const int MAX_RETRIES = 6; const double RETRY_DELAY_SECONDS = 4; var effectiveCancellationToken = requestCancellationToken.CanBeCanceled ? requestCancellationToken : userCancellationToken; var retry = 0; var response = default(HttpResponseMessage); var errorMessage = string.Empty; var lastProviderRequestFailure = ProviderRequestFailureReason.NONE; HttpStatusCode? lastResponseStatusCode = null; var lastResponseReasonPhrase = string.Empty; var lastErrorBody = string.Empty; while (retry++ < MAX_RETRIES) { using var request = await requestBuilder(); // // Send the request with the ResponseHeadersRead option. // This allows us to read the stream as soon as the headers are received. // This is important because we want to stream the responses. // // 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. HttpResponseMessage nextResponse; try { nextResponse = await this.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, effectiveCancellationToken); } catch (Exception e) when (this.IsTimeoutException(e, userCancellationToken)) { await this.SendTimeoutError("waiting for the chat response"); this.logger.LogError(e, "Timed out while sending a streaming request to provider '{ProviderInstanceName}' (provider={ProviderType}).", this.InstanceName, this.Provider); return new HttpRateLimitedStreamResult(false, true, e.Message, response); } if (nextResponse.IsSuccessStatusCode) { response = nextResponse; errorMessage = string.Empty; lastProviderRequestFailure = ProviderRequestFailureReason.NONE; break; } var errorBody = await nextResponse.Content.ReadAsStringAsync(effectiveCancellationToken); lastResponseStatusCode = nextResponse.StatusCode; lastResponseReasonPhrase = nextResponse.ReasonPhrase ?? string.Empty; lastErrorBody = errorBody; var providerRequestFailure = this.ClassifyProviderRequestFailure(nextResponse.StatusCode, errorBody); lastProviderRequestFailure = providerRequestFailure; if (providerRequestFailure is ProviderRequestFailureReason.INSUFFICIENT_QUOTA) { var userMessage = this.GetProviderRequestFailureUserMessage(providerRequestFailure); this.logger.LogError("Failed request with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody); throw new ProviderRequestException(providerRequestFailure, userMessage, nextResponse.StatusCode, nextResponse.ReasonPhrase ?? string.Empty, errorBody); } if (nextResponse.StatusCode is HttpStatusCode.Forbidden) { await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Block, string.Format(TB("We tried to communicate with the LLM provider '{0}' (type={1}). You might not be able to use this provider from your location. The provider message is: '{2}'"), this.InstanceName, this.Provider, nextResponse.ReasonPhrase))); this.logger.LogError("Failed request with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody); errorMessage = nextResponse.ReasonPhrase; break; } if(nextResponse.StatusCode is HttpStatusCode.BadRequest) { // // The provider explains the problem in the body, while the reason phrase says no // more than "Bad Request". We show that explanation and fall back to the phrase // only when the body carries none: // var badRequestMessage = ReadProviderErrorMessage(errorBody); if (string.IsNullOrWhiteSpace(badRequestMessage)) badRequestMessage = nextResponse.ReasonPhrase; // // When we recognize what went wrong, we say what it means for the user instead of // guessing at the message format. The classification happened above already: // var classifiedMessage = this.GetProviderRequestFailureUserMessage(providerRequestFailure); if(!string.IsNullOrWhiteSpace(classifiedMessage)) { await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, classifiedMessage)); } // Check if the error body contains "context" and "token" (case-insensitive), // which indicates that the context window is likely exceeded: else if(errorBody.Contains("context", StringComparison.InvariantCultureIgnoreCase) && errorBody.Contains("token", StringComparison.InvariantCultureIgnoreCase)) { await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, string.Format(TB("We tried to communicate with the LLM provider '{0}' (type={1}). The data of the chat, including all file attachments, is probably too large for the selected model and provider. The provider message is: '{2}'"), this.InstanceName, this.Provider, badRequestMessage))); } else { await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, string.Format(TB("We tried to communicate with the LLM provider '{0}' (type={1}). The required message format might be changed. The provider message is: '{2}'"), this.InstanceName, this.Provider, badRequestMessage))); } this.logger.LogError("Failed request with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody); errorMessage = nextResponse.ReasonPhrase; break; } if(nextResponse.StatusCode is HttpStatusCode.NotFound) { await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, string.Format(TB("We tried to communicate with the LLM provider '{0}' (type={1}). Something was not found. The provider message is: '{2}'"), this.InstanceName, this.Provider, nextResponse.ReasonPhrase))); this.logger.LogError("Failed request with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody); errorMessage = nextResponse.ReasonPhrase; break; } if(nextResponse.StatusCode is HttpStatusCode.Unauthorized) { await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Key, string.Format(TB("We tried to communicate with the LLM provider '{0}' (type={1}). The API key might be invalid. The provider message is: '{2}'"), this.InstanceName, this.Provider, nextResponse.ReasonPhrase))); this.logger.LogError("Failed request with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody); errorMessage = nextResponse.ReasonPhrase; break; } if(nextResponse.StatusCode is HttpStatusCode.InternalServerError) { await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, string.Format(TB("We tried to communicate with the LLM provider '{0}' (type={1}). The server might be down or having issues. The provider message is: '{2}'"), this.InstanceName, this.Provider, nextResponse.ReasonPhrase))); this.logger.LogError("Failed request with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody); errorMessage = nextResponse.ReasonPhrase; break; } if(nextResponse.StatusCode is HttpStatusCode.ServiceUnavailable) { await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, string.Format(TB("We tried to communicate with the LLM provider '{0}' (type={1}). The provider is overloaded. The message is: '{2}'"), this.InstanceName, this.Provider, nextResponse.ReasonPhrase))); this.logger.LogError("Failed request with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody); errorMessage = nextResponse.ReasonPhrase; break; } errorMessage = nextResponse.ReasonPhrase; var timeSeconds = Math.Pow(RETRY_DELAY_SECONDS, retry + 1); if(timeSeconds > 90) timeSeconds = 90; this.logger.LogDebug("Failed request with status code {ResponseStatusCode} (message = '{ErrorMessage}'). Retrying in {TimeSeconds:0.00} seconds.", nextResponse.StatusCode, errorMessage, timeSeconds); await Task.Delay(TimeSpan.FromSeconds(timeSeconds), effectiveCancellationToken); } if(retry >= MAX_RETRIES || !string.IsNullOrWhiteSpace(errorMessage)) { if (lastProviderRequestFailure is not ProviderRequestFailureReason.NONE) { var userMessage = this.GetProviderRequestFailureUserMessage(lastProviderRequestFailure); this.logger.LogError("The request to provider '{ProviderInstanceName}' (provider={ProviderType}) failed after {MaxRetries} retries with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}'): {ErrorMessage}", this.InstanceName, this.Provider, MAX_RETRIES, lastResponseStatusCode, lastResponseReasonPhrase, lastErrorBody, userMessage); throw new ProviderRequestException(lastProviderRequestFailure, userMessage, lastResponseStatusCode, lastResponseReasonPhrase, lastErrorBody); } await MessageBus.INSTANCE.SendError(new DataErrorMessage(Icons.Material.Filled.CloudOff, string.Format(TB("We tried to communicate with the LLM provider '{0}' (type={1}). Even after {2} retries, there were some problems with the request. The provider message is: '{3}'."), this.InstanceName, this.Provider, MAX_RETRIES, errorMessage))); return new HttpRateLimitedStreamResult(false, true, errorMessage ?? $"Failed after {MAX_RETRIES} retries; no provider message available", response); } return new HttpRateLimitedStreamResult(true, false, string.Empty, response); } /// /// Streams the chat completion from the provider using the Chat Completion API. /// /// The name of the provider. /// A function that builds the request. /// The cancellation token to use. /// The type of the delta lines inside the stream. /// The type of the annotation lines inside the stream. /// The stream of content chunks. protected async IAsyncEnumerable StreamChatCompletionInternal(string providerName, Func> requestBuilder, [EnumeratorCancellation] CancellationToken token = default) where TDelta : IResponseStreamLine where TAnnotation : IAnnotationStreamLine { // Check if annotations are supported: var annotationSupported = typeof(TAnnotation) != typeof(NoResponsesAnnotationStreamLine) && typeof(TAnnotation) != typeof(NoChatCompletionAnnotationStreamLine); StreamReader? streamReader = null; using var timeoutTokenSource = ExternalHttpClientTimeout.CreateTimeoutTokenSource(token); var timeoutToken = timeoutTokenSource.Token; try { // Send the request using exponential backoff: var responseData = await this.SendRequest(requestBuilder, token, timeoutToken); if(responseData.IsFailedAfterAllRetries) { this.logger.LogError($"The {providerName} chat completion failed: {responseData.ErrorMessage}"); yield break; } // Open the response stream: var providerStream = await responseData.Response!.Content.ReadAsStreamAsync(timeoutToken); // Add a stream reader to read the stream, line by line: streamReader = new StreamReader(providerStream); } catch(ProviderRequestException) { throw; } catch(Exception e) { if (token.IsCancellationRequested) { this.logger.LogWarning("The user canceled the chat completion request for {ProviderName} '{ProviderInstanceName}' before the response stream was opened.", providerName, this.InstanceName); } else if (this.IsTimeoutException(e, token)) { await this.SendTimeoutError("opening the chat response stream"); this.logger.LogError(e, "Timed out while opening the chat completion stream from {ProviderName} '{ProviderInstanceName}'.", providerName, this.InstanceName); } else { await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to communicate with the LLM provider '{0}'. There were some problems with the request. The provider message is: '{1}'"), this.InstanceName, e.Message))); this.logger.LogError($"Failed to stream chat completion from {providerName} '{this.InstanceName}': {e.Message}"); } } if (streamReader is null) yield break; // // Read the stream, line by line: // while (true) { try { if(streamReader.EndOfStream) break; } catch (Exception e) { await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}'"), this.InstanceName, e.Message))); this.logger.LogWarning($"Failed to read the end-of-stream state from {providerName} '{this.InstanceName}': {e.Message}"); break; } // Check if the token is canceled: if (token.IsCancellationRequested) { this.logger.LogWarning($"The user canceled the chat completion for {providerName} '{this.InstanceName}'."); streamReader.Close(); yield break; } // // Read the next line: // string? line; try { line = await streamReader.ReadLineAsync(timeoutToken); } catch (Exception e) { if (token.IsCancellationRequested) { this.logger.LogWarning("The user canceled the chat completion stream for {ProviderName} '{ProviderInstanceName}' while reading the next chunk.", providerName, this.InstanceName); } else if (this.IsTimeoutException(e, token)) { await this.SendTimeoutError("reading the chat response stream"); this.logger.LogError(e, "Timed out while reading the chat stream from {ProviderName} '{ProviderInstanceName}'.", providerName, this.InstanceName); } else { await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. Was not able to read the stream. The message is: '{1}'"), this.InstanceName, e.Message))); this.logger.LogError($"Failed to read the stream from {providerName} '{this.InstanceName}': {e.Message}"); } break; } if (line is null) break; // Skip empty lines: if (string.IsNullOrWhiteSpace(line)) continue; if (this.TryCreateProviderRequestExceptionFromStreamLine(providerName, line, out var providerRequestException)) throw providerRequestException; // Skip lines that do not start with "data:". According // to the specification, we only want to read the data lines: if (!TryGetServerSentEventData(line, out var jsonData)) continue; // Check if the line is the end of the stream: if (jsonData is "[DONE]") yield break; // // Process annotation lines: // if (annotationSupported && line.Contains(""" "annotations":[ """, StringComparison.InvariantCulture)) { TAnnotation? providerResponse; try { // Deserialize the JSON data: providerResponse = JsonSerializer.Deserialize(jsonData, JSON_SERIALIZER_OPTIONS); if (providerResponse is null) continue; } catch { // Skip invalid JSON data: continue; } // Skip empty responses: if (!providerResponse.ContainsSources()) continue; // Yield the response: yield return new(string.Empty, providerResponse.GetSources()); } // // Process delta lines: // else { TDelta? providerResponse; try { // Deserialize the JSON data: providerResponse = JsonSerializer.Deserialize(jsonData, JSON_SERIALIZER_OPTIONS); if (providerResponse is null) continue; } catch { // Skip invalid JSON data: continue; } // Skip empty responses: if (!providerResponse.ContainsContent()) continue; // Yield the response: yield return providerResponse.GetContent(); } } streamReader.Dispose(); } /// /// Streams the chat completion from the provider using the Responses API. /// /// The name of the provider. /// A function that builds the request. /// The cancellation token to use. /// The type of the delta lines inside the stream. /// The type of the annotation lines inside the stream. /// The stream of content chunks. protected async IAsyncEnumerable StreamResponsesInternal(string providerName, Func> requestBuilder, [EnumeratorCancellation] CancellationToken token = default) where TDelta : IResponseStreamLine where TAnnotation : IAnnotationStreamLine { // Check if annotations are supported: var annotationSupported = typeof(TAnnotation) != typeof(NoResponsesAnnotationStreamLine) && typeof(TAnnotation) != typeof(NoChatCompletionAnnotationStreamLine); StreamReader? streamReader = null; using var timeoutTokenSource = ExternalHttpClientTimeout.CreateTimeoutTokenSource(token); var timeoutToken = timeoutTokenSource.Token; try { // Send the request using exponential backoff: var responseData = await this.SendRequest(requestBuilder, token, timeoutToken); if(responseData.IsFailedAfterAllRetries) { this.logger.LogError($"The {providerName} responses call failed: {responseData.ErrorMessage}"); yield break; } // Open the response stream: var providerStream = await responseData.Response!.Content.ReadAsStreamAsync(timeoutToken); // Add a stream reader to read the stream, line by line: streamReader = new StreamReader(providerStream); } catch(ProviderRequestException) { throw; } catch(Exception e) { if (token.IsCancellationRequested) { this.logger.LogWarning("The user canceled the responses request for {ProviderName} '{ProviderInstanceName}' before the response stream was opened.", providerName, this.InstanceName); } else if (this.IsTimeoutException(e, token)) { await this.SendTimeoutError("opening the chat response stream"); this.logger.LogError(e, "Timed out while opening the responses stream from {ProviderName} '{ProviderInstanceName}'.", providerName, this.InstanceName); } else { await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to communicate with the LLM provider '{0}'. There were some problems with the request. The provider message is: '{1}'"), this.InstanceName, e.Message))); this.logger.LogError($"Failed to stream responses from {providerName} '{this.InstanceName}': {e.Message}"); } } if (streamReader is null) yield break; // // Read the stream, line by line: // while (true) { try { if(streamReader.EndOfStream) break; } catch (Exception e) { await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}'"), this.InstanceName, e.Message))); this.logger.LogWarning($"Failed to read the end-of-stream state from {providerName} '{this.InstanceName}': {e.Message}"); break; } // Check if the token is canceled: if (token.IsCancellationRequested) { this.logger.LogWarning($"The user canceled the responses for {providerName} '{this.InstanceName}'."); streamReader.Close(); yield break; } // // Read the next line: // string? line; try { line = await streamReader.ReadLineAsync(timeoutToken); } catch (Exception e) { if (token.IsCancellationRequested) { this.logger.LogWarning("The user canceled the responses stream for {ProviderName} '{ProviderInstanceName}' while reading the next chunk.", providerName, this.InstanceName); } else if (this.IsTimeoutException(e, token)) { await this.SendTimeoutError("reading the chat response stream"); this.logger.LogError(e, "Timed out while reading the responses stream from {ProviderName} '{ProviderInstanceName}'.", providerName, this.InstanceName); } else { await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. Was not able to read the stream. The message is: '{1}'"), this.InstanceName, e.Message))); this.logger.LogError($"Failed to read the stream from {providerName} '{this.InstanceName}': {e.Message}"); } break; } if (line is null) break; // Skip empty lines: if (string.IsNullOrWhiteSpace(line)) continue; if (this.TryCreateProviderRequestExceptionFromStreamLine(providerName, line, out var providerRequestException)) throw providerRequestException; // Check if the line is the end of the stream: if (line.StartsWith("event: response.completed", StringComparison.InvariantCulture)) yield break; if (!TryGetServerSentEventData(line, out var jsonData)) continue; // // Find delta lines: // if (jsonData.StartsWith(""" {"type":"response.output_text.delta" """, StringComparison.InvariantCulture)) { TDelta? providerResponse; try { // Deserialize the JSON data: providerResponse = JsonSerializer.Deserialize(jsonData, JSON_SERIALIZER_OPTIONS); if (providerResponse is null) continue; } catch { // Skip invalid JSON data: continue; } // Skip empty responses: if (!providerResponse.ContainsContent()) continue; // Yield the response: yield return providerResponse.GetContent(); } // // Find annotation added lines: // else if (annotationSupported && jsonData.StartsWith( """ {"type":"response.output_text.annotation.added" """, StringComparison.InvariantCulture)) { TAnnotation? providerResponse; try { // Deserialize the JSON data: providerResponse = JsonSerializer.Deserialize(jsonData, JSON_SERIALIZER_OPTIONS); if (providerResponse is null) continue; } catch { // Skip invalid JSON data: continue; } // Skip empty responses: if (!providerResponse.ContainsSources()) continue; // Yield the response: yield return new(string.Empty, providerResponse.GetSources()); } } streamReader.Dispose(); } /// /// Streams the chat completion from an OpenAI-compatible provider using the Chat Completion API. /// /// The provider name for logging and error reporting. /// The selected chat model. /// The current chat thread. /// The settings manager. /// Builds the provider-specific request body. /// The secret store type. /// Whether the API key is optional. /// The system prompt role to use. /// The request path, relative to the provider base URL. /// Optional additional headers to add. /// The cancellation token. /// The request DTO type. /// The delta stream line type. /// The annotation stream line type. /// The streamed content chunks. protected async IAsyncEnumerable StreamOpenAICompatibleChatCompletion( string providerName, Model chatModel, ChatThread chatThread, SettingsManager settingsManager, Func, IList?, Task> requestFactory, SecretStoreType storeType = SecretStoreType.LLM_PROVIDER, bool isTryingSecret = false, string systemPromptRole = "system", string requestPath = "chat/completions", Action? headersAction = null, [EnumeratorCancellation] CancellationToken token = default) where TRequest : ChatCompletionAPIRequest where TDelta : IResponseStreamLine where TAnnotation : IAnnotationStreamLine { // Get the API key: var requestedSecret = await Program.RUST_SERVICE.GetAPIKey(this, storeType, isTrying: isTryingSecret); if(!requestedSecret.Success && !isTryingSecret) yield break; // Parse the API parameters: var apiParameters = this.ParseAdditionalApiParameters("parallel_tool_calls"); var toolRegistry = Program.SERVICE_PROVIDER.GetService(); var toolExecutor = Program.SERVICE_PROVIDER.GetService(); var currentAssistantContent = chatThread.Blocks.LastOrDefault(x => x.Role is ChatRole.AI)?.Content as ContentText; currentAssistantContent?.ToolInvocations.Clear(); TextMessage systemPrompt; if (toolRegistry is not null && toolExecutor is not null) { var providerSettings = this.CreateSettingsProvider(chatModel); var runnableTools = await toolRegistry.GetRunnableToolsAsync( providerSettings, chatThread.RuntimeComponent, chatThread.RuntimeSelectedToolIds, this.Provider.GetConfidence(settingsManager).Level, chatThread.MayRunTools(settingsManager)); systemPrompt = new TextMessage { Role = systemPromptRole, Content = chatThread.PrepareSystemPrompt(settingsManager, runnableTools.Select(x => x.Definition)), }; if (runnableTools.Count > 0) { var adapter = new ChatCompletionToolCallingAdapter(requestFactory, systemPrompt, apiParameters, runnableTools.Select(x => ProviderToolAdapters.ToChatCompletionTool(x.Definition)).ToList(), runnableTools, (requestDto, requestToken) => this.ExecuteChatCompletionRequest(requestDto, requestPath, requestedSecret, headersAction, requestToken), this.InstanceName, this.logger); var loop = Program.SERVICE_PROVIDER.GetRequiredService(); var loopContext = new ToolCallingLoopContext { ChatThread = chatThread, RunnableTools = runnableTools, ToolExecutor = toolExecutor, Provider = this, CurrentAssistantContent = currentAssistantContent, ProviderInstanceName = this.InstanceName, ProviderType = this.Provider, ModelId = chatModel.Id, }; await foreach (var content in loop.RunAsync(adapter, loopContext, token)) yield return content; yield break; } } else { systemPrompt = new TextMessage { Role = systemPromptRole, Content = chatThread.PrepareSystemPrompt(settingsManager), }; } // Prepare the provider HTTP chat request: var providerChatRequest = JsonSerializer.Serialize(await requestFactory(systemPrompt, apiParameters, null), JSON_SERIALIZER_OPTIONS); async Task RequestBuilder() { // Build the HTTP post request: var request = new HttpRequestMessage(HttpMethod.Post, requestPath); // Set the authorization header: if (requestedSecret.Success) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(Program.ENCRYPTION)); // Set provider-specific headers: headersAction?.Invoke(request.Headers); // Set the content: request.Content = new StringContent(providerChatRequest, Encoding.UTF8, "application/json"); return request; } await foreach (var content in this.StreamChatCompletionInternal(providerName, RequestBuilder, token)) yield return content; } /// /// Describes this provider instance with the given model as configured provider settings. /// /// /// Anything asking about model capabilities must go through this, because the expert /// capability overrides live on the settings object: a provider that builds its own settings /// instance without them silently ignores what the user configured. /// protected AIStudio.Settings.Provider CreateSettingsProvider(Model chatModel) => new() { UsedLLMProvider = this.Provider, Model = chatModel, InstanceName = this.InstanceName, CapabilityOverrides = this.CapabilityOverrides, }; private async Task ExecuteChatCompletionRequest(ChatCompletionAPIRequest requestDto, string requestPath, RequestedSecret requestedSecret, Action? headersAction, CancellationToken token) { var responseData = await this.SendRequest(RequestBuilder, token); if (responseData.IsFailedAfterAllRetries) return null; using var response = responseData.Response!; return await response.Content.ReadFromJsonAsync(JSON_SERIALIZER_OPTIONS, token); async Task RequestBuilder() { var request = new HttpRequestMessage(HttpMethod.Post, requestPath); if (requestedSecret.Success) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(Program.ENCRYPTION)); headersAction?.Invoke(request.Headers); request.Content = new StringContent(JsonSerializer.Serialize(requestDto, JSON_SERIALIZER_OPTIONS), Encoding.UTF8, "application/json"); return request; } } /// /// Builds the message a user gets to see when a transcription request failed. /// /// /// AI Studio always sends WebM/Opus. Some providers run their speech recognition behind a /// decoder which reads WAV only, and they answer with a bad request whose body says that it /// could not decode the file. Nobody can do anything about that inside AI Studio, so we name /// the likely cause and point at the provider instead of showing the raw message. /// /// The status code the provider answered with. /// The body the provider answered with. /// The message to show, or an empty string when we have nothing to say. private string GetTranscriptionFailureUserMessage(HttpStatusCode statusCode, string responseBody) { var failureReason = this.ClassifyProviderRequestFailure(statusCode, responseBody); var classifiedMessage = this.GetProviderRequestFailureUserMessage(failureReason); if (!string.IsNullOrWhiteSpace(classifiedMessage)) return classifiedMessage; if (statusCode is HttpStatusCode.BadRequest && responseBody.Contains("not decode", StringComparison.OrdinalIgnoreCase)) return string.Format(TB("The provider '{0}' was not able to read the audio file. It probably does not support the WebM/Opus format which AI Studio sends. Please contact the provider about it."), this.InstanceName); var providerMessage = ReadProviderErrorMessage(responseBody); if (!string.IsNullOrWhiteSpace(providerMessage)) return string.Format(TB("The provider '{0}' reported an error: {1}"), this.InstanceName, providerMessage); return string.Empty; } protected async Task PerformStandardTranscriptionRequest(RequestedSecret requestedSecret, Model transcriptionModel, string audioFilePath, Host host = Host.NONE, CancellationToken token = default) { try { using var form = new MultipartFormDataContent(); var mimeType = Builder.FromFilename(audioFilePath); await using var fileStream = File.OpenRead(audioFilePath); using var fileContent = new StreamContent(fileStream); // Set the content type based on the file extension: fileContent.Headers.ContentType = new MediaTypeHeaderValue(mimeType); // Add the file content to the form data: form.Add(fileContent, "file", Path.GetFileName(audioFilePath)); // // Add the model name to the form data. Ensure that a model name is always provided. // Otherwise, the StringContent constructor will throw an exception. // var modelName = transcriptionModel.Id; if (string.IsNullOrWhiteSpace(modelName)) modelName = "placeholder"; form.Add(new StringContent(modelName), "model"); // // Ask for the plain JSON format explicitly. We only ever read the 'text' field, so the // additional data of 'verbose_json' would be wasted anyway. More importantly, gateways // fill in a format of their own when the client names none: LiteLLM asks for // 'verbose_json' to get the duration it needs for its cost tracking, and the newer // transcription models of OpenAI reject that format. // form.Add(new StringContent("json"), "response_format"); using var request = new HttpRequestMessage(HttpMethod.Post, host.TranscriptionURL()); request.Content = form; // Handle the authorization header based on the provider: switch (this.Provider) { case LLMProviders.SELF_HOSTED: if(requestedSecret.Success) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(Program.ENCRYPTION)); break; case LLMProviders.FIREWORKS: if(!requestedSecret.Success) { this.logger.LogError("No valid API key available for transcription request."); return TranscriptionResult.Failure(); } request.Headers.Add("Authorization", await requestedSecret.Secret.Decrypt(Program.ENCRYPTION)); break; default: if(!requestedSecret.Success) { this.logger.LogError("No valid API key available for transcription request."); return TranscriptionResult.Failure(); } request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(Program.ENCRYPTION)); break; } this.logger.LogInformation("Uploading transcription media '{FileName}' with content type '{ContentType}' and {FileSize} bytes.", Path.GetFileName(audioFilePath), mimeType.TextRepresentation, fileStream.Length); using var response = await this.HttpClient.SendAsync(request, token); var responseBody = await response.Content.ReadAsStringAsync(token); if (!response.IsSuccessStatusCode) { this.logger.LogError("Transcription request failed with status code {ResponseStatusCode} and body: '{ResponseBody}'.", response.StatusCode, responseBody); return TranscriptionResult.Failure(this.GetTranscriptionFailureUserMessage(response.StatusCode, responseBody)); } var transcriptionResponse = JsonSerializer.Deserialize(responseBody, JSON_SERIALIZER_OPTIONS); if(transcriptionResponse is null) { this.logger.LogError("Was not able to deserialize the transcription response."); return TranscriptionResult.Failure(); } return TranscriptionResult.FromText(transcriptionResponse.Text); } catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; } catch (Exception e) { if (this.IsTimeoutException(e, token)) await this.SendTimeoutError("transcribing audio"); this.logger.LogError("Failed to perform transcription request: '{Message}'.", e.Message); return TranscriptionResult.Failure(); } } /// /// The cancellation token is not the last parameter, unlike everywhere else in this codebase: /// C# demands that a params parameter comes last. /// protected async Task>> PerformStandardTextEmbeddingRequest(RequestedSecret requestedSecret, Model embeddingModel, Host host = Host.NONE, CancellationToken token = default, params List texts) { try { // // Add the model name to the form data. Ensure that a model name is always provided. // Otherwise, the StringContent constructor will throw an exception. // var modelName = embeddingModel.Id; if (string.IsNullOrWhiteSpace(modelName)) modelName = "placeholder"; // Prepare the HTTP embedding request: var payload = new { model = modelName, input = texts, encoding_format = "float" }; var embeddingRequest = JsonSerializer.Serialize(payload, JSON_SERIALIZER_OPTIONS); using var request = new HttpRequestMessage(HttpMethod.Post, host.EmbeddingURL()); // Handle the authorization header based on the provider: switch (this.Provider) { case LLMProviders.SELF_HOSTED: if(requestedSecret.Success) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(Program.ENCRYPTION)); break; default: if(!requestedSecret.Success) { this.logger.LogError("No valid API key available for embedding request."); 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; } // Set the content: request.Content = new StringContent(embeddingRequest, Encoding.UTF8, "application/json"); using var response = await this.HttpClient.SendAsync(request, token); var responseBody = await response.Content.ReadAsStringAsync(token); if (!response.IsSuccessStatusCode) { this.logger.LogError("Embedding request failed with status code {ResponseStatusCode} and body: '{ResponseBody}'.", response.StatusCode, responseBody); // // 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); if (embeddingResponse is { Data: not null }) { return embeddingResponse.Data .Select(d => d.Embedding?.ToArray() ?? []) .Cast>() .ToArray(); } else { this.logger.LogError("Was not able to deserialize the embedding response."); 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) { var isTimeout = this.IsTimeoutException(e, token); if (isTimeout) await this.SendTimeoutError("creating embeddings"); this.logger.LogError("Failed to perform embedding request: '{Message}'.", e.Message); throw this.CreateEmbeddingRequestException(e, isTimeout); } } /// /// Parse and convert API parameters from a provided JSON string into a dictionary, /// optionally merging additional parameters and removing specific keys. /// /// Optional list of keys to remove from the final dictionary /// (case-insensitive). The parameters stream, model, and messages are removed by default. protected IDictionary ParseAdditionalApiParameters( params string[] keysToRemove) { if (!AdditionalApiParametersParser.TryParse(this.AdditionalJsonApiParameters, out var apiParameters, out var errorMessage)) { this.logger.LogError("Failed to parse additional API parameters: {ExceptionMessage}", errorMessage); return new Dictionary(); } // Some keys are always removed because AI Studio sets them itself. var reservedKeys = keysToRemove.Concat(["stream", "model", "messages"]); return AdditionalApiParametersParser.RemoveKeys(apiParameters, reservedKeys); } protected static bool TryPopIntParameter(IDictionary parameters, string key, out int value) { value = 0; if (!TryPopParameter(parameters, key, out var raw) || raw is null) return false; switch (raw) { case int i: value = i; return true; case long l and >= int.MinValue and <= int.MaxValue: value = (int)l; return true; case double d and >= int.MinValue and <= int.MaxValue: value = (int)d; return true; case decimal m and >= int.MinValue and <= int.MaxValue: value = (int)m; return true; } return false; } protected static bool TryPopBoolParameter(IDictionary parameters, string key, out bool value) { value = false; if (!TryPopParameter(parameters, key, out var raw) || raw is null) return false; switch (raw) { case bool b: value = b; return true; case string s when bool.TryParse(s, out var parsed): value = parsed; return true; case int i: value = i != 0; return true; case long l: value = l != 0; return true; case double d: value = Math.Abs(d) > double.Epsilon; return true; case decimal m: value = m != 0; return true; } return false; } private static bool TryPopParameter(IDictionary parameters, string key, out object? value) { value = null; if (parameters.Count == 0) return false; var foundKey = parameters.Keys.FirstOrDefault(k => string.Equals(k, key, StringComparison.OrdinalIgnoreCase)); if (foundKey is null) return false; value = parameters[foundKey]; parameters.Remove(foundKey); return true; } }