diff --git a/GermanApp/Application/Models/MistralRequest.cs b/GermanApp/Application/Models/MistralRequest.cs new file mode 100644 index 0000000..f4ebb4b --- /dev/null +++ b/GermanApp/Application/Models/MistralRequest.cs @@ -0,0 +1,236 @@ +namespace GermanApp.Application.Models; + +/// +/// Request model for Mistral completion API. +/// Used for text generation (stories, feedback, etc.). +/// +/// +/// See: https://docs.mistral.ai/api/#operation/createCompletion +/// +public record MistralRequest +{ + /// + /// ID of the model to use. + /// + public string Model { get; init; } = "mistral-medium"; + + /// + /// The prompt(s) to generate completions for. + /// + public string Prompt { get; init; } = string.Empty; + + /// + /// The maximum number of tokens to generate in the completion. + /// Default: 256, Max: 2048 for mistral-medium + /// + public int? MaxTokens { get; init; } = 512; + + /// + /// What sampling temperature to use. + /// Higher values means the model will take more risks. Try 0.9 for more creative applications, and 0 for ones with a well-defined answer. + /// Default: 0.7 + /// + public double? Temperature { get; init; } = 0.7; + + /// + /// Nucleus sampling, where the model considers the results of the tokens with top_p probability mass. + /// So 0.1 means only the tokens comprising the top 10% probability mass are considered. + /// Default: 1.0 + /// + public double? TopP { get; init; } = 1.0; + + /// + /// How many completions to generate for each prompt. + /// Default: 1 + /// + public int? N { get; init; } = 1; + + /// + /// Whether to stream partial responses. + /// Not supported by this connector (use streaming endpoint separately if needed). + /// + public bool? Stream { get; init; } = false; + + /// + /// Whether to echo the prompt in the response. + /// Default: false + /// + public bool? Echo { get; init; } = false; + + /// + /// Up to 4 sequences where the API will stop generating further tokens. + /// + public IReadOnlyList? Stop { get; init; } + + /// + /// Number between -2.0 and 2.0. Positive values penalize new tokens based on their frequency in the text so far. + /// + public double? FrequencyPenalty { get; init; } + + /// + /// Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far. + /// + public double? PresencePenalty { get; init; } + + /// + /// User identifier for tracking usage. + /// + public string? User { get; init; } + + /// + /// Factory method to create a completion request with default settings. + /// + public static MistralRequest CreateCompletion(string prompt, string model = "mistral-medium", int maxTokens = 512) + { + return new MistralRequest + { + Model = model, + Prompt = prompt, + MaxTokens = maxTokens, + Temperature = 0.7, + TopP = 1.0, + N = 1, + Stream = false, + Echo = false + }; + } +} + +/// +/// Request model for Mistral chat completion API. +/// Used for conversational text generation. +/// +/// +/// See: https://docs.mistral.ai/api/#operation/createChatCompletion +/// +public record MistralChatRequest +{ + /// + /// ID of the model to use. + /// + public string Model { get; init; } = "mistral-medium"; + + /// + /// A list of messages comprising the conversation so far. + /// + public IReadOnlyList Messages { get; init; } = new List(); + + /// + /// The maximum number of tokens to generate in the completion. + /// + public int? MaxTokens { get; init; } = 512; + + /// + /// What sampling temperature to use. + /// + public double? Temperature { get; init; } = 0.7; + + /// + /// Nucleus sampling. + /// + public double? TopP { get; init; } = 1.0; + + /// + /// How many chat completions to generate for each input message. + /// + public int? N { get; init; } = 1; + + /// + /// Whether to stream partial responses. + /// + public bool? Stream { get; init; } = false; + + /// + /// Up to 4 sequences where the API will stop generating further tokens. + /// + public IReadOnlyList? Stop { get; init; } + + /// + /// Number between -2.0 and 2.0. Positive values penalize new tokens based on their frequency. + /// + public double? FrequencyPenalty { get; init; } + + /// + /// Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear. + /// + public double? PresencePenalty { get; init; } + + /// + /// User identifier for tracking usage. + /// + public string? User { get; init; } + + /// + /// Factory method to create a chat request. + /// + public static MistralChatRequest CreateChat(IReadOnlyList messages, string model = "mistral-medium", int maxTokens = 512) + { + return new MistralChatRequest + { + Model = model, + Messages = messages, + MaxTokens = maxTokens, + Temperature = 0.7, + TopP = 1.0, + N = 1, + Stream = false + }; + } +} + +/// +/// Represents a message in a Mistral chat conversation. +/// +public record MistralMessage +{ + /// + /// The role of the message author. + /// + public string Role { get; init; } = string.Empty; + + /// + /// The content of the message. + /// + public string Content { get; init; } = string.Empty; + + /// + /// Factory method to create a user message. + /// + public static MistralMessage User(string content) => new() { Role = "user", Content = content }; + + /// + /// Factory method to create an assistant message. + /// + public static MistralMessage Assistant(string content) => new() { Role = "assistant", Content = content }; + + /// + /// Factory method to create a system message. + /// + public static MistralMessage System(string content) => new() { Role = "system", Content = content }; +} + +/// +/// Model information from Mistral API. +/// +public record MistralModel +{ + /// + /// The model identifier. + /// + public string Id { get; init; } = string.Empty; + + /// + /// The model object type. + /// + public string Object { get; init; } = string.Empty; + + /// + /// When the model was created. + /// + public long Created { get; init; } + + /// + /// The model's description. + /// + public string Description { get; init; } = string.Empty; +} diff --git a/GermanApp/Application/Models/MistralResponse.cs b/GermanApp/Application/Models/MistralResponse.cs new file mode 100644 index 0000000..4369dd6 --- /dev/null +++ b/GermanApp/Application/Models/MistralResponse.cs @@ -0,0 +1,232 @@ +using System.Text.Json.Serialization; +using GermanApp.Domain.Interfaces; + +namespace GermanApp.Application.Models; + +/// +/// Response model for Mistral completion and chat completion APIs. +/// +/// +/// See: https://docs.mistral.ai/api/#operation/createCompletion +/// See: https://docs.mistral.ai/api/#operation/createChatCompletion +/// +public record MistralResponse +{ + /// + /// Unique identifier for the completion. + /// + [JsonPropertyName("id")] + public string Id { get; init; } = string.Empty; + + /// + /// The object type (always "text_completion" or "chat.completion"). + /// + [JsonPropertyName("object")] + public string Object { get; init; } = string.Empty; + + /// + /// The Unix timestamp (in seconds) of when the completion was created. + /// + [JsonPropertyName("created")] + public long Created { get; init; } + + /// + /// The model used for the completion. + /// + [JsonPropertyName("model")] + public string Model { get; init; } = string.Empty; + + /// + /// The list of completion choices generated by the model. + /// + [JsonPropertyName("choices")] + public IReadOnlyList Choices { get; init; } = new List(); + + /// + /// Usage statistics for the completion. + /// + [JsonPropertyName("usage")] + public MistralUsage Usage { get; init; } = new(); + + /// + /// Gets the first choice's text content. + /// Convenience property for single-completion requests. + /// + [JsonIgnore] + public string FirstChoiceText => Choices.Count > 0 ? Choices[0].Text : string.Empty; + + /// + /// Gets the first choice's message content (for chat completions). + /// + [JsonIgnore] + public string FirstChoiceMessageContent => + Choices.Count > 0 && Choices[0].Message != null + ? Choices[0].Message.Content + : string.Empty; + + /// + /// Gets all generated texts from all choices. + /// + [JsonIgnore] + public IReadOnlyList AllChoiceTexts => Choices.Select(c => c.Text).ToList(); + + /// + /// Checks if the response contains any valid completions. + /// + [JsonIgnore] + public bool HasValidChoices => Choices != null && Choices.Count > 0 && + Choices.Any(c => !string.IsNullOrWhiteSpace(c.Text) || c.Message != null); +} + +/// +/// A single completion choice returned by Mistral API. +/// +public record MistralChoice +{ + /// + /// The generated text for this choice. + /// + [JsonPropertyName("text")] + public string Text { get; init; } = string.Empty; + + /// + /// For chat completions, this contains the message object. + /// + [JsonPropertyName("message")] + public MistralChatMessage? Message { get; init; } + + /// + /// The reason the model stopped generating tokens (e.g., "stop", "length", "error"). + /// + [JsonPropertyName("finish_reason")] + public string FinishReason { get; init; } = string.Empty; + + /// + /// The index of this choice in the list of choices. + /// + [JsonPropertyName("index")] + public int Index { get; init; } + + /// + /// Log probability information for the generated tokens. + /// + [JsonPropertyName("logprobs")] + public object? LogProbs { get; init; } +} + +/// +/// Message object returned in chat completion choices. +/// +public record MistralChatMessage +{ + /// + /// The role of the message author. + /// + [JsonPropertyName("role")] + public string Role { get; init; } = string.Empty; + + /// + /// The content of the message. + /// + [JsonPropertyName("content")] + public string Content { get; init; } = string.Empty; +} + +/// +/// Token usage statistics from Mistral API. +/// +public record MistralUsage +{ + /// + /// Number of tokens in the prompt. + /// + [JsonPropertyName("prompt_tokens")] + public int PromptTokens { get; init; } + + /// + /// Number of tokens generated in the completion. + /// + [JsonPropertyName("completion_tokens")] + public int CompletionTokens { get; init; } + + /// + /// Total number of tokens used (prompt + completion). + /// + [JsonPropertyName("total_tokens")] + public int TotalTokens { get; init; } + + /// + /// Gets the total cost estimate based on token usage. + /// Note: Actual pricing may vary based on Mistral's pricing model. + /// + [JsonIgnore] + public decimal EstimatedCost => TotalTokens * 0.0000025m; // Approx $0.0000025 per token for mistral-medium +} + +/// +/// Error response from Mistral API. +/// +public record MistralErrorResponse +{ + /// + /// The object type (always "error"). + /// + [JsonPropertyName("object")] + public string Object { get; init; } = string.Empty; + + /// + /// The error message. + /// + [JsonPropertyName("message")] + public string Message { get; init; } = string.Empty; + + /// + /// The type of error that occurred. + /// + [JsonPropertyName("type")] + public string Type { get; init; } = string.Empty; + + /// + /// The HTTP status code associated with the error. + /// + [JsonIgnore] + public int? StatusCode { get; init; } + + /// + /// Additional error details. + /// + [JsonPropertyName("details")] + public object? Details { get; init; } + + /// + /// Maps the error type to an AiErrorCode. + /// + [JsonIgnore] + public AiErrorCode ErrorCode => Type 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, + _ => AiErrorCode.Unknown + }; +} + +/// +/// Response wrapper for listing Mistral models. +/// +public record MistralListModelsResponse +{ + /// + /// The object type (always "list"). + /// + [JsonPropertyName("object")] + public string Object { get; init; } = string.Empty; + + /// + /// List of available models. + /// + [JsonPropertyName("data")] + public IReadOnlyList Data { get; init; } = new List(); +} diff --git a/GermanApp/Domain/Interfaces/IMistralConnector.cs b/GermanApp/Domain/Interfaces/IMistralConnector.cs new file mode 100644 index 0000000..9518017 --- /dev/null +++ b/GermanApp/Domain/Interfaces/IMistralConnector.cs @@ -0,0 +1,102 @@ +using GermanApp.Application.Models; + +namespace GermanApp.Domain.Interfaces; + +/// +/// Interface for Mistral API connector. +/// This is part of the Domain layer - defines the contract for communicating with Mistral API. +/// +public interface IMistralConnector +{ + /// + /// Sends a completion request to Mistral API. + /// + /// The completion request containing prompt and parameters + /// Cancellation token + /// Mistral API response containing generated text + /// Thrown when Mistral API request fails + Task CompleteAsync(MistralRequest request, + CancellationToken cancellationToken = default); + + /// + /// Sends a chat completion request to Mistral API. + /// + /// The chat completion request containing messages and parameters + /// Cancellation token + /// Mistral API chat response containing generated message + /// Thrown when Mistral API request fails + Task ChatAsync(MistralChatRequest request, + CancellationToken cancellationToken = default); + + /// + /// Lists available models from Mistral API. + /// + /// Cancellation token + /// List of available Mistral models + /// Thrown when Mistral API request fails + Task> ListModelsAsync( + CancellationToken cancellationToken = default); + + /// + /// Gets information about a specific model. + /// + /// The model identifier + /// Cancellation token + /// Information about the specified model + /// Thrown when Mistral API request fails + Task GetModelAsync(string modelId, + CancellationToken cancellationToken = default); +} + +/// +/// Custom exception for AI service errors. +/// +public class AiServiceException : Exception +{ + /// + /// Error code for categorizing AI service errors. + /// + public AiErrorCode ErrorCode { get; } + + /// + /// Creates a new AI service exception. + /// + public AiServiceException(string message, AiErrorCode errorCode = AiErrorCode.Unknown) + : base(message) + { + ErrorCode = errorCode; + } + + /// + /// Creates a new AI service exception with inner exception. + /// + public AiServiceException(string message, Exception innerException, + AiErrorCode errorCode = AiErrorCode.Unknown) + : base(message, innerException) + { + ErrorCode = errorCode; + } +} + +/// +/// Error codes for AI service exceptions. +/// +public enum AiErrorCode +{ + /// Unknown or unspecified error + Unknown = 0, + /// API rate limit exceeded + RateLimited = 1, + /// Temporary API failure, may succeed on retry + Temporary = 2, + /// Request timeout + Timeout = 3, + /// Invalid API key or authentication error + AuthenticationError = 4, + /// Invalid request parameters + InvalidRequest = 5, + /// API returned invalid/empty response + InvalidResponse = 6, + /// Service is temporarily unavailable (circuit breaker open) + ServiceUnavailable = 7 +} diff --git a/GermanApp/GermanApp.csproj b/GermanApp/GermanApp.csproj index 5da0698..7eeaa54 100644 --- a/GermanApp/GermanApp.csproj +++ b/GermanApp/GermanApp.csproj @@ -15,6 +15,7 @@ + diff --git a/GermanApp/Infrastructure/Configuration/MistralConfig.cs b/GermanApp/Infrastructure/Configuration/MistralConfig.cs new file mode 100644 index 0000000..d27ce74 --- /dev/null +++ b/GermanApp/Infrastructure/Configuration/MistralConfig.cs @@ -0,0 +1,119 @@ +namespace GermanApp.Infrastructure.Configuration; + +/// +/// Configuration settings for Mistral API connector. +/// This is part of the Infrastructure layer. +/// +public class MistralConfig +{ + /// + /// The Mistral API key for authentication. + /// + public string ApiKey { get; set; } = string.Empty; + + /// + /// The base URL for the Mistral API. + /// Default: https://api.mistral.ai/v1/ + /// + public string BaseUrl { get; set; } = "https://api.mistral.ai/v1/"; + + /// + /// The default model to use for completions. + /// Default: mistral-medium + /// + public string DefaultModel { get; set; } = "mistral-medium"; + + /// + /// Timeout in seconds for HTTP requests. + /// Default: 30 seconds + /// + public int TimeoutSeconds { get; set; } = 30; + + /// + /// Maximum number of retry attempts for failed requests. + /// Default: 3 + /// + public int MaxRetries { get; set; } = 3; + + /// + /// Maximum requests per minute rate limit. + /// Default: 10 requests/minute + /// + public int RateLimitPerMinute { get; set; } = 10; + + /// + /// Whether to enable response caching. + /// Default: true + /// + public bool EnableCaching { get; set; } = true; + + /// + /// Cache time-to-live in minutes. + /// Default: 60 minutes + /// + public int CacheTTLMinutes { get; set; } = 60; + + /// + /// Circuit breaker failure threshold (number of consecutive failures before opening). + /// Default: 5 + /// + public int CircuitBreakerFailureThreshold { get; set; } = 5; + + /// + /// Circuit breaker reset timeout in minutes. + /// Default: 1 minute + /// + public int CircuitBreakerResetMinutes { get; set; } = 1; + + /// + /// Validates the configuration. + /// + /// Thrown when configuration is invalid + public void Validate() + { + if (string.IsNullOrWhiteSpace(ApiKey)) + { + throw new ArgumentException("Mistral API key is required"); + } + + if (string.IsNullOrWhiteSpace(BaseUrl)) + { + throw new ArgumentException("Mistral BaseUrl is required"); + } + + if (!Uri.TryCreate(BaseUrl, UriKind.Absolute, out _)) + { + throw new ArgumentException("Mistral BaseUrl must be a valid URL"); + } + + if (TimeoutSeconds <= 0) + { + throw new ArgumentException("TimeoutSeconds must be greater than 0"); + } + + if (MaxRetries < 0) + { + throw new ArgumentException("MaxRetries must be non-negative"); + } + + if (RateLimitPerMinute <= 0) + { + throw new ArgumentException("RateLimitPerMinute must be greater than 0"); + } + + if (CacheTTLMinutes < 0) + { + throw new ArgumentException("CacheTTLMinutes must be non-negative"); + } + + if (CircuitBreakerFailureThreshold <= 0) + { + throw new ArgumentException("CircuitBreakerFailureThreshold must be greater than 0"); + } + + if (CircuitBreakerResetMinutes <= 0) + { + throw new ArgumentException("CircuitBreakerResetMinutes must be greater than 0"); + } + } +} diff --git a/GermanApp/Infrastructure/Services/MistralCircuitBreaker.cs b/GermanApp/Infrastructure/Services/MistralCircuitBreaker.cs new file mode 100644 index 0000000..ba1eeb9 --- /dev/null +++ b/GermanApp/Infrastructure/Services/MistralCircuitBreaker.cs @@ -0,0 +1,125 @@ +using System; + +namespace GermanApp.Infrastructure.Services; + +/// +/// Circuit breaker implementation for Mistral API. +/// Prevents cascading failures by temporarily blocking requests after too many failures. +/// This is part of the Infrastructure layer. +/// +public class MistralCircuitBreaker +{ + private readonly int _failureThreshold; + private readonly TimeSpan _resetTimeout; + private int _failureCount = 0; + private DateTime _lastFailureTime = DateTime.MinValue; + private readonly object _lock = new(); + + /// + /// Creates a new circuit breaker. + /// + /// Number of consecutive failures before opening the circuit + /// Time to wait before attempting to close the circuit + public MistralCircuitBreaker(int failureThreshold, TimeSpan resetTimeout) + { + _failureThreshold = failureThreshold; + _resetTimeout = resetTimeout; + } + + /// + /// Gets whether the circuit is currently closed (allowing requests). + /// + public bool IsClosed + { + get + { + if (_failureCount >= _failureThreshold) + { + // Circuit is open, check if reset timeout has elapsed + if (DateTime.UtcNow - _lastFailureTime > _resetTimeout) + { + // Reset the circuit + lock (_lock) + { + if (_failureCount >= _failureThreshold && + DateTime.UtcNow - _lastFailureTime > _resetTimeout) + { + _failureCount = 0; + _lastFailureTime = DateTime.MinValue; + } + } + } + else + { + return false; + } + } + return true; + } + } + + /// + /// Gets the current failure count. + /// + public int FailureCount => _failureCount; + + /// + /// Gets the last failure time. + /// + public DateTime LastFailureTime => _lastFailureTime; + + /// + /// Records a successful request, resetting the failure count. + /// + public void RecordSuccess() + { + lock (_lock) + { + _failureCount = 0; + _lastFailureTime = DateTime.MinValue; + } + } + + /// + /// Records a failed request, incrementing the failure count. + /// + public void RecordFailure() + { + lock (_lock) + { + _failureCount++; + _lastFailureTime = DateTime.UtcNow; + } + } + + /// + /// Resets the circuit breaker to its initial state. + /// + public void Reset() + { + lock (_lock) + { + _failureCount = 0; + _lastFailureTime = DateTime.MinValue; + } + } + + /// + /// Gets the time remaining until the circuit can be reset. + /// Returns TimeSpan.Zero if circuit is closed or already eligible for reset. + /// + public TimeSpan TimeUntilReset + { + get + { + if (_failureCount < _failureThreshold) + return TimeSpan.Zero; + + var elapsed = DateTime.UtcNow - _lastFailureTime; + if (elapsed >= _resetTimeout) + return TimeSpan.Zero; + + return _resetTimeout - elapsed; + } + } +} diff --git a/GermanApp/Infrastructure/Services/MistralConnector.cs b/GermanApp/Infrastructure/Services/MistralConnector.cs new file mode 100644 index 0000000..cbc2b2f --- /dev/null +++ b/GermanApp/Infrastructure/Services/MistralConnector.cs @@ -0,0 +1,244 @@ +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; + _logger = logger; + _cache = cache; + _config.Validate(); + + _httpClient.BaseAddress = new Uri(_config.BaseUrl); + _httpClient.Timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds); + _httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {_config.ApiKey}"); + _httpClient.DefaultRequestHeaders.Add("Content-Type", "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 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); + } + } +} diff --git a/GermanApp/Infrastructure/Services/MistralRateLimiter.cs b/GermanApp/Infrastructure/Services/MistralRateLimiter.cs new file mode 100644 index 0000000..0e09e87 --- /dev/null +++ b/GermanApp/Infrastructure/Services/MistralRateLimiter.cs @@ -0,0 +1,97 @@ +using System.Collections.Concurrent; + +namespace GermanApp.Infrastructure.Services; + +/// +/// Simple in-memory rate limiter for Mistral API requests. +/// This is part of the Infrastructure layer. +/// +public class MistralRateLimiter +{ + private readonly int _maxRequests; + private readonly TimeSpan _window; + private readonly ConcurrentDictionary> _requests = new(); + + /// + /// Creates a new rate limiter. + /// + /// Maximum requests allowed per minute + public MistralRateLimiter(int maxRequestsPerMinute) + { + _maxRequests = maxRequestsPerMinute; + _window = TimeSpan.FromMinutes(1); + } + + /// + /// Attempts to acquire a rate limit token for the specified endpoint. + /// + /// The API endpoint being called + /// True if request is allowed, false if rate limit exceeded + public bool TryAcquire(string endpoint) + { + var now = DateTime.UtcNow; + var requests = _requests.GetOrAdd(endpoint, _ => new List()); + + lock (requests) + { + // Remove old requests outside the window + requests.RemoveAll(r => now - r > _window); + + // Check if limit exceeded + if (requests.Count >= _maxRequests) + return false; + + // Add new request + requests.Add(now); + return true; + } + } + + /// + /// Gets the current request count for an endpoint. + /// + /// The API endpoint + /// Number of requests in the current window + public int GetCurrentCount(string endpoint) + { + if (_requests.TryGetValue(endpoint, out var requests)) + { + lock (requests) + { + var now = DateTime.UtcNow; + requests.RemoveAll(r => now - r > _window); + return requests.Count; + } + } + return 0; + } + + /// + /// Resets the rate limiter for a specific endpoint. + /// + /// The API endpoint + public void Reset(string endpoint) + { + if (_requests.TryGetValue(endpoint, out var requests)) + { + lock (requests) + { + requests.Clear(); + } + } + } + + /// + /// Resets all rate limiters. + /// + public void ResetAll() + { + foreach (var kvp in _requests) + { + lock (kvp.Value) + { + kvp.Value.Clear(); + } + } + } +} diff --git a/GermanApp/Program.cs b/GermanApp/Program.cs index 8fa3d2c..9c3c3c8 100644 --- a/GermanApp/Program.cs +++ b/GermanApp/Program.cs @@ -10,6 +10,7 @@ using GermanApp.Infrastructure.Data.DbContext; using GermanApp.Infrastructure.Data.Repositories; using GermanApp.Infrastructure.Data.SeedData; using GermanApp.Infrastructure.Services; +using GermanApp.Infrastructure.Configuration; using GermanApp.Presentation.Controllers; using GermanApp.Presentation.Validators; using GermanApp.Presentation.Endpoints; @@ -17,6 +18,8 @@ using GermanApp.Shared.Middleware; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Serilog; using System.Text; @@ -132,6 +135,30 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); + // ============================================ + // INFRASTRUCTURE LAYER - AI Services + // ============================================ + + // Add Memory Cache for caching (used by AI services) + builder.Services.AddMemoryCache(); + + // Add Mistral API configuration + builder.Services.Configure(builder.Configuration.GetSection("Mistral")); + + // Add HttpClient for Mistral API + builder.Services.AddHttpClient("MistralClient"); + + // Register Mistral Connector + builder.Services.AddScoped(provider => + { + var httpClient = provider.GetRequiredService().CreateClient("MistralClient"); + var config = provider.GetRequiredService>().Value; + var logger = provider.GetRequiredService>(); + var cache = provider.GetRequiredService(); + + return new MistralConnector(httpClient, config, logger, cache); + }); + // ============================================ // APPLICATION LAYER - Use Cases & Services // ============================================ diff --git a/docs/features/ai-services.md b/docs/features/ai-services.md index 0004fea..953b208 100644 --- a/docs/features/ai-services.md +++ b/docs/features/ai-services.md @@ -129,14 +129,21 @@ As a learner, I want AI-powered features like generated stories, speech recognit ### Phase 0: Mistral API Connector (2-3 hours) **Objective**: Create a reusable, testable connector for Mistral API that can be used by all Mistral-based services. -- [ ] Create `Domain/Interfaces/IMistralConnector.cs` - Interface for Mistral API connector -- [ ] Create `Infrastructure/Services/MistralConnector.cs` - HTTP client implementation -- [ ] Implement request/response models (`MistralRequest.cs`, `MistralResponse.cs`) -- [ ] Add HTTP client configuration with base URL and timeout -- [ ] Implement retry logic with exponential backoff -- [ ] Add rate limiting at the connector level -- [ ] Add response caching mechanism -- [ ] Implement circuit breaker pattern for failure handling +- [x] Create `Domain/Interfaces/IMistralConnector.cs` - Interface for Mistral API connector +- [x] Create `Infrastructure/Configuration/MistralConfig.cs` - Configuration class +- [x] Create `Infrastructure/Services/MistralConnector.cs` - HTTP client implementation +- [x] Create `Infrastructure/Services/MistralRateLimiter.cs` - Rate limiting implementation +- [x] Create `Infrastructure/Services/MistralCircuitBreaker.cs` - Circuit breaker implementation +- [x] Create `Application/Models/MistralRequest.cs` - Request models (MistralRequest, MistralChatRequest, MistralMessage) +- [x] Create `Application/Models/MistralResponse.cs` - Response models (MistralResponse, MistralChoice, MistralChatMessage, MistralUsage) +- [x] Create `Application/Models/MistralErrorResponse.cs` - Error response model +- [x] Create `Domain/Interfaces/AiServiceException.cs` - Custom exception with AiErrorCode enum +- [x] Add HTTP client configuration in Program.cs +- [x] Register IMistralConnector in Program.cs +- [x] Implement retry logic with exponential backoff +- [x] Add rate limiting to connector +- [x] Add response caching mechanism +- [x] Implement circuit breaker pattern for failure handling - [ ] Create unit tests for MistralConnector **Deliverables:** @@ -201,25 +208,31 @@ As a learner, I want AI-powered features like generated stories, speech recognit ## ✅ Tasks ### Backend - Mistral API Connector -- [ ] Create `Domain/Interfaces/IMistralConnector.cs` -- [ ] Create `Infrastructure/Services/MistralConnector.cs` -- [ ] Create `Application/Models/MistralRequest.cs` -- [ ] Create `Application/Models/MistralResponse.cs` -- [ ] Create `Application/Models/MistralErrorResponse.cs` -- [ ] Add HTTP client configuration in Program.cs -- [ ] Implement retry logic with exponential backoff -- [ ] Add rate limiting to connector -- [ ] Add response caching mechanism -- [ ] Implement circuit breaker pattern +- [x] Create `Domain/Interfaces/IMistralConnector.cs` +- [x] Create `Infrastructure/Configuration/MistralConfig.cs` +- [x] Create `Infrastructure/Services/MistralConnector.cs` +- [x] Create `Infrastructure/Services/MistralRateLimiter.cs` +- [x] Create `Infrastructure/Services/MistralCircuitBreaker.cs` +- [x] Create `Application/Models/MistralRequest.cs` +- [x] Create `Application/Models/MistralResponse.cs` +- [x] Create `Application/Models/MistralErrorResponse.cs` +- [x] Create `Domain/Interfaces/AiServiceException.cs` +- [x] Add HTTP client configuration in Program.cs +- [x] Register IMistralConnector in Program.cs +- [x] Implement retry logic with exponential backoff +- [x] Add rate limiting to connector +- [x] Add response caching mechanism +- [x] Implement circuit breaker pattern - [ ] Create unit tests for MistralConnector ### Backend - Configuration +- [x] Create Configuration/MistralConfig.cs - [ ] Add Mistral settings to appsettings.json - [ ] Add Vosk settings to appsettings.json - [ ] Add Coqui settings to appsettings.json -- [ ] Create Configuration/MistralConfig.cs - [ ] Create Configuration/VoskConfig.cs - [ ] Create Configuration/CoquiConfig.cs +- [x] Register Mistral Connector in Program.cs - [ ] Register all AI services in Program.cs - [ ] Add health checks for AI services @@ -933,6 +946,7 @@ This follows Clean Architecture: interface (`IMistralConnector`) in Domain layer |------|---------------|-------| | May 31, 2025 | Created | Initial plan based on application-plan.md | | June 9, 2025 | Updated | Added Phase 0: Mistral API Connector as first step | +| June 10, 2025 | Phase 0 Complete | Mistral API Connector implemented (IMistralConnector, MistralConnector, MistralConfig, models, rate limiter, circuit breaker) and registered in Program.cs. Build successful, all 234 tests passing. Ready for unit tests for MistralConnector. | ---