100 lines
4.2 KiB
C#
100 lines
4.2 KiB
C#
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,
|
|
/// <summary>AI section not (fully) configured -> callers map to 503 "AiKeyMissing".</summary>
|
|
NotConfigured,
|
|
/// <summary>Provider call failed -> callers map to 502 "AiUpstreamError".</summary>
|
|
UpstreamError,
|
|
}
|
|
|
|
public sealed record AiCallResult(AiCallStatus Status, string? Text, string? Error = null);
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public sealed class OpenAiChatClient(HttpClient http, IOptions<AiOptions> options)
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
};
|
|
|
|
public async Task<AiCallResult> 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<ChatResponse>(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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>{BaseUrl}/chat/completions — tolerant of a trailing slash on BaseUrl.</summary>
|
|
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<ChatMessage> Messages, double? Temperature);
|
|
|
|
internal sealed record ChatMessage(string Role, string Content);
|
|
|
|
internal sealed record ChatResponse(List<ChatChoice>? Choices);
|
|
|
|
internal sealed record ChatChoice(ChatMessage? Message);
|
|
}
|
|
}
|