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.
This commit is contained in:
2026-04-04 04:10:39 +02:00
parent a4ed65e452
commit 1c42c8c006
18 changed files with 515 additions and 0 deletions
@@ -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<MistralChoiceChunk>? Choices { get; set; }
}
@@ -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<MistralMessage> 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; }
}
@@ -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<MistralChoice>? Choices { get; set; }
[JsonPropertyName("usage")]
public Usage? Usage { get; set; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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
{
}
@@ -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; }
}
@@ -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; }
}
+15
View File
@@ -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; }
}
+180
View File
@@ -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;
/// <summary>
/// Custom IChatClient implementation for the Mistral API.
/// Implements chat completion according to the Mistral OpenAPI spec.
/// </summary>
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<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
var request = CreateChatCompletionRequest(messages, options);
var response = await SendChatCompletionRequest(request, cancellationToken);
return MapToChatResponse(response);
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> 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;
}
/// <summary>
/// Creates a chat completion request according to the Mistral API spec.
/// Reference: /v1/chat/completions endpoint in the OpenAPI spec.
/// </summary>
private MistralChatCompletionRequest CreateChatCompletionRequest(IEnumerable<ChatMessage> messages, ChatOptions? options, bool streaming = false)
{
var messagesWithInstructions = new List<ChatMessage>();
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<MistralChatCompletionResponse> 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<MistralChatCompletionChunk> 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
};
}
+17
View File
@@ -0,0 +1,17 @@
using Luna.Providers.Abstractions;
using Microsoft.Extensions.AI;
namespace Luna.Providers.Mistral;
/// <summary>
/// Mistral API provider implementation for the Luna AI Assistant.
/// Uses Mistral's OpenAI-compatible chat completion API endpoint.
/// Reference: https://docs.mistral.ai/api/
/// </summary>
public sealed class MistralProvider(IHttpClientFactory httpClientFactory) : IProvider
{
public static string Name => "Mistral";
public IChatClient CreateChatClient(string modelId)
=> new MistralChatClient(httpClientFactory.CreateClient(Name), modelId);
}