feat(backend/infrastructure): implement Mistral API Connector (Phase 0 of AI Services)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
fac3d1f269
commit
2b11367a97
10 changed files with 1216 additions and 19 deletions
236
GermanApp/Application/Models/MistralRequest.cs
Normal file
236
GermanApp/Application/Models/MistralRequest.cs
Normal 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;
|
||||||
|
}
|
||||||
232
GermanApp/Application/Models/MistralResponse.cs
Normal file
232
GermanApp/Application/Models/MistralResponse.cs
Normal 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>();
|
||||||
|
}
|
||||||
102
GermanApp/Domain/Interfaces/IMistralConnector.cs
Normal file
102
GermanApp/Domain/Interfaces/IMistralConnector.cs
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
|
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.16" />
|
<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="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
|
||||||
|
|
|
||||||
119
GermanApp/Infrastructure/Configuration/MistralConfig.cs
Normal file
119
GermanApp/Infrastructure/Configuration/MistralConfig.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
125
GermanApp/Infrastructure/Services/MistralCircuitBreaker.cs
Normal file
125
GermanApp/Infrastructure/Services/MistralCircuitBreaker.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
244
GermanApp/Infrastructure/Services/MistralConnector.cs
Normal file
244
GermanApp/Infrastructure/Services/MistralConnector.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
97
GermanApp/Infrastructure/Services/MistralRateLimiter.cs
Normal file
97
GermanApp/Infrastructure/Services/MistralRateLimiter.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ using GermanApp.Infrastructure.Data.DbContext;
|
||||||
using GermanApp.Infrastructure.Data.Repositories;
|
using GermanApp.Infrastructure.Data.Repositories;
|
||||||
using GermanApp.Infrastructure.Data.SeedData;
|
using GermanApp.Infrastructure.Data.SeedData;
|
||||||
using GermanApp.Infrastructure.Services;
|
using GermanApp.Infrastructure.Services;
|
||||||
|
using GermanApp.Infrastructure.Configuration;
|
||||||
using GermanApp.Presentation.Controllers;
|
using GermanApp.Presentation.Controllers;
|
||||||
using GermanApp.Presentation.Validators;
|
using GermanApp.Presentation.Validators;
|
||||||
using GermanApp.Presentation.Endpoints;
|
using GermanApp.Presentation.Endpoints;
|
||||||
|
|
@ -17,6 +18,8 @@ using GermanApp.Shared.Middleware;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
@ -132,6 +135,30 @@ try
|
||||||
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
|
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
|
||||||
builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>();
|
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
|
// APPLICATION LAYER - Use Cases & Services
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
|
||||||
|
|
@ -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)
|
### 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.
|
**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
|
- [x] Create `Domain/Interfaces/IMistralConnector.cs` - Interface for Mistral API connector
|
||||||
- [ ] Create `Infrastructure/Services/MistralConnector.cs` - HTTP client implementation
|
- [x] Create `Infrastructure/Configuration/MistralConfig.cs` - Configuration class
|
||||||
- [ ] Implement request/response models (`MistralRequest.cs`, `MistralResponse.cs`)
|
- [x] Create `Infrastructure/Services/MistralConnector.cs` - HTTP client implementation
|
||||||
- [ ] Add HTTP client configuration with base URL and timeout
|
- [x] Create `Infrastructure/Services/MistralRateLimiter.cs` - Rate limiting implementation
|
||||||
- [ ] Implement retry logic with exponential backoff
|
- [x] Create `Infrastructure/Services/MistralCircuitBreaker.cs` - Circuit breaker implementation
|
||||||
- [ ] Add rate limiting at the connector level
|
- [x] Create `Application/Models/MistralRequest.cs` - Request models (MistralRequest, MistralChatRequest, MistralMessage)
|
||||||
- [ ] Add response caching mechanism
|
- [x] Create `Application/Models/MistralResponse.cs` - Response models (MistralResponse, MistralChoice, MistralChatMessage, MistralUsage)
|
||||||
- [ ] Implement circuit breaker pattern for failure handling
|
- [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
|
- [ ] Create unit tests for MistralConnector
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
|
|
@ -201,25 +208,31 @@ As a learner, I want AI-powered features like generated stories, speech recognit
|
||||||
## ✅ Tasks
|
## ✅ Tasks
|
||||||
|
|
||||||
### Backend - Mistral API Connector
|
### Backend - Mistral API Connector
|
||||||
- [ ] Create `Domain/Interfaces/IMistralConnector.cs`
|
- [x] Create `Domain/Interfaces/IMistralConnector.cs`
|
||||||
- [ ] Create `Infrastructure/Services/MistralConnector.cs`
|
- [x] Create `Infrastructure/Configuration/MistralConfig.cs`
|
||||||
- [ ] Create `Application/Models/MistralRequest.cs`
|
- [x] Create `Infrastructure/Services/MistralConnector.cs`
|
||||||
- [ ] Create `Application/Models/MistralResponse.cs`
|
- [x] Create `Infrastructure/Services/MistralRateLimiter.cs`
|
||||||
- [ ] Create `Application/Models/MistralErrorResponse.cs`
|
- [x] Create `Infrastructure/Services/MistralCircuitBreaker.cs`
|
||||||
- [ ] Add HTTP client configuration in Program.cs
|
- [x] Create `Application/Models/MistralRequest.cs`
|
||||||
- [ ] Implement retry logic with exponential backoff
|
- [x] Create `Application/Models/MistralResponse.cs`
|
||||||
- [ ] Add rate limiting to connector
|
- [x] Create `Application/Models/MistralErrorResponse.cs`
|
||||||
- [ ] Add response caching mechanism
|
- [x] Create `Domain/Interfaces/AiServiceException.cs`
|
||||||
- [ ] Implement circuit breaker pattern
|
- [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
|
- [ ] Create unit tests for MistralConnector
|
||||||
|
|
||||||
### Backend - Configuration
|
### Backend - Configuration
|
||||||
|
- [x] Create Configuration/MistralConfig.cs
|
||||||
- [ ] Add Mistral settings to appsettings.json
|
- [ ] Add Mistral settings to appsettings.json
|
||||||
- [ ] Add Vosk settings to appsettings.json
|
- [ ] Add Vosk settings to appsettings.json
|
||||||
- [ ] Add Coqui settings to appsettings.json
|
- [ ] Add Coqui settings to appsettings.json
|
||||||
- [ ] Create Configuration/MistralConfig.cs
|
|
||||||
- [ ] Create Configuration/VoskConfig.cs
|
- [ ] Create Configuration/VoskConfig.cs
|
||||||
- [ ] Create Configuration/CoquiConfig.cs
|
- [ ] Create Configuration/CoquiConfig.cs
|
||||||
|
- [x] Register Mistral Connector in Program.cs
|
||||||
- [ ] Register all AI services in Program.cs
|
- [ ] Register all AI services in Program.cs
|
||||||
- [ ] Add health checks for AI services
|
- [ ] 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 |
|
| 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 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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue