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; /// /// HTTP client implementation for Mistral API. /// Implements IMistralConnector from Domain layer. /// public class MistralConnector : IMistralConnector { private readonly HttpClient _httpClient; private readonly MistralConfig _config; private readonly ILogger _logger; private readonly MistralRateLimiter _rateLimiter; private readonly IMemoryCache _cache; private readonly MistralCircuitBreaker _circuitBreaker; private readonly JsonSerializerOptions _jsonOptions; public MistralConnector(HttpClient httpClient, MistralConfig config, ILogger logger, IMemoryCache cache) { _httpClient = httpClient; _config = config ?? new MistralConfig(); _logger = logger; _cache = cache; // Initialize JSON options (always needed) _jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; // Skip validation and initialization during EF migrations // (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); bool hasValidBaseUrl = Uri.TryCreate(_config.BaseUrl, UriKind.Absolute, out var baseUri); if (!isEfDesignTime) { // Always set up HttpClient if we have a valid BaseUrl if (hasValidBaseUrl) { _httpClient.BaseAddress = baseUri; _httpClient.Timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds); _httpClient.DefaultRequestHeaders.Accept.Add( new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); // Only add auth header if we have an API key if (hasApiKey) { _config.Validate(); _httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {_config.ApiKey}"); } _rateLimiter = new MistralRateLimiter(_config.RateLimitPerMinute); _circuitBreaker = new MistralCircuitBreaker( _config.CircuitBreakerFailureThreshold, TimeSpan.FromMinutes(_config.CircuitBreakerResetMinutes)); } else { // No valid BaseUrl - this is an error condition _logger.LogError("Mistral configuration has invalid or missing BaseUrl: {BaseUrl}", _config.BaseUrl); _rateLimiter = new MistralRateLimiter(10); _circuitBreaker = new MistralCircuitBreaker(5, TimeSpan.FromMinutes(1)); } } else { // EF Design time - initialize with defaults _rateLimiter = new MistralRateLimiter(10); _circuitBreaker = new MistralCircuitBreaker(5, TimeSpan.FromMinutes(1)); } } public async Task CompleteAsync(MistralRequest request, CancellationToken cancellationToken = default) { return await ExecuteRequestAsync( "completions", request, cancellationToken); } public async Task ChatAsync(MistralChatRequest request, CancellationToken cancellationToken = default) { return await ExecuteRequestAsync( "chat/completions", request, cancellationToken); } public async Task> ListModelsAsync( CancellationToken cancellationToken = default) { CheckCircuitAndRateLimit("models"); try { var response = await ExecuteWithRetryAsync(async () => { var httpResponse = await _httpClient.GetAsync("models", cancellationToken); return await HandleResponseAsync(httpResponse); }, "ListModels"); _circuitBreaker.RecordSuccess(); return response.Data; } catch (Exception ex) { HandleRequestError(ex, "ListModels"); throw; } } public async Task 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(httpResponse); }, "GetModel"); _circuitBreaker.RecordSuccess(); return response; } catch (Exception ex) { HandleRequestError(ex, "GetModel"); throw; } } private async Task ExecuteRequestAsync( 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(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 ExecuteWithRetryAsync(Func> 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 HandleResponseAsync(HttpResponseMessage httpResponse) { if (!httpResponse.IsSuccessStatusCode) { var errorContent = await httpResponse.Content.ReadAsStringAsync(); var errorResponse = JsonSerializer.Deserialize(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(content, _jsonOptions) ?? throw new AiServiceException("Invalid response from Mistral API", AiErrorCode.InvalidResponse); } private string GenerateCacheKey(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); } } }