using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using GerbilManagerWebAPI.SaleAd; using Microsoft.Extensions.Options; namespace GerbilManagerWebAPI.Ai { public enum AiCallStatus { Ok, /// AI section not (fully) configured -> callers map to 503 "AiKeyMissing". NotConfigured, /// Provider call failed -> callers map to 502 "AiUpstreamError". UpstreamError, } public sealed record AiCallResult(AiCallStatus Status, string? Text, string? Error = null); /// /// INBOX-2: provider-agnostic chat client, extracted from SaleAdService so the /// reply-draft (and future AI features) reuse the SAME wire implementation: /// plain JSON POST to {AI:BaseUrl}/chat/completions with a Bearer key — covers /// Gemini (compat endpoint), Groq, Mistral, local Ollama; no vendor SDK. /// Configuration stays the single AI section (AiOptions, env-only). /// public sealed class OpenAiChatClient(HttpClient http, IOptions options) { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; public async Task CompleteAsync( string systemPrompt, string userPrompt, CancellationToken ct = default) { var ai = options.Value; if (!ai.IsConfigured) { return new AiCallResult(AiCallStatus.NotConfigured, null, "KI-Anbieter ist nicht konfiguriert (AI__BaseUrl / AI__ApiKey / AI__Model)."); } var payload = new ChatRequest( Model: ai.Model!, Messages: [ new ChatMessage("system", systemPrompt), new ChatMessage("user", userPrompt), ], Temperature: 0.7); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, BuildCompletionsUri(ai.BaseUrl!)) { Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json"), }; httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ai.ApiKey); try { using var response = await http.SendAsync(httpRequest, ct); var body = await response.Content.ReadAsStringAsync(ct); if (!response.IsSuccessStatusCode) { return new AiCallResult(AiCallStatus.UpstreamError, null, $"KI-Anbieter antwortete mit HTTP {(int)response.StatusCode}."); } var completion = JsonSerializer.Deserialize(body, JsonOptions); var text = completion?.Choices?.FirstOrDefault()?.Message?.Content?.Trim(); return string.IsNullOrWhiteSpace(text) ? new AiCallResult(AiCallStatus.UpstreamError, null, "KI-Antwort enthielt keinen Text.") : new AiCallResult(AiCallStatus.Ok, text); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException) { return new AiCallResult(AiCallStatus.UpstreamError, null, $"KI-Anbieter nicht erreichbar: {ex.Message}"); } } /// {BaseUrl}/chat/completions — tolerant of a trailing slash on BaseUrl. internal static Uri BuildCompletionsUri(string baseUrl) => new($"{baseUrl.TrimEnd('/')}/chat/completions"); // ── OpenAI-compatible wire records (request + the slice of the response we read) ── internal sealed record ChatRequest(string Model, List Messages, double? Temperature); internal sealed record ChatMessage(string Role, string Content); internal sealed record ChatResponse(List? Choices); internal sealed record ChatChoice(ChatMessage? Message); } }