- MistralConnector, TtsService, VoskService: Skip validation when required config values are empty - ValidateAiConfigurations: Only validate if config values are set - Prevents startup crashes in Docker when AI service environment variables are not provided - Allows backend to start without Mistral/Coqui/Vosk configurations Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
253 lines
9.7 KiB
C#
253 lines
9.7 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using GermanApp.Application.Models;
|
|
using GermanApp.Domain.Interfaces;
|
|
using GermanApp.Infrastructure.Configuration;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace GermanApp.Infrastructure.Services;
|
|
|
|
/// <summary>
|
|
/// HTTP client implementation for Mistral API.
|
|
/// Implements IMistralConnector from Domain layer.
|
|
/// </summary>
|
|
public class MistralConnector : IMistralConnector
|
|
{
|
|
private readonly HttpClient _httpClient;
|
|
private readonly MistralConfig _config;
|
|
private readonly ILogger<MistralConnector> _logger;
|
|
private readonly MistralRateLimiter _rateLimiter;
|
|
private readonly IMemoryCache _cache;
|
|
private readonly MistralCircuitBreaker _circuitBreaker;
|
|
private readonly JsonSerializerOptions _jsonOptions;
|
|
|
|
public MistralConnector(HttpClient httpClient, MistralConfig config,
|
|
ILogger<MistralConnector> logger, IMemoryCache cache)
|
|
{
|
|
_httpClient = httpClient;
|
|
_config = config;
|
|
_logger = logger;
|
|
_cache = cache;
|
|
|
|
// Skip validation and initialization during EF migrations or when configs are not set
|
|
// (In Docker, AI configs may be set via environment variables or may be optional)
|
|
bool isEfDesignTime = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_EF") == "true";
|
|
bool hasApiKey = !string.IsNullOrWhiteSpace(_config?.ApiKey);
|
|
|
|
if (!isEfDesignTime && hasApiKey)
|
|
{
|
|
_config.Validate();
|
|
|
|
_httpClient.BaseAddress = new Uri(_config.BaseUrl);
|
|
_httpClient.Timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds);
|
|
_httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {_config.ApiKey}");
|
|
_httpClient.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
|
|
|
_jsonOptions = new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
};
|
|
|
|
_rateLimiter = new MistralRateLimiter(_config.RateLimitPerMinute);
|
|
_circuitBreaker = new MistralCircuitBreaker(
|
|
_config.CircuitBreakerFailureThreshold,
|
|
TimeSpan.FromMinutes(_config.CircuitBreakerResetMinutes));
|
|
}
|
|
}
|
|
|
|
public async Task<MistralResponse> CompleteAsync(MistralRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return await ExecuteRequestAsync<MistralRequest, MistralResponse>(
|
|
"completions", request, cancellationToken);
|
|
}
|
|
|
|
public async Task<MistralResponse> ChatAsync(MistralChatRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return await ExecuteRequestAsync<MistralChatRequest, MistralResponse>(
|
|
"chat/completions", request, cancellationToken);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<MistralModel>> ListModelsAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
CheckCircuitAndRateLimit("models");
|
|
try
|
|
{
|
|
var response = await ExecuteWithRetryAsync(async () =>
|
|
{
|
|
var httpResponse = await _httpClient.GetAsync("models", cancellationToken);
|
|
return await HandleResponseAsync<MistralListModelsResponse>(httpResponse);
|
|
}, "ListModels");
|
|
_circuitBreaker.RecordSuccess();
|
|
return response.Data;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
HandleRequestError(ex, "ListModels");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<MistralModel> GetModelAsync(string modelId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(modelId))
|
|
throw new ArgumentException("Model ID cannot be null or empty", nameof(modelId));
|
|
CheckCircuitAndRateLimit("models");
|
|
try
|
|
{
|
|
var response = await ExecuteWithRetryAsync(async () =>
|
|
{
|
|
var httpResponse = await _httpClient.GetAsync($"models/{modelId}", cancellationToken);
|
|
return await HandleResponseAsync<MistralModel>(httpResponse);
|
|
}, "GetModel");
|
|
_circuitBreaker.RecordSuccess();
|
|
return response;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
HandleRequestError(ex, "GetModel");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private async Task<TResponse> ExecuteRequestAsync<TRequest, TResponse>(
|
|
string endpoint, TRequest request, CancellationToken cancellationToken)
|
|
where TResponse : class
|
|
{
|
|
CheckCircuitAndRateLimit(endpoint);
|
|
|
|
// Generate cache key for caching
|
|
var cacheKey = GenerateCacheKey(request);
|
|
if (_config.EnableCaching && _cache.TryGetValue(cacheKey, out TResponse cachedResponse))
|
|
{
|
|
_logger.LogDebug("Cache hit for {Endpoint}", endpoint);
|
|
return cachedResponse!;
|
|
}
|
|
|
|
try
|
|
{
|
|
var response = await ExecuteWithRetryAsync(async () =>
|
|
{
|
|
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
var httpResponse = await _httpClient.PostAsync(endpoint, content, cancellationToken);
|
|
return await HandleResponseAsync<TResponse>(httpResponse);
|
|
}, endpoint);
|
|
|
|
_circuitBreaker.RecordSuccess();
|
|
|
|
// Cache the response if caching is enabled
|
|
if (_config.EnableCaching && response != null)
|
|
{
|
|
_cache.Set(cacheKey, response, TimeSpan.FromMinutes(_config.CacheTTLMinutes));
|
|
}
|
|
|
|
return response;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
HandleRequestError(ex, endpoint);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private void CheckCircuitAndRateLimit(string endpoint)
|
|
{
|
|
if (!_circuitBreaker.IsClosed)
|
|
throw new AiServiceException("Mistral API circuit breaker is open",
|
|
AiErrorCode.ServiceUnavailable);
|
|
if (!_rateLimiter.TryAcquire(endpoint))
|
|
throw new AiServiceException("Rate limit exceeded",
|
|
AiErrorCode.RateLimited);
|
|
}
|
|
|
|
private async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> action, string operationName)
|
|
{
|
|
int retryCount = 0;
|
|
while (true)
|
|
{
|
|
try
|
|
{
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(_config.TimeoutSeconds));
|
|
return await action();
|
|
}
|
|
catch (Exception ex) when (retryCount < _config.MaxRetries && IsRetryable(ex))
|
|
{
|
|
retryCount++;
|
|
var delay = TimeSpan.FromSeconds(Math.Pow(2, retryCount));
|
|
_logger.LogWarning(ex, "{Operation} failed (attempt {RetryCount}), retrying in {Delay}s...",
|
|
operationName, retryCount, delay.TotalSeconds);
|
|
await Task.Delay(delay);
|
|
}
|
|
}
|
|
}
|
|
|
|
private bool IsRetryable(Exception ex) => ex switch
|
|
{
|
|
HttpRequestException or TimeoutException or TaskCanceledException => true,
|
|
_ => false
|
|
};
|
|
|
|
private async Task<T> HandleResponseAsync<T>(HttpResponseMessage httpResponse)
|
|
{
|
|
if (!httpResponse.IsSuccessStatusCode)
|
|
{
|
|
var errorContent = await httpResponse.Content.ReadAsStringAsync();
|
|
var errorResponse = JsonSerializer.Deserialize<MistralErrorResponse>(errorContent, _jsonOptions);
|
|
|
|
var errorCode = errorResponse?.ErrorCode ?? MapStatusCodeToErrorCode(httpResponse.StatusCode);
|
|
var message = errorResponse?.Message ?? $"HTTP {(int)httpResponse.StatusCode}: {httpResponse.ReasonPhrase}";
|
|
|
|
_logger.LogError("Mistral API error: {StatusCode} - {Message}",
|
|
(int)httpResponse.StatusCode, message);
|
|
|
|
httpResponse.EnsureSuccessStatusCode(); // This will throw
|
|
}
|
|
|
|
var content = await httpResponse.Content.ReadAsStringAsync();
|
|
return JsonSerializer.Deserialize<T>(content, _jsonOptions)
|
|
?? throw new AiServiceException("Invalid response from Mistral API",
|
|
AiErrorCode.InvalidResponse);
|
|
}
|
|
|
|
private string GenerateCacheKey<T>(T request)
|
|
{
|
|
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
|
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
|
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(json));
|
|
return Convert.ToBase64String(hashBytes);
|
|
}
|
|
|
|
private AiErrorCode MapStatusCodeToErrorCode(HttpStatusCode statusCode)
|
|
{
|
|
return statusCode switch
|
|
{
|
|
HttpStatusCode.Unauthorized => AiErrorCode.AuthenticationError,
|
|
HttpStatusCode.TooManyRequests => AiErrorCode.RateLimited,
|
|
HttpStatusCode.RequestTimeout => AiErrorCode.Timeout,
|
|
HttpStatusCode.BadRequest => AiErrorCode.InvalidRequest,
|
|
HttpStatusCode.InternalServerError or
|
|
HttpStatusCode.ServiceUnavailable or
|
|
HttpStatusCode.BadGateway or
|
|
HttpStatusCode.GatewayTimeout => AiErrorCode.Temporary,
|
|
_ => AiErrorCode.Unknown
|
|
};
|
|
}
|
|
|
|
private void HandleRequestError(Exception ex, string operationName)
|
|
{
|
|
_circuitBreaker.RecordFailure();
|
|
_logger.LogError(ex, "Mistral API request failed: {Operation}", operationName);
|
|
if (ex is not AiServiceException)
|
|
{
|
|
throw new AiServiceException($"Mistral {operationName} failed: {ex.Message}",
|
|
ex, AiErrorCode.Temporary);
|
|
}
|
|
}
|
|
}
|