diff --git a/GerbilManagerWebAPI/Ai/OpenAiChatClient.cs b/GerbilManagerWebAPI/Ai/OpenAiChatClient.cs
new file mode 100644
index 0000000..4cda969
--- /dev/null
+++ b/GerbilManagerWebAPI/Ai/OpenAiChatClient.cs
@@ -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,
+ /// 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);
+ }
+}
diff --git a/GerbilManagerWebAPI/SaleAd/SaleAdService.cs b/GerbilManagerWebAPI/SaleAd/SaleAdService.cs
index 47a7c5c..109c2a8 100644
--- a/GerbilManagerWebAPI/SaleAd/SaleAdService.cs
+++ b/GerbilManagerWebAPI/SaleAd/SaleAdService.cs
@@ -1,87 +1,35 @@
-using System.Net.Http.Headers;
-using System.Text;
-using System.Text.Json;
-using System.Text.Json.Serialization;
+using GerbilManagerWebAPI.Ai;
using Microsoft.Extensions.Options;
namespace GerbilManagerWebAPI.SaleAd
{
///
- /// FEAT-12a: provider-agnostic AI client for sale-ad generation.
- ///
- /// Speaks the OpenAI-compatible chat-completions wire shape — deliberately
- /// WITHOUT any vendor SDK: a plain JSON POST to {AI:BaseUrl}/chat/completions
- /// with a Bearer key covers Google Gemini (compat endpoint), Groq, Mistral,
- /// local Ollama and any future provider. The wire shape IS the abstraction.
+ /// FEAT-12a: sale-ad generation. The provider-agnostic wire handling lives in
+ /// (extracted in INBOX-2 so the reply-draft and
+ /// future AI features reuse the SAME implementation); this service contributes
+ /// the sale-ad prompts and the SaleAd-shaped result. Public surface unchanged.
///
public sealed class SaleAdService(HttpClient http, IOptions options)
{
- private static readonly JsonSerializerOptions JsonOptions = new()
- {
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
- };
+ private readonly OpenAiChatClient _client = new(http, options);
public async Task GenerateAsync(SaleAdRequest request, CancellationToken ct = default)
{
- var ai = options.Value;
- if (!ai.IsConfigured)
+ var result = await _client.CompleteAsync(
+ SaleAdPromptBuilder.BuildSystemPrompt(),
+ SaleAdPromptBuilder.BuildUserPrompt(request),
+ ct);
+ var status = result.Status switch
{
- return new SaleAdResult(SaleAdStatus.NotConfigured, null,
- "KI-Anbieter ist nicht konfiguriert (AI__BaseUrl / AI__ApiKey / AI__Model).");
- }
-
- 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"),
+ AiCallStatus.Ok => SaleAdStatus.Ok,
+ AiCallStatus.NotConfigured => SaleAdStatus.NotConfigured,
+ _ => SaleAdStatus.UpstreamError,
};
- 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 SaleAdResult(SaleAdStatus.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 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}");
- }
+ return new SaleAdResult(status, result.Text, result.Error);
}
- /// {BaseUrl}/chat/completions — tolerant of a trailing slash on BaseUrl.
+ /// Forwarder kept for the existing config-matrix tests.
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);
+ OpenAiChatClient.BuildCompletionsUri(baseUrl);
}
}