INBOX-2: extract provider-agnostic OpenAiChatClient from SaleAdService (shared AI wire, public surface unchanged)
This commit is contained in:
99
GerbilManagerWebAPI/Ai/OpenAiChatClient.cs
Normal file
99
GerbilManagerWebAPI/Ai/OpenAiChatClient.cs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,87 +1,35 @@
|
|||||||
using System.Net.Http.Headers;
|
using GerbilManagerWebAPI.Ai;
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.SaleAd
|
namespace GerbilManagerWebAPI.SaleAd
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// FEAT-12a: provider-agnostic AI client for sale-ad generation.
|
/// FEAT-12a: sale-ad generation. The provider-agnostic wire handling lives in
|
||||||
///
|
/// <see cref="OpenAiChatClient"/> (extracted in INBOX-2 so the reply-draft and
|
||||||
/// Speaks the OpenAI-compatible chat-completions wire shape — deliberately
|
/// future AI features reuse the SAME implementation); this service contributes
|
||||||
/// WITHOUT any vendor SDK: a plain JSON POST to {AI:BaseUrl}/chat/completions
|
/// the sale-ad prompts and the SaleAd-shaped result. Public surface unchanged.
|
||||||
/// with a Bearer key covers Google Gemini (compat endpoint), Groq, Mistral,
|
|
||||||
/// local Ollama and any future provider. The wire shape IS the abstraction.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SaleAdService(HttpClient http, IOptions<AiOptions> options)
|
public sealed class SaleAdService(HttpClient http, IOptions<AiOptions> options)
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private readonly OpenAiChatClient _client = new(http, options);
|
||||||
{
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
||||||
};
|
|
||||||
|
|
||||||
public async Task<SaleAdResult> GenerateAsync(SaleAdRequest request, CancellationToken ct = default)
|
public async Task<SaleAdResult> GenerateAsync(SaleAdRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var ai = options.Value;
|
var result = await _client.CompleteAsync(
|
||||||
if (!ai.IsConfigured)
|
SaleAdPromptBuilder.BuildSystemPrompt(),
|
||||||
|
SaleAdPromptBuilder.BuildUserPrompt(request),
|
||||||
|
ct);
|
||||||
|
var status = result.Status switch
|
||||||
{
|
{
|
||||||
return new SaleAdResult(SaleAdStatus.NotConfigured, null,
|
AiCallStatus.Ok => SaleAdStatus.Ok,
|
||||||
"KI-Anbieter ist nicht konfiguriert (AI__BaseUrl / AI__ApiKey / AI__Model).");
|
AiCallStatus.NotConfigured => SaleAdStatus.NotConfigured,
|
||||||
}
|
_ => SaleAdStatus.UpstreamError,
|
||||||
|
|
||||||
var payload = new ChatRequest(
|
|
||||||
Model: ai.Model!,
|
|
||||||
Messages:
|
|
||||||
[
|
|
||||||
new ChatMessage("system", SaleAdPromptBuilder.BuildSystemPrompt()),
|
|
||||||
new ChatMessage("user", SaleAdPromptBuilder.BuildUserPrompt(request)),
|
|
||||||
],
|
|
||||||
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);
|
return new SaleAdResult(status, result.Text, result.Error);
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var response = await http.SendAsync(httpRequest, ct);
|
|
||||||
var body = await response.Content.ReadAsStringAsync(ct);
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
return new SaleAdResult(SaleAdStatus.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 SaleAdResult(SaleAdStatus.UpstreamError, null,
|
|
||||||
"KI-Antwort enthielt keinen Text.")
|
|
||||||
: new SaleAdResult(SaleAdStatus.Ok, text);
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
|
|
||||||
{
|
|
||||||
return new SaleAdResult(SaleAdStatus.UpstreamError, null,
|
|
||||||
$"KI-Anbieter nicht erreichbar: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>{BaseUrl}/chat/completions — tolerant of a trailing slash on BaseUrl.</summary>
|
/// <summary>Forwarder kept for the existing config-matrix tests.</summary>
|
||||||
internal static Uri BuildCompletionsUri(string baseUrl) =>
|
internal static Uri BuildCompletionsUri(string baseUrl) =>
|
||||||
new($"{baseUrl.TrimEnd('/')}/chat/completions");
|
OpenAiChatClient.BuildCompletionsUri(baseUrl);
|
||||||
|
|
||||||
// ── 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user