using System.Runtime.CompilerServices; using System.Text.Json; using Luna.Providers.Mistral.DTOs; using Microsoft.Extensions.AI; namespace Luna.Providers.Mistral; /// /// Custom IChatClient implementation for the Mistral API. /// Implements chat completion according to the Mistral OpenAPI spec. /// public class MistralChatClient : IChatClient { private readonly HttpClient httpClient; private readonly string model; private bool disposed; public MistralChatClient(HttpClient httpClient, string model) { this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); this.model = model ?? throw new ArgumentNullException(nameof(model)); } public async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) { var request = CreateChatCompletionRequest(messages, options); var response = await SendChatCompletionRequest(request, cancellationToken); return MapToChatResponse(response); } public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var request = CreateChatCompletionRequest(messages, options, true); await foreach (var update in SendStreamingRequest(request, cancellationToken)) { yield return MapToChatResponseUpdate(update); } } public object? GetService(Type serviceType, object? serviceKey = null) { return serviceType == typeof(HttpClient) ? httpClient : null; } public ChatClientMetadata Metadata => new( "Mistral", httpClient.BaseAddress, model ); public void Dispose() { GC.SuppressFinalize(this); if (disposed) return; httpClient.Dispose(); disposed = true; } /// /// Creates a chat completion request according to the Mistral API spec. /// Reference: /v1/chat/completions endpoint in the OpenAPI spec. /// private MistralChatCompletionRequest CreateChatCompletionRequest(IEnumerable messages, ChatOptions? options, bool streaming = false) { var messagesWithInstructions = new List(); if (!string.IsNullOrWhiteSpace(options?.Instructions)) { messagesWithInstructions.Add(new ChatMessage(ChatRole.System, options.Instructions)); } messagesWithInstructions.AddRange(messages); var request = new MistralChatCompletionRequest { Model = options?.ModelId ?? model, Messages = messagesWithInstructions.Select(m => new MistralMessage { Role = m.Role.ToString().ToLowerInvariant(), Content = m.Text }).ToList(), Temperature = options?.Temperature, MaxTokens = options?.MaxOutputTokens, TopP = options?.TopP, Stream = streaming, ResponseFormat = options?.ResponseFormat is ChatResponseFormatJson ? new ResponseFormat { Type = "json_object" } : null }; return request; } private async Task SendChatCompletionRequest( MistralChatCompletionRequest request, CancellationToken cancellationToken) { var json = JsonSerializer.Serialize(request, MistralJsonContext.Default.MistralChatCompletionRequest); var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); var response = await httpClient.PostAsync("chat/completions", content, cancellationToken); response.EnsureSuccessStatusCode(); var responseJson = await response.Content.ReadAsStringAsync(cancellationToken); return JsonSerializer.Deserialize(responseJson, MistralJsonContext.Default.MistralChatCompletionResponse) ?? throw new InvalidOperationException("Failed to deserialize response"); } private async IAsyncEnumerable SendStreamingRequest( MistralChatCompletionRequest request, [EnumeratorCancellation] CancellationToken cancellationToken) { var json = JsonSerializer.Serialize(request, MistralJsonContext.Default.MistralChatCompletionRequest); var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); var response = await httpClient.PostAsync("chat/completions", content, cancellationToken); response.EnsureSuccessStatusCode(); var stream = await response.Content.ReadAsStreamAsync(cancellationToken); using var reader = new StreamReader(stream); while (!cancellationToken.IsCancellationRequested) { var line = await reader.ReadLineAsync(cancellationToken); if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: ")) continue; var data = line[6..]; // Remove "data: " prefix if (data == "[DONE]") yield break; var chunk = JsonSerializer.Deserialize(data, MistralJsonContext.Default.MistralChatCompletionChunk); if (chunk != null) { yield return chunk; } } } private static ChatResponse MapToChatResponse(MistralChatCompletionResponse response) { var choice = response.Choices?.FirstOrDefault(); if (choice?.Message == null) { throw new InvalidOperationException("No choices in response"); } return new ChatResponse( new ChatMessage( MapRole(choice.Message.Role), choice.Message.Content ) ); } private static ChatResponseUpdate MapToChatResponseUpdate(MistralChatCompletionChunk chunk) { var choice = chunk.Choices?.FirstOrDefault(); if (choice?.Delta == null) { return new ChatResponseUpdate(); } return new ChatResponseUpdate( MapRole(choice.Delta.Role), choice.Delta.Content ); } private static ChatRole MapRole(string? role) => role?.ToLowerInvariant() switch { "system" => ChatRole.System, "user" => ChatRole.User, "assistant" => ChatRole.Assistant, "tool" => ChatRole.Tool, _ => ChatRole.User }; }