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 63818df4b5
commit 45d3ee1ed6
18 changed files with 515 additions and 0 deletions
+19
View File
@@ -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);
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI" Version="10.4.1" />
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" Version="10.4.1" />
</ItemGroup>
</Project>
@@ -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();
}
/// <summary>
/// Registers the <see cref="MistralProvider"/> and maps <see cref="Provider"/> to it as a singleton.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The updated service collection.</returns>
private IServiceCollection AddMistralProvider()
{
ArgumentNullException.ThrowIfNull(services);
services.AddProviderOptions(MistralProvider.Name);
services.AddHttpClient(
MistralProvider.Name,
(provider, httpClient) =>
{
var options = provider.GetProviderOptions<MistralProvider>(MistralProvider.Name);
httpClient.BaseAddress = new Uri(options.ApiUrl);
httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {options.ApiKey}");
});
services.AddKeyedSingleton<IProvider, MistralProvider>(MistralProvider.Name);
return services;
}
/// <summary>
/// Registers the <see cref="OllamaProvider"/> and maps <see cref="Provider"/> to it as a singleton.
/// </summary>
/// <returns></returns>
private IServiceCollection AddOllamaProvider()
{
ArgumentNullException.ThrowIfNull(services);
services.AddProviderOptions(OllamaProvider.Name);
services.AddHttpClient(
OllamaProvider.Name,
(provider, httpClient) =>
{
var options = provider.GetProviderOptions<OllamaProvider>(OllamaProvider.Name);
httpClient.BaseAddress = new Uri(options.ApiUrl);
httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {options.ApiKey}");
});
services.AddKeyedSingleton<IProvider, OllamaProvider>(OllamaProvider.Name);
return services;
}
}
}
@@ -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<TProvider>(string? name = null)
where TProvider : IProvider
{
name ??= TProvider.Name;
return provider.GetRequiredKeyedService<TProvider>(name);
}
public ProviderOptions GetProviderOptions<TProvider>(string? name = null)
where TProvider : IProvider
{
name ??= TProvider.Name;
return provider
.GetRequiredService<IOptionsMonitor<ProviderOptions>>()
.Get(name);
}
}
}
+19
View File
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Luna.Configuration\Luna.Configuration.csproj" />
<ProjectReference Include="..\Luna.Providers.Abstractions\Luna.Providers.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.5" />
<PackageReference Include="OllamaSharp" Version="5.4.25" />
</ItemGroup>
</Project>
@@ -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);
}
+13
View File
@@ -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);
}