feat(backend/infrastructure): implement Mistral API Connector (Phase 0 of AI Services)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

- Add Domain/Interfaces/IMistralConnector.cs with AiServiceException and AiErrorCode enum
- Add Infrastructure/Configuration/MistralConfig.cs with validation
- Add Infrastructure/Services/MistralConnector.cs with HTTP client implementation
- Add Infrastructure/Services/MistralRateLimiter.cs for rate limiting
- Add Infrastructure/Services/MistralCircuitBreaker.cs for circuit breaker pattern
- Add Application/Models/MistralRequest.cs with request models
- Add Application/Models/MistralResponse.cs with response models
- Update Program.cs to register IMistralConnector with MemoryCache, HttpClient, and config
- Update GermanApp.csproj with existing dependencies
- Update docs/features/ai-services.md with Phase 0 completion

Phase 0 of AI Services feature is complete. Mistral API Connector is ready for use by MistralService.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Lasse Rune Hansen 2026-06-09 18:56:37 +02:00
parent fac3d1f269
commit 2b11367a97
10 changed files with 1216 additions and 19 deletions

View file

@ -0,0 +1,236 @@
namespace GermanApp.Application.Models;
/// <summary>
/// Request model for Mistral completion API.
/// Used for text generation (stories, feedback, etc.).
/// </summary>
/// <remarks>
/// See: https://docs.mistral.ai/api/#operation/createCompletion
/// </remarks>
public record MistralRequest
{
/// <summary>
/// ID of the model to use.
/// </summary>
public string Model { get; init; } = "mistral-medium";
/// <summary>
/// The prompt(s) to generate completions for.
/// </summary>
public string Prompt { get; init; } = string.Empty;
/// <summary>
/// The maximum number of tokens to generate in the completion.
/// Default: 256, Max: 2048 for mistral-medium
/// </summary>
public int? MaxTokens { get; init; } = 512;
/// <summary>
/// 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
/// </summary>
public double? Temperature { get; init; } = 0.7;
/// <summary>
/// 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
/// </summary>
public double? TopP { get; init; } = 1.0;
/// <summary>
/// How many completions to generate for each prompt.
/// Default: 1
/// </summary>
public int? N { get; init; } = 1;
/// <summary>
/// Whether to stream partial responses.
/// Not supported by this connector (use streaming endpoint separately if needed).
/// </summary>
public bool? Stream { get; init; } = false;
/// <summary>
/// Whether to echo the prompt in the response.
/// Default: false
/// </summary>
public bool? Echo { get; init; } = false;
/// <summary>
/// Up to 4 sequences where the API will stop generating further tokens.
/// </summary>
public IReadOnlyList<string>? Stop { get; init; }
/// <summary>
/// Number between -2.0 and 2.0. Positive values penalize new tokens based on their frequency in the text so far.
/// </summary>
public double? FrequencyPenalty { get; init; }
/// <summary>
/// Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far.
/// </summary>
public double? PresencePenalty { get; init; }
/// <summary>
/// User identifier for tracking usage.
/// </summary>
public string? User { get; init; }
/// <summary>
/// Factory method to create a completion request with default settings.
/// </summary>
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
};
}
}
/// <summary>
/// Request model for Mistral chat completion API.
/// Used for conversational text generation.
/// </summary>
/// <remarks>
/// See: https://docs.mistral.ai/api/#operation/createChatCompletion
/// </remarks>
public record MistralChatRequest
{
/// <summary>
/// ID of the model to use.
/// </summary>
public string Model { get; init; } = "mistral-medium";
/// <summary>
/// A list of messages comprising the conversation so far.
/// </summary>
public IReadOnlyList<MistralMessage> Messages { get; init; } = new List<MistralMessage>();
/// <summary>
/// The maximum number of tokens to generate in the completion.
/// </summary>
public int? MaxTokens { get; init; } = 512;
/// <summary>
/// What sampling temperature to use.
/// </summary>
public double? Temperature { get; init; } = 0.7;
/// <summary>
/// Nucleus sampling.
/// </summary>
public double? TopP { get; init; } = 1.0;
/// <summary>
/// How many chat completions to generate for each input message.
/// </summary>
public int? N { get; init; } = 1;
/// <summary>
/// Whether to stream partial responses.
/// </summary>
public bool? Stream { get; init; } = false;
/// <summary>
/// Up to 4 sequences where the API will stop generating further tokens.
/// </summary>
public IReadOnlyList<string>? Stop { get; init; }
/// <summary>
/// Number between -2.0 and 2.0. Positive values penalize new tokens based on their frequency.
/// </summary>
public double? FrequencyPenalty { get; init; }
/// <summary>
/// Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear.
/// </summary>
public double? PresencePenalty { get; init; }
/// <summary>
/// User identifier for tracking usage.
/// </summary>
public string? User { get; init; }
/// <summary>
/// Factory method to create a chat request.
/// </summary>
public static MistralChatRequest CreateChat(IReadOnlyList<MistralMessage> 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
};
}
}
/// <summary>
/// Represents a message in a Mistral chat conversation.
/// </summary>
public record MistralMessage
{
/// <summary>
/// The role of the message author.
/// </summary>
public string Role { get; init; } = string.Empty;
/// <summary>
/// The content of the message.
/// </summary>
public string Content { get; init; } = string.Empty;
/// <summary>
/// Factory method to create a user message.
/// </summary>
public static MistralMessage User(string content) => new() { Role = "user", Content = content };
/// <summary>
/// Factory method to create an assistant message.
/// </summary>
public static MistralMessage Assistant(string content) => new() { Role = "assistant", Content = content };
/// <summary>
/// Factory method to create a system message.
/// </summary>
public static MistralMessage System(string content) => new() { Role = "system", Content = content };
}
/// <summary>
/// Model information from Mistral API.
/// </summary>
public record MistralModel
{
/// <summary>
/// The model identifier.
/// </summary>
public string Id { get; init; } = string.Empty;
/// <summary>
/// The model object type.
/// </summary>
public string Object { get; init; } = string.Empty;
/// <summary>
/// When the model was created.
/// </summary>
public long Created { get; init; }
/// <summary>
/// The model's description.
/// </summary>
public string Description { get; init; } = string.Empty;
}

