DeutschLernen/GermanApp/Infrastructure/Services/MistralConnector.cs
Lasse Rune Hansen aa3db295ce fix(backend): Fix Mistral story generation with proper chat completion format
- Update MistralChatRequest to remove unused properties (User, Stop, FrequencyPenalty, PresencePenalty) causing 422 errors
- Add explicit JsonPropertyName attributes to ensure correct JSON serialization
- Update MistralService to send minimal requests matching Mistral API expectations
- Pass SegmentCount and CustomPrompt through StoryGenerationService to build proper prompts
- Add request logging in MistralConnector to debug API calls
- Update default model to mistral-small-latest
- Enhance error handling to log raw JSON responses

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-18 15:21:04 +02:00

326 lines
13 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 ?? new MistralConfig();
_logger = logger;
_cache = cache;
// Initialize JSON options (always needed)
// Mistral API uses snake_case for property names (max_tokens, not maxTokens)
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
// 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<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);
_logger.LogDebug("Mistral API request to {Endpoint}: {Request}", endpoint, json);
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();
_logger.LogError("Mistral API error response (status {StatusCode}): {Content}",
(int)httpResponse.StatusCode, errorContent);
AiErrorCode errorCode = MapStatusCodeToErrorCode(httpResponse.StatusCode);
string message = $"HTTP {(int)httpResponse.StatusCode}: {httpResponse.ReasonPhrase}";
// Try to extract error message from JSON without requiring exact DTO match
try
{
using var doc = JsonDocument.Parse(errorContent);
if (doc.RootElement.TryGetProperty("message", out var messageElement))
{
message = messageElement.ValueKind switch
{
JsonValueKind.String => messageElement.GetString() ?? message,
JsonValueKind.Object => messageElement.EnumerateObject().FirstOrDefault().Value.GetString() ?? message,
JsonValueKind.Array => messageElement.EnumerateArray().FirstOrDefault().GetString() ?? message,
_ => message
};
}
if (doc.RootElement.TryGetProperty("type", out var typeElement) && typeElement.ValueKind == JsonValueKind.String)
{
errorCode = typeElement.GetString() switch
{
"rate_limit_exceeded" or "rate_limit" => AiErrorCode.RateLimited,
"invalid_request_error" => AiErrorCode.InvalidRequest,
"authentication_error" or "invalid_api_key" => AiErrorCode.AuthenticationError,
"server_error" or "internal_server_error" => AiErrorCode.Temporary,
"timeout" => AiErrorCode.Timeout,
_ => errorCode
};
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not parse error response JSON, using status code mapping");
}
_logger.LogError("Mistral API error: {StatusCode} - {Message}",
(int)httpResponse.StatusCode, message);
httpResponse.EnsureSuccessStatusCode();
}
var content = await httpResponse.Content.ReadAsStringAsync();
try
{
var result = JsonSerializer.Deserialize<T>(content, _jsonOptions);
if (result == null)
{
_logger.LogError("Mistral API returned null when deserializing to {Type}. Content: {Content}", typeof(T).Name, content);
throw new AiServiceException("Invalid response from Mistral API",
AiErrorCode.InvalidResponse);
}
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize Mistral response to {Type}. Raw JSON: {Content}", typeof(T).Name, content);
throw;
}
}
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);
}
}
}