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