View file

@ -0,0 +1,232 @@
using System.Text.Json.Serialization;
using GermanApp.Domain.Interfaces;
namespace GermanApp.Application.Models;
/// <summary>
/// Response model for Mistral completion and chat completion APIs.
/// </summary>
/// <remarks>
/// See: https://docs.mistral.ai/api/#operation/createCompletion
/// See: https://docs.mistral.ai/api/#operation/createChatCompletion
/// </remarks>
public record MistralResponse
{
/// <summary>
/// Unique identifier for the completion.
/// </summary>
[JsonPropertyName("id")]
public string Id { get; init; } = string.Empty;
/// <summary>
/// The object type (always "text_completion" or "chat.completion").
/// </summary>
[JsonPropertyName("object")]
public string Object { get; init; } = string.Empty;
/// <summary>
/// The Unix timestamp (in seconds) of when the completion was created.
/// </summary>
[JsonPropertyName("created")]
public long Created { get; init; }
/// <summary>
/// The model used for the completion.
/// </summary>
[JsonPropertyName("model")]
public string Model { get; init; } = string.Empty;
/// <summary>
/// The list of completion choices generated by the model.
/// </summary>
[JsonPropertyName("choices")]
public IReadOnlyList<MistralChoice> Choices { get; init; } = new List<MistralChoice>();
/// <summary>
/// Usage statistics for the completion.
/// </summary>
[JsonPropertyName("usage")]
public MistralUsage Usage { get; init; } = new();
/// <summary>
/// Gets the first choice's text content.
/// Convenience property for single-completion requests.
/// </summary>
[JsonIgnore]
public string FirstChoiceText => Choices.Count > 0 ? Choices[0].Text : string.Empty;
/// <summary>
/// Gets the first choice's message content (for chat completions).
/// </summary>
[JsonIgnore]
public string FirstChoiceMessageContent =>
Choices.Count > 0 && Choices[0].Message != null
? Choices[0].Message.Content
: string.Empty;
/// <summary>
/// Gets all generated texts from all choices.
/// </summary>
[JsonIgnore]
public IReadOnlyList<string> AllChoiceTexts => Choices.Select(c => c.Text).ToList();
/// <summary>
/// Checks if the response contains any valid completions.
/// </summary>
[JsonIgnore]
public bool HasValidChoices => Choices != null && Choices.Count > 0 &&
Choices.Any(c => !string.IsNullOrWhiteSpace(c.Text) || c.Message != null);
}
/// <summary>
/// A single completion choice returned by Mistral API.
/// </summary>
public record MistralChoice
{
/// <summary>
/// The generated text for this choice.
/// </summary>
[JsonPropertyName("text")]
public string Text { get; init; } = string.Empty;
/// <summary>
/// For chat completions, this contains the message object.
/// </summary>
[JsonPropertyName("message")]
public MistralChatMessage? Message { get; init; }
/// <summary>
/// The reason the model stopped generating tokens (e.g., "stop", "length", "error").
/// </summary>
[JsonPropertyName("finish_reason")]
public string FinishReason { get; init; } = string.Empty;
/// <summary>
/// The index of this choice in the list of choices.
/// </summary>
[JsonPropertyName("index")]
public int Index { get; init; }
/// <summary>
/// Log probability information for the generated tokens.
/// </summary>
[JsonPropertyName("logprobs")]
public object? LogProbs { get; init; }
}
/// <summary>
/// Message object returned in chat completion choices.
/// </summary>
public record MistralChatMessage
{
/// <summary>
/// The role of the message author.
/// </summary>
[JsonPropertyName("role")]
public string Role { get; init; } = string.Empty;
/// <summary>
/// The content of the message.
/// </summary>
[JsonPropertyName("content")]
public string Content { get; init; } = string.Empty;
}
/// <summary>
/// Token usage statistics from Mistral API.
/// </summary>
public record MistralUsage
{
/// <summary>
/// Number of tokens in the prompt.
/// </summary>
[JsonPropertyName("prompt_tokens")]
public int PromptTokens { get; init; }
/// <summary>
/// Number of tokens generated in the completion.
/// </summary>
[JsonPropertyName("completion_tokens")]
public int CompletionTokens { get; init; }
/// <summary>
/// Total number of tokens used (prompt + completion).
/// </summary>
[JsonPropertyName("total_tokens")]
public int TotalTokens { get; init; }
/// <summary>
/// Gets the total cost estimate based on token usage.
/// Note: Actual pricing may vary based on Mistral's pricing model.
/// </summary>
[JsonIgnore]
public decimal EstimatedCost => TotalTokens * 0.0000025m; // Approx $0.0000025 per token for mistral-medium
}
/// <summary>
/// Error response from Mistral API.
/// </summary>
public record MistralErrorResponse
{
/// <summary>
/// The object type (always "error").
/// </summary>
[JsonPropertyName("object")]
public string Object { get; init; } = string.Empty;
/// <summary>
/// The error message.
/// </summary>
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
/// <summary>
/// The type of error that occurred.
/// </summary>
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
/// <summary>
/// The HTTP status code associated with the error.
/// </summary>
[JsonIgnore]
public int? StatusCode { get; init; }
/// <summary>
/// Additional error details.
/// </summary>
[JsonPropertyName("details")]
public object? Details { get; init; }
/// <summary>
/// Maps the error type to an AiErrorCode.
/// </summary>
[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
};
}
/// <summary>
/// Response wrapper for listing Mistral models.
/// </summary>
public record MistralListModelsResponse
{
/// <summary>
/// The object type (always "list").
/// </summary>
[JsonPropertyName("object")]
public string Object { get; init; } = string.Empty;
/// <summary>
/// List of available models.
/// </summary>
[JsonPropertyName("data")]
public IReadOnlyList<MistralModel> Data { get; init; } = new List<MistralModel>();
}

