mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-29 16:03:36 +00:00
Resolved 29 conflicting files. The notable decisions: Confidence: main's tool-calling gate (RequiredProviderConfidence) and this branch's local-RAG gate (DataConfidenceLevel) turned out to be the same rule on the same axis, so they are now one field. Both tool results and data sources raise it through RequireProviderConfidence(). The gate checks the level strictly and no longer exempts providers trusted by configuration: TrustedProviderIds is documented as applying to data-source security checks only, and organizations set confidence through DataConfidence .CustomConfidenceScheme instead. The security axis (DataSecurity, ERI, IsTrustedForDataSourceSecurityChecks) is unchanged. Provider creation: main's CreateProvider signature won (hfEndpointKind, capabilityOverrides, no model parameter); tokenizerPath was added to it and is set for every provider, including the new Hetzner, IONOS and LiteLLM. Provider and EmbeddingProvider combine the record parameters, Lua parsing and Lua serialization of both sides. File types: main's hierarchy (ODT leaf, WORD parent, PowerPoint without the legacy .ppt, TABULAR instead of DELIMITED_TABLE) plus this branch's SPREADSHEET parent with ODS and the xlsm/xlsb/xla/xlam extensions, which the runtime already reads. Both sides had added a conflicting HTML filter; the reading family keeps the name, and the export path uses a narrow HTML_DOCUMENT, following the existing LATEX/TEX split. Runtime: main's file_data.rs is the base, including the prompt-injection sanitizer and the extraction routes. Token counting and chunk segmentation moved into take_released, so they act on the text the filter has released rather than on text it is still holding. A failed count is logged and left out instead of ending the extraction, because the app counts such a segment itself. Data sources: the participating-provider checks of this branch are kept, and main's GetAllowedDataSources overload now builds on them. DirectChatService resolves the launched chat's data source options before the check, so filter and chat see the same options. .NET and Rust both build clean; I18N regenerated to 4060 keys.
1497 lines
70 KiB
C#
1497 lines
70 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// The base class for all providers.
|
|
/// </summary>
|
|
public abstract class BaseProvider : IProvider, ISecretId
|
|
{
|
|
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(BaseProvider).Namespace, nameof(BaseProvider));
|
|
|
|
/// <summary>
|
|
/// The HTTP client to use it for all requests.
|
|
/// </summary>
|
|
protected readonly HttpClient HttpClient;
|
|
|
|
/// <summary>
|
|
/// The logger to use.
|
|
/// </summary>
|
|
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
|
|
};
|
|
|
|
/// <summary>
|
|
/// Constructor for the base provider.
|
|
/// </summary>
|
|
/// <param name="provider">The provider enum value.</param>
|
|
/// <param name="baseUri">The base URI for the provider.</param>
|
|
/// <param name="trustPolicy">The trust policy for external HTTPS requests to this provider.</param>
|
|
/// <param name="logger">The logger to use.</param>
|
|
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
|
|
|
|
/// <inheritdoc />
|
|
public LLMProviders Provider { get; }
|
|
|
|
/// <summary>
|
|
/// The base URI for all relative provider requests.
|
|
/// </summary>
|
|
public Uri BaseUri { get; }
|
|
|
|
/// <inheritdoc />
|
|
public abstract string Id { get; }
|
|
|
|
/// <inheritdoc />
|
|
public string ConfiguredProviderId { get; init; } = string.Empty;
|
|
|
|
/// <inheritdoc />
|
|
public abstract string InstanceName { get; set; }
|
|
|
|
/// <inheritdoc />
|
|
public string AdditionalJsonApiParameters { get; init; } = string.Empty;
|
|
|
|
internal ProviderCapabilityOverrides? CapabilityOverrides { get; set; }
|
|
|
|
/// <inheritdoc />
|
|
public string TokenizerPath { get; init; } = string.Empty;
|
|
|
|
/// <inheritdoc />
|
|
public abstract bool HasModelLoadingCapability { get; }
|
|
|
|
/// <inheritdoc />
|
|
public abstract IAsyncEnumerable<ContentStreamChunk> StreamChatCompletion(Model chatModel, ChatThread chatThread, SettingsManager settingsManager, CancellationToken token = default);
|
|
|
|
/// <inheritdoc />
|
|
public abstract IAsyncEnumerable<ImageURL> StreamImageCompletion(Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, CancellationToken token = default);
|
|
|
|
/// <inheritdoc />
|
|
public abstract Task<TranscriptionResult> TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default);
|
|
|
|
/// <inheritdoc />
|
|
public abstract Task<IReadOnlyList<IReadOnlyList<float>>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List<string> texts);
|
|
|
|
/// <inheritdoc />
|
|
public abstract Task<ModelLoadResult> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default);
|
|
|
|
/// <inheritdoc />
|
|
public abstract Task<ModelLoadResult> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default);
|
|
|
|
/// <inheritdoc />
|
|
public abstract Task<ModelLoadResult> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default);
|
|
|
|
/// <inheritdoc />
|
|
public abstract Task<ModelLoadResult> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default);
|
|
|
|
#endregion
|
|
|
|
/// <summary>
|
|
/// Whether this provider was imported from an enterprise configuration plugin.
|
|
/// </summary>
|
|
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<Model> 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<string?> 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<ModelLoadResult> LoadModelsResponse<TResponse>(SecretStoreType storeType,
|
|
string requestPath,
|
|
Func<TResponse, IEnumerable<Model>> modelFactory,
|
|
string? apiKeyProvisional = null,
|
|
Func<HttpResponseMessage, string, ModelLoadFailureReason>? failureReasonSelector = null,
|
|
Action<HttpRequestMessage, string>? 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<TResponse>(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."),
|
|
_ => string.Empty,
|
|
};
|
|
|
|
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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the error message a provider sent in the body of a failed response.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </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)
|
|
{
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends a request and handles rate limiting by exponential backoff.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
/// <param name="requestBuilder">A function that builds the request.</param>
|
|
/// <param name="userCancellationToken">The user cancellation token.</param>
|
|
/// <param name="requestCancellationToken">The token to use for the HTTP request.</param>
|
|
/// <returns>The status object of the request.</returns>
|
|
private async Task<HttpRateLimitedStreamResult> SendRequest(Func<Task<HttpRequestMessage>> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Streams the chat completion from the provider using the Chat Completion API.
|
|
/// </summary>
|
|
/// <param name="providerName">The name of the provider.</param>
|
|
/// <param name="requestBuilder">A function that builds the request.</param>
|
|
/// <param name="token">The cancellation token to use.</param>
|
|
/// <typeparam name="TDelta">The type of the delta lines inside the stream.</typeparam>
|
|
/// <typeparam name="TAnnotation">The type of the annotation lines inside the stream.</typeparam>
|
|
/// <returns>The stream of content chunks.</returns>
|
|
protected async IAsyncEnumerable<ContentStreamChunk> StreamChatCompletionInternal<TDelta, TAnnotation>(string providerName, Func<Task<HttpRequestMessage>> 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<TAnnotation>(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<TDelta>(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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Streams the chat completion from the provider using the Responses API.
|
|
/// </summary>
|
|
/// <param name="providerName">The name of the provider.</param>
|
|
/// <param name="requestBuilder">A function that builds the request.</param>
|
|
/// <param name="token">The cancellation token to use.</param>
|
|
/// <typeparam name="TDelta">The type of the delta lines inside the stream.</typeparam>
|
|
/// <typeparam name="TAnnotation">The type of the annotation lines inside the stream.</typeparam>
|
|
/// <returns>The stream of content chunks.</returns>
|
|
protected async IAsyncEnumerable<ContentStreamChunk> StreamResponsesInternal<TDelta, TAnnotation>(string providerName, Func<Task<HttpRequestMessage>> 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<TDelta>(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<TAnnotation>(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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Streams the chat completion from an OpenAI-compatible provider using the Chat Completion API.
|
|
/// </summary>
|
|
/// <param name="providerName">The provider name for logging and error reporting.</param>
|
|
/// <param name="chatModel">The selected chat model.</param>
|
|
/// <param name="chatThread">The current chat thread.</param>
|
|
/// <param name="settingsManager">The settings manager.</param>
|
|
/// <param name="requestFactory">Builds the provider-specific request body.</param>
|
|
/// <param name="storeType">The secret store type.</param>
|
|
/// <param name="isTryingSecret">Whether the API key is optional.</param>
|
|
/// <param name="systemPromptRole">The system prompt role to use.</param>
|
|
/// <param name="requestPath">The request path, relative to the provider base URL.</param>
|
|
/// <param name="headersAction">Optional additional headers to add.</param>
|
|
/// <param name="token">The cancellation token.</param>
|
|
/// <typeparam name="TRequest">The request DTO type.</typeparam>
|
|
/// <typeparam name="TDelta">The delta stream line type.</typeparam>
|
|
/// <typeparam name="TAnnotation">The annotation stream line type.</typeparam>
|
|
/// <returns>The streamed content chunks.</returns>
|
|
protected async IAsyncEnumerable<ContentStreamChunk> StreamOpenAICompatibleChatCompletion<TRequest, TDelta, TAnnotation>(
|
|
string providerName,
|
|
Model chatModel,
|
|
ChatThread chatThread,
|
|
SettingsManager settingsManager,
|
|
Func<TextMessage, IDictionary<string, object>, IList<object>?, Task<TRequest>> requestFactory,
|
|
SecretStoreType storeType = SecretStoreType.LLM_PROVIDER,
|
|
bool isTryingSecret = false,
|
|
string systemPromptRole = "system",
|
|
string requestPath = "chat/completions",
|
|
Action<HttpRequestHeaders>? 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<ToolRegistry>();
|
|
var toolExecutor = Program.SERVICE_PROVIDER.GetService<ToolExecutor>();
|
|
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<TRequest>(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<IToolCallingLoop>();
|
|
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<HttpRequestMessage> 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<TDelta, TAnnotation>(providerName, RequestBuilder, token))
|
|
yield return content;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Describes this provider instance with the given model as configured provider settings.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
protected AIStudio.Settings.Provider CreateSettingsProvider(Model chatModel) => new()
|
|
{
|
|
UsedLLMProvider = this.Provider,
|
|
Model = chatModel,
|
|
InstanceName = this.InstanceName,
|
|
CapabilityOverrides = this.CapabilityOverrides,
|
|
};
|
|
|
|
private async Task<ChatCompletionResponse?> ExecuteChatCompletionRequest(ChatCompletionAPIRequest requestDto, string requestPath, RequestedSecret requestedSecret,
|
|
Action<HttpRequestHeaders>? 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<ChatCompletionResponse>(JSON_SERIALIZER_OPTIONS, token);
|
|
|
|
async Task<HttpRequestMessage> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the message a user gets to see when a transcription request failed.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
/// <param name="statusCode">The status code the provider answered with.</param>
|
|
/// <param name="responseBody">The body the provider answered with.</param>
|
|
/// <returns>The message to show, or an empty string when we have nothing to say.</returns>
|
|
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<TranscriptionResult> 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<TranscriptionResponse>(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();
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The cancellation token is not the last parameter, unlike everywhere else in this codebase:
|
|
/// C# demands that a params parameter comes last.
|
|
/// </remarks>
|
|
protected async Task<IReadOnlyList<IReadOnlyList<float>>> PerformStandardTextEmbeddingRequest(RequestedSecret requestedSecret, Model embeddingModel, Host host = Host.NONE, CancellationToken token = default, params List<string> 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.");
|
|
return [];
|
|
}
|
|
|
|
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);
|
|
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 [];
|
|
}
|
|
|
|
var embeddingResponse = JsonSerializer.Deserialize<EmbeddingResponse>(responseBody, JSON_SERIALIZER_OPTIONS);
|
|
if (embeddingResponse is { Data: not null })
|
|
{
|
|
return embeddingResponse.Data
|
|
.Select(d => d.Embedding?.ToArray() ?? [])
|
|
.Cast<IReadOnlyList<float>>()
|
|
.ToArray();
|
|
}
|
|
else
|
|
{
|
|
this.logger.LogError("Was not able to deserialize the embedding response.");
|
|
return [];
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
if (this.IsTimeoutException(e, token))
|
|
await this.SendTimeoutError("creating embeddings");
|
|
|
|
this.logger.LogError("Failed to perform embedding request: '{Message}'.", e.Message);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parse and convert API parameters from a provided JSON string into a dictionary,
|
|
/// optionally merging additional parameters and removing specific keys.
|
|
/// </summary>
|
|
/// <param name="keysToRemove">Optional list of keys to remove from the final dictionary
|
|
/// (case-insensitive). The parameters stream, model, and messages are removed by default.</param>
|
|
protected IDictionary<string, object> 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<string, object>();
|
|
}
|
|
|
|
// 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<string, object> 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<string, object> 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<string, object> 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;
|
|
}
|
|
|
|
} |