From 45d3ee1ed6f20c8200c7ba95e0ff30b8fb138553 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Sat, 4 Apr 2026 04:10:39 +0200 Subject: [PATCH] Add provider abstraction layer with Mistral and Ollama implementations Define IProvider interface and implement concrete providers for Mistral (with custom DTOs for chat completion API) and Ollama (via OllamaSharp). Includes DI registration extensions using Microsoft.Extensions.AI. --- Luna.Providers.Abstractions/IProvider.cs | 19 ++ .../Luna.Providers.Abstractions.csproj | 14 ++ .../Extensions/ServiceCollectionExtensions.cs | 67 +++++++ .../Extensions/ServiceProviderExtensions.cs | 28 +++ Luna.Providers/Luna.Providers.csproj | 19 ++ .../DTOs/MistralChatCompletionChunk.cs | 18 ++ .../DTOs/MistralChatCompletionRequest.cs | 31 +++ .../DTOs/MistralChatCompletionResponse.cs | 21 ++ Luna.Providers/Mistral/DTOs/MistralChoice.cs | 15 ++ .../Mistral/DTOs/MistralChoiceChunk.cs | 15 ++ Luna.Providers/Mistral/DTOs/MistralDelta.cs | 12 ++ .../Mistral/DTOs/MistralJsonContext.cs | 10 + Luna.Providers/Mistral/DTOs/MistralMessage.cs | 12 ++ Luna.Providers/Mistral/DTOs/ResponseFormat.cs | 9 + Luna.Providers/Mistral/DTOs/Usage.cs | 15 ++ Luna.Providers/Mistral/MistralChatClient.cs | 180 ++++++++++++++++++ Luna.Providers/Mistral/MistralProvider.cs | 17 ++ Luna.Providers/Ollama/OllamaProvider.cs | 13 ++ 18 files changed, 515 insertions(+) create mode 100644 Luna.Providers.Abstractions/IProvider.cs create mode 100644 Luna.Providers.Abstractions/Luna.Providers.Abstractions.csproj create mode 100644 Luna.Providers/Extensions/ServiceCollectionExtensions.cs create mode 100644 Luna.Providers/Extensions/ServiceProviderExtensions.cs create mode 100644 Luna.Providers/Luna.Providers.csproj create mode 100644 Luna.Providers/Mistral/DTOs/MistralChatCompletionChunk.cs create mode 100644 Luna.Providers/Mistral/DTOs/MistralChatCompletionRequest.cs create mode 100644 Luna.Providers/Mistral/DTOs/MistralChatCompletionResponse.cs create mode 100644 Luna.Providers/Mistral/DTOs/MistralChoice.cs create mode 100644 Luna.Providers/Mistral/DTOs/MistralChoiceChunk.cs create mode 100644 Luna.Providers/Mistral/DTOs/MistralDelta.cs create mode 100644 Luna.Providers/Mistral/DTOs/MistralJsonContext.cs create mode 100644 Luna.Providers/Mistral/DTOs/MistralMessage.cs create mode 100644 Luna.Providers/Mistral/DTOs/ResponseFormat.cs create mode 100644 Luna.Providers/Mistral/DTOs/Usage.cs create mode 100644 Luna.Providers/Mistral/MistralChatClient.cs create mode 100644 Luna.Providers/Mistral/MistralProvider.cs create mode 100644 Luna.Providers/Ollama/OllamaProvider.cs diff --git a/Luna.Providers.Abstractions/IProvider.cs b/Luna.Providers.Abstractions/IProvider.cs new file mode 100644 index 0000000..17fd42d --- /dev/null +++ b/Luna.Providers.Abstractions/IProvider.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.AI; + +namespace Luna.Providers.Abstractions; + +public interface IProvider +{ + public static virtual string Name { get; } = null!; + + public sealed IChatClient GetChatClient(string modelId) + { + var client = CreateChatClient(modelId); + + return new ChatClientBuilder(client) + .UseFunctionInvocation() + .Build(); + } + + protected IChatClient CreateChatClient(string model); +} \ No newline at end of file diff --git a/Luna.Providers.Abstractions/Luna.Providers.Abstractions.csproj b/Luna.Providers.Abstractions/Luna.Providers.Abstractions.csproj new file mode 100644 index 0000000..f6b0ace --- /dev/null +++ b/Luna.Providers.Abstractions/Luna.Providers.Abstractions.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + + + + + + + + diff --git a/Luna.Providers/Extensions/ServiceCollectionExtensions.cs b/Luna.Providers/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..f768310 --- /dev/null +++ b/Luna.Providers/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,67 @@ + +using Luna.Configuration.Extensions; +using Luna.Providers.Abstractions; +using Luna.Providers.Mistral; +using Luna.Providers.Ollama; +using Microsoft.Extensions.DependencyInjection; + +namespace Luna.Providers.Extensions; + +public static class ServiceCollectionExtensions +{ + extension(IServiceCollection services) + { + public IServiceCollection AddProviders() + { + return services + .AddMistralProvider() + .AddOllamaProvider(); + } + + /// + /// Registers the and maps to it as a singleton. + /// + /// The service collection. + /// The updated service collection. + private IServiceCollection AddMistralProvider() + { + ArgumentNullException.ThrowIfNull(services); + + services.AddProviderOptions(MistralProvider.Name); + services.AddHttpClient( + MistralProvider.Name, + (provider, httpClient) => + { + var options = provider.GetProviderOptions(MistralProvider.Name); + httpClient.BaseAddress = new Uri(options.ApiUrl); + httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {options.ApiKey}"); + }); + + services.AddKeyedSingleton(MistralProvider.Name); + return services; + } + + /// + /// Registers the and maps to it as a singleton. + /// + /// + private IServiceCollection AddOllamaProvider() + { + ArgumentNullException.ThrowIfNull(services); + + services.AddProviderOptions(OllamaProvider.Name); + services.AddHttpClient( + OllamaProvider.Name, + (provider, httpClient) => + { + var options = provider.GetProviderOptions(OllamaProvider.Name); + + httpClient.BaseAddress = new Uri(options.ApiUrl); + httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {options.ApiKey}"); + }); + + services.AddKeyedSingleton(OllamaProvider.Name); + return services; + } + } +} \ No newline at end of file diff --git a/Luna.Providers/Extensions/ServiceProviderExtensions.cs b/Luna.Providers/Extensions/ServiceProviderExtensions.cs new file mode 100644 index 0000000..407dc74 --- /dev/null +++ b/Luna.Providers/Extensions/ServiceProviderExtensions.cs @@ -0,0 +1,28 @@ +using Luna.Configuration; +using Luna.Providers.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Luna.Providers.Extensions; + +public static class ServiceProviderExtensions +{ + extension(IServiceProvider provider) + { + public TProvider GetProvider(string? name = null) + where TProvider : IProvider + { + name ??= TProvider.Name; + return provider.GetRequiredKeyedService(name); + } + + public ProviderOptions GetProviderOptions(string? name = null) + where TProvider : IProvider + { + name ??= TProvider.Name; + return provider + .GetRequiredService>() + .Get(name); + } + } +} \ No newline at end of file diff --git a/Luna.Providers/Luna.Providers.csproj b/Luna.Providers/Luna.Providers.csproj new file mode 100644 index 0000000..27375df --- /dev/null +++ b/Luna.Providers/Luna.Providers.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + diff --git a/Luna.Providers/Mistral/DTOs/MistralChatCompletionChunk.cs b/Luna.Providers/Mistral/DTOs/MistralChatCompletionChunk.cs new file mode 100644 index 0000000..65eed59 --- /dev/null +++ b/Luna.Providers/Mistral/DTOs/MistralChatCompletionChunk.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Luna.Providers.Mistral.DTOs; + +public class MistralChatCompletionChunk +{ + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("model")] + public string? Model { get; set; } + + [JsonPropertyName("choices")] + public List? Choices { get; set; } +} \ No newline at end of file diff --git a/Luna.Providers/Mistral/DTOs/MistralChatCompletionRequest.cs b/Luna.Providers/Mistral/DTOs/MistralChatCompletionRequest.cs new file mode 100644 index 0000000..5956ae0 --- /dev/null +++ b/Luna.Providers/Mistral/DTOs/MistralChatCompletionRequest.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace Luna.Providers.Mistral.DTOs; + +public class MistralChatCompletionRequest +{ + [JsonPropertyName("model")] + public required string Model { get; set; } + + [JsonPropertyName("messages")] + public required List Messages { get; set; } + + [JsonPropertyName("temperature")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public float? Temperature { get; set; } + + [JsonPropertyName("max_tokens")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? MaxTokens { get; set; } + + [JsonPropertyName("top_p")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public float? TopP { get; set; } + + [JsonPropertyName("stream")] + public bool Stream { get; set; } + + [JsonPropertyName("response_format")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ResponseFormat? ResponseFormat { get; set; } +} \ No newline at end of file diff --git a/Luna.Providers/Mistral/DTOs/MistralChatCompletionResponse.cs b/Luna.Providers/Mistral/DTOs/MistralChatCompletionResponse.cs new file mode 100644 index 0000000..a8d2d4d --- /dev/null +++ b/Luna.Providers/Mistral/DTOs/MistralChatCompletionResponse.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Luna.Providers.Mistral.DTOs; + +public class MistralChatCompletionResponse +{ + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("model")] + public string? Model { get; set; } + + [JsonPropertyName("choices")] + public List? Choices { get; set; } + + [JsonPropertyName("usage")] + public Usage? Usage { get; set; } +} \ No newline at end of file diff --git a/Luna.Providers/Mistral/DTOs/MistralChoice.cs b/Luna.Providers/Mistral/DTOs/MistralChoice.cs new file mode 100644 index 0000000..8c755a0 --- /dev/null +++ b/Luna.Providers/Mistral/DTOs/MistralChoice.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Luna.Providers.Mistral.DTOs; + +public class MistralChoice +{ + [JsonPropertyName("index")] + public int Index { get; set; } + + [JsonPropertyName("message")] + public MistralMessage? Message { get; set; } + + [JsonPropertyName("finish_reason")] + public string? FinishReason { get; set; } +} \ No newline at end of file diff --git a/Luna.Providers/Mistral/DTOs/MistralChoiceChunk.cs b/Luna.Providers/Mistral/DTOs/MistralChoiceChunk.cs new file mode 100644 index 0000000..1532588 --- /dev/null +++ b/Luna.Providers/Mistral/DTOs/MistralChoiceChunk.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Luna.Providers.Mistral.DTOs; + +public class MistralChoiceChunk +{ + [JsonPropertyName("index")] + public int Index { get; set; } + + [JsonPropertyName("delta")] + public MistralDelta? Delta { get; set; } + + [JsonPropertyName("finish_reason")] + public string? FinishReason { get; set; } +} \ No newline at end of file diff --git a/Luna.Providers/Mistral/DTOs/MistralDelta.cs b/Luna.Providers/Mistral/DTOs/MistralDelta.cs new file mode 100644 index 0000000..8838ea3 --- /dev/null +++ b/Luna.Providers/Mistral/DTOs/MistralDelta.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Luna.Providers.Mistral.DTOs; + +public class MistralDelta +{ + [JsonPropertyName("role")] + public string? Role { get; set; } + + [JsonPropertyName("content")] + public string? Content { get; set; } +} \ No newline at end of file diff --git a/Luna.Providers/Mistral/DTOs/MistralJsonContext.cs b/Luna.Providers/Mistral/DTOs/MistralJsonContext.cs new file mode 100644 index 0000000..8469d5c --- /dev/null +++ b/Luna.Providers/Mistral/DTOs/MistralJsonContext.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace Luna.Providers.Mistral.DTOs; + +[JsonSerializable(typeof(MistralChatCompletionRequest))] +[JsonSerializable(typeof(MistralChatCompletionResponse))] +[JsonSerializable(typeof(MistralChatCompletionChunk))] +public partial class MistralJsonContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/Luna.Providers/Mistral/DTOs/MistralMessage.cs b/Luna.Providers/Mistral/DTOs/MistralMessage.cs new file mode 100644 index 0000000..5f4007e --- /dev/null +++ b/Luna.Providers/Mistral/DTOs/MistralMessage.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Luna.Providers.Mistral.DTOs; + +public class MistralMessage +{ + [JsonPropertyName("role")] + public required string Role { get; set; } + + [JsonPropertyName("content")] + public required string Content { get; set; } +} \ No newline at end of file diff --git a/Luna.Providers/Mistral/DTOs/ResponseFormat.cs b/Luna.Providers/Mistral/DTOs/ResponseFormat.cs new file mode 100644 index 0000000..03ae9f5 --- /dev/null +++ b/Luna.Providers/Mistral/DTOs/ResponseFormat.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Luna.Providers.Mistral.DTOs; + +public class ResponseFormat +{ + [JsonPropertyName("type")] + public required string Type { get; set; } +} \ No newline at end of file diff --git a/Luna.Providers/Mistral/DTOs/Usage.cs b/Luna.Providers/Mistral/DTOs/Usage.cs new file mode 100644 index 0000000..99bde6c --- /dev/null +++ b/Luna.Providers/Mistral/DTOs/Usage.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Luna.Providers.Mistral.DTOs; + +public class Usage +{ + [JsonPropertyName("prompt_tokens")] + public int PromptTokens { get; set; } + + [JsonPropertyName("completion_tokens")] + public int CompletionTokens { get; set; } + + [JsonPropertyName("total_tokens")] + public int TotalTokens { get; set; } +} \ No newline at end of file diff --git a/Luna.Providers/Mistral/MistralChatClient.cs b/Luna.Providers/Mistral/MistralChatClient.cs new file mode 100644 index 0000000..f69d576 --- /dev/null +++ b/Luna.Providers/Mistral/MistralChatClient.cs @@ -0,0 +1,180 @@ +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 + }; +} \ No newline at end of file diff --git a/Luna.Providers/Mistral/MistralProvider.cs b/Luna.Providers/Mistral/MistralProvider.cs new file mode 100644 index 0000000..ebf40ef --- /dev/null +++ b/Luna.Providers/Mistral/MistralProvider.cs @@ -0,0 +1,17 @@ +using Luna.Providers.Abstractions; +using Microsoft.Extensions.AI; + +namespace Luna.Providers.Mistral; + +/// +/// Mistral API provider implementation for the Luna AI Assistant. +/// Uses Mistral's OpenAI-compatible chat completion API endpoint. +/// Reference: https://docs.mistral.ai/api/ +/// +public sealed class MistralProvider(IHttpClientFactory httpClientFactory) : IProvider +{ + public static string Name => "Mistral"; + + public IChatClient CreateChatClient(string modelId) + => new MistralChatClient(httpClientFactory.CreateClient(Name), modelId); +} \ No newline at end of file diff --git a/Luna.Providers/Ollama/OllamaProvider.cs b/Luna.Providers/Ollama/OllamaProvider.cs new file mode 100644 index 0000000..a6722a4 --- /dev/null +++ b/Luna.Providers/Ollama/OllamaProvider.cs @@ -0,0 +1,13 @@ +using Luna.Providers.Abstractions; +using Microsoft.Extensions.AI; +using OllamaSharp; + +namespace Luna.Providers.Ollama; + +public sealed class OllamaProvider(IHttpClientFactory httpClientFactory) : IProvider +{ + public static string Name => "Ollama"; + + public IChatClient CreateChatClient(string model) + => new OllamaApiClient(httpClientFactory.CreateClient(Name), model); +} \ No newline at end of file