View file

@ -0,0 +1,102 @@
using GermanApp.Application.Models;
namespace GermanApp.Domain.Interfaces;
/// <summary>
/// Interface for Mistral API connector.
/// This is part of the Domain layer - defines the contract for communicating with Mistral API.
/// </summary>
public interface IMistralConnector
{
/// <summary>
/// Sends a completion request to Mistral API.
/// </summary>
/// <param name="request">The completion request containing prompt and parameters</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Mistral API response containing generated text</returns>
/// <exception cref="AiServiceException">Thrown when Mistral API request fails</exception>
Task<MistralResponse> CompleteAsync(MistralRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Sends a chat completion request to Mistral API.
/// </summary>
/// <param name="request">The chat completion request containing messages and parameters</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Mistral API chat response containing generated message</returns>
/// <exception cref="AiServiceException">Thrown when Mistral API request fails</exception>
Task<MistralResponse> ChatAsync(MistralChatRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists available models from Mistral API.
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of available Mistral models</returns>
/// <exception cref="AiServiceException">Thrown when Mistral API request fails</exception>
Task<IReadOnlyList<MistralModel>> ListModelsAsync(
CancellationToken cancellationToken = default);
/// <summary>
/// Gets information about a specific model.
/// </summary>
/// <param name="modelId">The model identifier</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Information about the specified model</returns>
/// <exception cref="AiServiceException">Thrown when Mistral API request fails</exception>
Task<MistralModel> GetModelAsync(string modelId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Custom exception for AI service errors.
/// </summary>
public class AiServiceException : Exception
{
/// <summary>
/// Error code for categorizing AI service errors.
/// </summary>
public AiErrorCode ErrorCode { get; }
/// <summary>
/// Creates a new AI service exception.
/// </summary>
public AiServiceException(string message, AiErrorCode errorCode = AiErrorCode.Unknown)
: base(message)
{
ErrorCode = errorCode;
}
/// <summary>
/// Creates a new AI service exception with inner exception.
/// </summary>
public AiServiceException(string message, Exception innerException,
AiErrorCode errorCode = AiErrorCode.Unknown)
: base(message, innerException)
{
ErrorCode = errorCode;
}
}
/// <summary>
/// Error codes for AI service exceptions.
/// </summary>
public enum AiErrorCode
{
/// <summary>Unknown or unspecified error</summary>
Unknown = 0,
/// <summary>API rate limit exceeded</summary>
RateLimited = 1,
/// <summary>Temporary API failure, may succeed on retry</summary>
Temporary = 2,
/// <summary>Request timeout</summary>
Timeout = 3,
/// <summary>Invalid API key or authentication error</summary>
AuthenticationError = 4,
/// <summary>Invalid request parameters</summary>
InvalidRequest = 5,
/// <summary>API returned invalid/empty response</summary>
InvalidResponse = 6,
/// <summary>Service is temporarily unavailable (circuit breaker open)</summary>
ServiceUnavailable = 7
}

View file

@ -15,6 +15,7 @@
<ItemGroup>
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.16" />
<PackageReference Include="Polly" Version="8.6.6" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />

View file

@ -0,0 +1,119 @@
namespace GermanApp.Infrastructure.Configuration;
/// <summary>
/// Configuration settings for Mistral API connector.
/// This is part of the Infrastructure layer.
/// </summary>
public class MistralConfig
{
/// <summary>
/// The Mistral API key for authentication.
/// </summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>
/// The base URL for the Mistral API.
/// Default: https://api.mistral.ai/v1/
/// </summary>
public string BaseUrl { get; set; } = "https://api.mistral.ai/v1/";
/// <summary>
/// The default model to use for completions.
/// Default: mistral-medium
/// </summary>
public string DefaultModel { get; set; } = "mistral-medium";
/// <summary>
/// Timeout in seconds for HTTP requests.
/// Default: 30 seconds
/// </summary>
public int TimeoutSeconds { get; set; } = 30;
/// <summary>
/// Maximum number of retry attempts for failed requests.
/// Default: 3
/// </summary>
public int MaxRetries { get; set; } = 3;
/// <summary>
/// Maximum requests per minute rate limit.
/// Default: 10 requests/minute
/// </summary>
public int RateLimitPerMinute { get; set; } = 10;
/// <summary>
/// Whether to enable response caching.
/// Default: true
/// </summary>
public bool EnableCaching { get; set; } = true;
/// <summary>
/// Cache time-to-live in minutes.
/// Default: 60 minutes
/// </summary>
public int CacheTTLMinutes { get; set; } = 60;
/// <summary>
/// Circuit breaker failure threshold (number of consecutive failures before opening).
/// Default: 5
/// </summary>
public int CircuitBreakerFailureThreshold { get; set; } = 5;
/// <summary>
/// Circuit breaker reset timeout in minutes.
/// Default: 1 minute
/// </summary>
public int CircuitBreakerResetMinutes { get; set; } = 1;
/// <summary>
/// Validates the configuration.
/// </summary>
/// <exception cref="ArgumentException">Thrown when configuration is invalid</exception>
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");
}
}
}

View file

@ -0,0 +1,125 @@
using System;
namespace GermanApp.Infrastructure.Services;
/// <summary>
/// Circuit breaker implementation for Mistral API.
/// Prevents cascading failures by temporarily blocking requests after too many failures.
/// This is part of the Infrastructure layer.
/// </summary>
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();
/// <summary>
/// Creates a new circuit breaker.
/// </summary>
/// <param name="failureThreshold">Number of consecutive failures before opening the circuit</param>
/// <param name="resetTimeout">Time to wait before attempting to close the circuit</param>
public MistralCircuitBreaker(int failureThreshold, TimeSpan resetTimeout)
{
_failureThreshold = failureThreshold;
_resetTimeout = resetTimeout;
}
/// <summary>
/// Gets whether the circuit is currently closed (allowing requests).
/// </summary>
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;
}
}
/// <summary>
/// Gets the current failure count.
/// </summary>
public int FailureCount => _failureCount;
/// <summary>
/// Gets the last failure time.
/// </summary>
public DateTime LastFailureTime => _lastFailureTime;
/// <summary>
/// Records a successful request, resetting the failure count.
/// </summary>
public void RecordSuccess()
{
lock (_lock)
{
_failureCount = 0;
_lastFailureTime = DateTime.MinValue;
}
}
/// <summary>
/// Records a failed request, incrementing the failure count.
/// </summary>
public void RecordFailure()
{
lock (_lock)
{
_failureCount++;
_lastFailureTime = DateTime.UtcNow;
}
}
/// <summary>
/// Resets the circuit breaker to its initial state.
/// </summary>
public void Reset()
{
lock (_lock)
{
_failureCount = 0;
_lastFailureTime = DateTime.MinValue;
}
}
/// <summary>
/// Gets the time remaining until the circuit can be reset.
/// Returns TimeSpan.Zero if circuit is closed or already eligible for reset.
/// </summary>
public TimeSpan TimeUntilReset
{
get
{
if (_failureCount < _failureThreshold)
return TimeSpan.Zero;
var elapsed = DateTime.UtcNow - _lastFailureTime;
if (elapsed >= _resetTimeout)
return TimeSpan.Zero;
return _resetTimeout - elapsed;
}
}
}

