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