View file

@ -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;
/// <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;
_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<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);
}
}
}

View file

@ -0,0 +1,97 @@
using System.Collections.Concurrent;
namespace GermanApp.Infrastructure.Services;
/// <summary>
/// Simple in-memory rate limiter for Mistral API requests.
/// This is part of the Infrastructure layer.
/// </summary>
public class MistralRateLimiter
{
private readonly int _maxRequests;
private readonly TimeSpan _window;
private readonly ConcurrentDictionary<string, List<DateTime>> _requests = new();
/// <summary>
/// Creates a new rate limiter.
/// </summary>
/// <param name="maxRequestsPerMinute">Maximum requests allowed per minute</param>
public MistralRateLimiter(int maxRequestsPerMinute)
{
_maxRequests = maxRequestsPerMinute;
_window = TimeSpan.FromMinutes(1);
}
/// <summary>
/// Attempts to acquire a rate limit token for the specified endpoint.
/// </summary>
/// <param name="endpoint">The API endpoint being called</param>
/// <returns>True if request is allowed, false if rate limit exceeded</returns>
public bool TryAcquire(string endpoint)
{
var now = DateTime.UtcNow;
var requests = _requests.GetOrAdd(endpoint, _ => new List<DateTime>());
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;
}
}
/// <summary>
/// Gets the current request count for an endpoint.
/// </summary>
/// <param name="endpoint">The API endpoint</param>
/// <returns>Number of requests in the current window</returns>
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;
}
/// <summary>
/// Resets the rate limiter for a specific endpoint.
/// </summary>
/// <param name="endpoint">The API endpoint</param>
public void Reset(string endpoint)
{
if (_requests.TryGetValue(endpoint, out var requests))
{
lock (requests)
{
requests.Clear();
}
}
}
/// <summary>
/// Resets all rate limiters.
/// </summary>
public void ResetAll()
{
foreach (var kvp in _requests)
{
lock (kvp.Value)
{
kvp.Value.Clear();
}
}
}
}

View file

@ -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<ILessonRepository, LessonRepository>();
builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>();
// ============================================
// INFRASTRUCTURE LAYER - AI Services
// ============================================
// Add Memory Cache for caching (used by AI services)
builder.Services.AddMemoryCache();
// Add Mistral API configuration
builder.Services.Configure<MistralConfig>(builder.Configuration.GetSection("Mistral"));
// Add HttpClient for Mistral API
builder.Services.AddHttpClient("MistralClient");
// Register Mistral Connector
builder.Services.AddScoped<IMistralConnector>(provider =>
{
var httpClient = provider.GetRequiredService<IHttpClientFactory>().CreateClient("MistralClient");
var config = provider.GetRequiredService<IOptions<MistralConfig>>().Value;
var logger = provider.GetRequiredService<ILogger<MistralConnector>>();
var cache = provider.GetRequiredService<IMemoryCache>();
return new MistralConnector(httpClient, config, logger, cache);
});
// ============================================
// APPLICATION LAYER - Use Cases & Services
// ============================================

View file

@ -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. |
---