diff --git a/GerbilManager.Tests/SaleAdTests.cs b/GerbilManager.Tests/SaleAdTests.cs
new file mode 100644
index 0000000..0032df1
--- /dev/null
+++ b/GerbilManager.Tests/SaleAdTests.cs
@@ -0,0 +1,225 @@
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using GerbilManagerWebAPI.SaleAd;
+using Microsoft.Extensions.Options;
+
+namespace GerbilManager.Tests
+{
+ ///
+ /// FEAT-12a: sale-ad backend — prompt assembly, the 503-unconfigured path,
+ /// the OpenAI-compatible wire handling against a stubbed provider, and the
+ /// provider config matrix (Gemini / Groq / Mistral / Ollama base URLs).
+ ///
+ public class SaleAdTests
+ {
+ private static SaleAdRequest SampleRequest() => new(
+ Animals:
+ [
+ new SaleAdAnimal("Balu", "CP-Agouti", "2024-03-12", "ruhig, nimmt Leckerlis aus der Hand"),
+ new SaleAdAnimal("Benny", "Schwarz Schecke", "2024-03-12", "neugieriger Entdecker"),
+ ],
+ StatusLine: "FREI",
+ Hints: "bitte die Bruder-Dynamik betonen");
+
+ // ── Prompt assembly ────────────────────────────────────────────────
+
+ [Fact]
+ public void UserPrompt_enthält_Tiere_Status_und_Hinweise()
+ {
+ var prompt = SaleAdPromptBuilder.BuildUserPrompt(SampleRequest());
+
+ Assert.Contains("Status-Zeile: FREI", prompt);
+ Assert.Contains("Name: Balu", prompt);
+ Assert.Contains("Farbschlag: CP-Agouti", prompt);
+ Assert.Contains("Name: Benny", prompt);
+ Assert.Contains("ruhig, nimmt Leckerlis aus der Hand", prompt);
+ Assert.Contains("Wünsche/Hinweise: bitte die Bruder-Dynamik betonen", prompt);
+ }
+
+ [Fact]
+ public void UserPrompt_formatiert_Geburtsdatum_deutsch()
+ {
+ var prompt = SaleAdPromptBuilder.BuildUserPrompt(SampleRequest());
+ Assert.Contains("geboren am 12.03.2024", prompt);
+ Assert.DoesNotContain("2024-03-12", prompt);
+ }
+
+ [Fact]
+ public void UserPrompt_lässt_fehlende_Angaben_weg()
+ {
+ var request = new SaleAdRequest(
+ [new SaleAdAnimal("Mysti", null, null, null)], "RESERVIERT", "");
+ var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
+
+ Assert.Contains("Name: Mysti", prompt);
+ Assert.DoesNotContain("Farbschlag:", prompt);
+ Assert.DoesNotContain("geboren am", prompt);
+ Assert.DoesNotContain("Notizen:", prompt);
+ Assert.DoesNotContain("Wünsche/Hinweise:", prompt);
+ }
+
+ [Fact]
+ public void SystemPrompt_enthält_Stilregeln_und_beide_Beispiele()
+ {
+ var prompt = SaleAdPromptBuilder.BuildSystemPrompt();
+
+ Assert.Contains("Zucht der kleinen Chaoten", prompt);
+ Assert.Contains("NIEMALS Preise", prompt);
+ Assert.Contains("KEINE Fakten erfinden", prompt);
+ // few-shot: die beiden Beispiel-Inserate (Status-Zeilen der Vorlage)
+ Assert.Contains("Status: FREI", prompt);
+ Assert.Contains("Status: LOCKER RESERVIERT Anna", prompt);
+ Assert.Contains("Großer und kleiner Bruder Dynamik", prompt);
+ }
+
+ [Theory]
+ [InlineData("2024-03-12", "12.03.2024")]
+ [InlineData("kaputt", "kaputt")] // unparsebar -> unverändert durchreichen
+ public void FormatGermanDate_konvertiert_ISO_nach_TT_MM_JJJJ(string input, string expected)
+ => Assert.Equal(expected, SaleAdPromptBuilder.FormatGermanDate(input));
+
+ // ── 503: unkonfiguriert ────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(null, "key", "model")]
+ [InlineData("https://api.example.com/v1", null, "model")]
+ [InlineData("https://api.example.com/v1", "key", null)]
+ [InlineData(null, null, null)]
+ public async Task GenerateAsync_meldet_NotConfigured_wenn_eine_Einstellung_fehlt(
+ string? baseUrl, string? apiKey, string? model)
+ {
+ var service = CreateService(baseUrl, apiKey, model,
+ new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden")));
+
+ var result = await service.GenerateAsync(SampleRequest());
+
+ Assert.Equal(SaleAdStatus.NotConfigured, result.Status);
+ Assert.Null(result.Text);
+ }
+
+ // ── Fake-Server: OpenAI-kompatible Antwort wird geparst ────────────
+
+ [Fact]
+ public async Task GenerateAsync_parst_die_Chat_Completion_des_Stubs()
+ {
+ var handler = new StubHandler(_ => Canned("Status: FREI\n\nWunderbares Duo …"));
+ var service = CreateService("https://api.groq.com/openai/v1", "k", "llama-3.3-70b", handler);
+
+ var result = await service.GenerateAsync(SampleRequest());
+
+ Assert.Equal(SaleAdStatus.Ok, result.Status);
+ Assert.Equal("Status: FREI\n\nWunderbares Duo …", result.Text);
+ }
+
+ [Fact]
+ public async Task GenerateAsync_sendet_Modell_Messages_und_Bearer_Key()
+ {
+ var handler = new StubHandler(_ => Canned("ok"));
+ var service = CreateService("https://api.example.com/v1", "geheim", "test-model", handler);
+
+ await service.GenerateAsync(SampleRequest());
+
+ Assert.NotNull(handler.LastRequest);
+ Assert.Equal("Bearer", handler.LastRequest!.Headers.Authorization?.Scheme);
+ Assert.Equal("geheim", handler.LastRequest.Headers.Authorization?.Parameter);
+
+ using var body = JsonDocument.Parse(handler.LastRequestBody!);
+ Assert.Equal("test-model", body.RootElement.GetProperty("model").GetString());
+ var messages = body.RootElement.GetProperty("messages");
+ Assert.Equal(2, messages.GetArrayLength());
+ Assert.Equal("system", messages[0].GetProperty("role").GetString());
+ Assert.Equal("user", messages[1].GetProperty("role").GetString());
+ }
+
+ [Fact]
+ public async Task GenerateAsync_meldet_UpstreamError_bei_Provider_HTTP_Fehler()
+ {
+ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests)
+ {
+ Content = new StringContent("rate limited"),
+ });
+ var service = CreateService("https://api.example.com/v1", "k", "m", handler);
+
+ var result = await service.GenerateAsync(SampleRequest());
+
+ Assert.Equal(SaleAdStatus.UpstreamError, result.Status);
+ }
+
+ [Fact]
+ public async Task GenerateAsync_meldet_UpstreamError_bei_leerer_Antwort()
+ {
+ var handler = new StubHandler(_ => Canned(""));
+ var service = CreateService("https://api.example.com/v1", "k", "m", handler);
+
+ var result = await service.GenerateAsync(SampleRequest());
+
+ Assert.Equal(SaleAdStatus.UpstreamError, result.Status);
+ }
+
+ // ── Provider-Konfigurationsmatrix: BaseUrl -> /chat/completions ────
+
+ [Theory]
+ [InlineData("https://generativelanguage.googleapis.com/v1beta/openai",
+ "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions")] // Gemini
+ [InlineData("https://api.groq.com/openai/v1",
+ "https://api.groq.com/openai/v1/chat/completions")] // Groq
+ [InlineData("https://api.mistral.ai/v1",
+ "https://api.mistral.ai/v1/chat/completions")] // Mistral
+ [InlineData("http://truenas:11434/v1",
+ "http://truenas:11434/v1/chat/completions")] // Ollama (lokal)
+ [InlineData("https://api.groq.com/openai/v1/",
+ "https://api.groq.com/openai/v1/chat/completions")] // trailing slash
+ public async Task Konfigurationsmatrix_baut_die_richtige_Completions_URL(
+ string baseUrl, string expectedUri)
+ {
+ Assert.Equal(new Uri(expectedUri), SaleAdService.BuildCompletionsUri(baseUrl));
+
+ // und der Stub sieht den Aufruf tatsächlich auf dieser URL
+ var handler = new StubHandler(_ => Canned("ok"));
+ var service = CreateService(baseUrl, "k", "m", handler);
+ await service.GenerateAsync(SampleRequest());
+ Assert.Equal(new Uri(expectedUri), handler.LastRequest!.RequestUri);
+ }
+
+ // ── Helfer ─────────────────────────────────────────────────────────
+
+ private static SaleAdService CreateService(
+ string? baseUrl, string? apiKey, string? model, StubHandler handler)
+ {
+ var options = Options.Create(new AiOptions { BaseUrl = baseUrl, ApiKey = apiKey, Model = model });
+ return new SaleAdService(new HttpClient(handler), options);
+ }
+
+ private static HttpResponseMessage Canned(string content)
+ {
+ var completion = new
+ {
+ choices = new[] { new { message = new { role = "assistant", content } } },
+ };
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(JsonSerializer.Serialize(completion),
+ Encoding.UTF8, "application/json"),
+ };
+ }
+
+ /// HttpMessageHandler-Stub, der Request + Body zur Inspektion festhält.
+ private sealed class StubHandler(Func respond)
+ : HttpMessageHandler
+ {
+ public HttpRequestMessage? LastRequest { get; private set; }
+ public string? LastRequestBody { get; private set; }
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ LastRequest = request;
+ LastRequestBody = request.Content is null
+ ? null
+ : await request.Content.ReadAsStringAsync(cancellationToken);
+ return respond(request);
+ }
+ }
+ }
+}
diff --git a/GerbilManagerWebAPI/Endpoints/SaleAdEndpoints.cs b/GerbilManagerWebAPI/Endpoints/SaleAdEndpoints.cs
new file mode 100644
index 0000000..5f73ebc
--- /dev/null
+++ b/GerbilManagerWebAPI/Endpoints/SaleAdEndpoints.cs
@@ -0,0 +1,34 @@
+using GerbilManagerWebAPI.SaleAd;
+
+namespace GerbilManagerWebAPI.Endpoints
+{
+ ///
+ /// FEAT-12a: POST /gerbils/sale-ad — AI-gestütztes Abgabe-Inserat (frozen
+ /// contract, see gerbil-manager-web/src/api/saleAd.ts).
+ ///
+ /// 503 {code:"AiKeyMissing"} while the AI section is unconfigured — the
+ /// frontend maps exactly this code to its German disabled-state hint.
+ ///
+ public static class SaleAdEndpoints
+ {
+ public static IEndpointRouteBuilder MapSaleAdEndpoints(this IEndpointRouteBuilder app)
+ {
+ app.MapPost("/gerbils/sale-ad",
+ async (SaleAdRequest request, SaleAdService service, CancellationToken ct) =>
+ {
+ var result = await service.GenerateAsync(request, ct);
+ return result.Status switch
+ {
+ SaleAdStatus.Ok => Results.Ok(new SaleAdResponse(result.Text!)),
+ SaleAdStatus.NotConfigured => Results.Json(
+ new SaleAdError("AiKeyMissing", result.Error ?? ""), statusCode: 503),
+ _ => Results.Json(
+ new SaleAdError("AiUpstreamError", result.Error ?? ""), statusCode: 502),
+ };
+ })
+ .WithTags("SaleAd");
+
+ return app;
+ }
+ }
+}
diff --git a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj
index af6567e..cd572d9 100644
--- a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj
+++ b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj
@@ -30,4 +30,9 @@
+
+
+
+
+
diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs
index c4bfc3e..c960f6e 100644
--- a/GerbilManagerWebAPI/Program.cs
+++ b/GerbilManagerWebAPI/Program.cs
@@ -35,6 +35,13 @@ builder.Services.AddCors(options => options.AddPolicy(LanCorsPolicy, policy =>
// OpenAPI document for the Scalar API reference (Swashbuckle removed).
builder.Services.AddOpenApi();
+// FEAT-12a: provider-agnostic AI config (env vars AI__BaseUrl/AI__ApiKey/AI__Model
+// or user-secrets — never committed) + typed client for sale-ad generation.
+builder.Services.AddOptions()
+ .BindConfiguration(GerbilManagerWebAPI.SaleAd.AiOptions.SectionName);
+builder.Services.AddHttpClient(
+ http => http.Timeout = TimeSpan.FromSeconds(60));
+
var app = builder.Build();
app.MapDefaultEndpoints();
@@ -62,5 +69,6 @@ app.MapHealthRecordEndpoints();
app.MapWeightRecordEndpoints();
app.MapInbreedingEndpoints();
app.MapPhotoEndpoints();
+app.MapSaleAdEndpoints();
app.Run();
diff --git a/GerbilManagerWebAPI/SaleAd/AiOptions.cs b/GerbilManagerWebAPI/SaleAd/AiOptions.cs
new file mode 100644
index 0000000..2dd8734
--- /dev/null
+++ b/GerbilManagerWebAPI/SaleAd/AiOptions.cs
@@ -0,0 +1,30 @@
+namespace GerbilManagerWebAPI.SaleAd
+{
+ ///
+ /// FEAT-12a: provider-agnostic AI configuration (section "AI").
+ ///
+ /// Values come from ENVIRONMENT VARIABLES or user-secrets — NEVER from a
+ /// committed appsettings file (Julian's directive). Env-var names:
+ /// AI__BaseUrl, AI__ApiKey, AI__Model
+ ///
+ /// Any endpoint that speaks the OpenAI-compatible chat-completions shape works:
+ /// Google Gemini : https://generativelanguage.googleapis.com/v1beta/openai
+ /// Groq : https://api.groq.com/openai/v1
+ /// Mistral : https://api.mistral.ai/v1
+ /// Ollama (lokal): http://<host>:11434/v1 (ApiKey beliebig, z. B. "ollama")
+ ///
+ public sealed class AiOptions
+ {
+ public const string SectionName = "AI";
+
+ public string? BaseUrl { get; set; }
+ public string? ApiKey { get; set; }
+ public string? Model { get; set; }
+
+ /// All three settings present -> the sale-ad endpoint is live.
+ public bool IsConfigured =>
+ !string.IsNullOrWhiteSpace(BaseUrl)
+ && !string.IsNullOrWhiteSpace(ApiKey)
+ && !string.IsNullOrWhiteSpace(Model);
+ }
+}
diff --git a/GerbilManagerWebAPI/SaleAd/SaleAdModels.cs b/GerbilManagerWebAPI/SaleAd/SaleAdModels.cs
new file mode 100644
index 0000000..ed161a1
--- /dev/null
+++ b/GerbilManagerWebAPI/SaleAd/SaleAdModels.cs
@@ -0,0 +1,39 @@
+namespace GerbilManagerWebAPI.SaleAd
+{
+ ///
+ /// FEAT-12a: request/response contract of POST /gerbils/sale-ad — FROZEN,
+ /// mirror of the frontend (gerbil-manager-web/src/api/saleAd.ts).
+ ///
+ public sealed record SaleAdAnimal(
+ string Name,
+ string? Farbschlag,
+ /// ISO "YYYY-MM-DD" (frontend sends the DTO string verbatim).
+ string? DateOfBirth,
+ string? Notes);
+
+ public sealed record SaleAdRequest(
+ List Animals,
+ /// e.g. "FREI" / "LOCKER RESERVIERT Anna" / "RESERVIERT".
+ string StatusLine,
+ /// Free-text style hints/wishes from the user.
+ string Hints);
+
+ public sealed record SaleAdResponse(string Text);
+
+ ///
+ /// Error body for the non-200 paths. The code string "AiKeyMissing" is FROZEN —
+ /// Kevin's UI maps it to the German "API-Schlüssel noch nicht konfiguriert" hint.
+ ///
+ public sealed record SaleAdError(string Code, string Message);
+
+ public enum SaleAdStatus
+ {
+ Ok,
+ /// AI section not (fully) configured -> HTTP 503, code "AiKeyMissing".
+ NotConfigured,
+ /// Provider call failed -> HTTP 502, code "AiUpstreamError".
+ UpstreamError,
+ }
+
+ public sealed record SaleAdResult(SaleAdStatus Status, string? Text, string? Error = null);
+}
diff --git a/GerbilManagerWebAPI/SaleAd/SaleAdPromptBuilder.cs b/GerbilManagerWebAPI/SaleAd/SaleAdPromptBuilder.cs
new file mode 100644
index 0000000..a39a3af
--- /dev/null
+++ b/GerbilManagerWebAPI/SaleAd/SaleAdPromptBuilder.cs
@@ -0,0 +1,107 @@
+using System.Globalization;
+using System.Text;
+
+namespace GerbilManagerWebAPI.SaleAd
+{
+ ///
+ /// FEAT-12a: assembles the German prompt for the sale-ad (Abgabe-Inserat)
+ /// generation. Style and rules follow the breeder's real Jimdo listings
+ /// (analysed in hive/agents/god/FEAT12-jimdo-publish-research.md):
+ /// group-based listings, status line, bold emotive tagline, per-animal
+ /// Farbschlag + "geboren am DD.MM.YYYY" + personality prose, NO PRICES.
+ ///
+ public static class SaleAdPromptBuilder
+ {
+ ///
+ /// Two few-shot examples derived from the documented listing template.
+ /// NOTE: the research file documents the format and one real tagline;
+ /// these examples are synthesized to that template. Swap in verbatim
+ /// listings from kleine-chaoten.jimdofree.com when available.
+ ///
+ private const string ExampleListing1 = """
+ Status: FREI
+
+ „Großer und kleiner Bruder Dynamik“ – Männliches Rennmaus-Duo sucht ein liebevolles Zuhause
+
+ Balu – CP-Agouti, geboren am 12.03.2024
+ Balu ist der ruhige Pol des Duos: Er beobachtet erst in aller Seelenruhe und buddelt sich dann zielstrebig durch jedes Einstreu-Projekt. Aus der Hand nimmt er Leckerlis schon ganz vorsichtig.
+
+ Benny – Schwarz Schecke, geboren am 12.03.2024
+ Benny ist der Entdecker: kein Röhrchen bleibt unerforscht, kein Häuschen unbewohnt. Mit seinem Bruder kuschelt er sich abends ins Nest — getrennt werden die beiden deshalb nicht.
+
+ Die zwei werden nur gemeinsam in ein rennmausgerechtes Zuhause abgegeben.
+ """;
+
+ private const string ExampleListing2 = """
+ Status: LOCKER RESERVIERT Anna
+
+ „Zwei Schwestern, ein Herz und ganz viel Neugier“ – Weibliches Duo sucht seine Menschen
+
+ Frieda – Gold, geboren am 28.06.2024
+ Frieda ist die Mutige der beiden und steht beim Öffnen des Geheges sofort am Glas. Sie liebt Kolbenhirse und nimmt sie dir behutsam aus den Fingern.
+
+ Fine – Agouti, geboren am 28.06.2024
+ Fine ist etwas zurückhaltender, taut aber neben ihrer Schwester schnell auf. Beim abendlichen Buddeln sind die zwei ein unschlagbares Team.
+
+ Die Schwestern ziehen selbstverständlich nur zusammen um.
+ """;
+
+ /// System prompt: role, style description, hard rules, few-shot examples.
+ public static string BuildSystemPrompt() => $"""
+ Du schreibst Abgabe-Inserate für Mongolische Rennmäuse im Stil der „Zucht der kleinen Chaoten“.
+
+ Stil und Aufbau (verbindlich):
+ - Erste Zeile: die Status-Zeile, exakt wie vorgegeben (z. B. „Status: FREI“).
+ - Danach eine fette, emotionale Überschrift (Tagline) in Anführungszeichen mit kurzem Untertitel, die den Charakter der Gruppe einfängt.
+ - Danach pro Tier ein Absatz: Name – Farbschlag, geboren am TT.MM.JJJJ, gefolgt von warmherziger Persönlichkeits-Prosa auf Basis der mitgelieferten Notizen.
+ - Abschluss: ein Satz, dass die Tiere nur gemeinsam in ein artgerechtes Zuhause abgegeben werden.
+
+ Harte Regeln:
+ - NIEMALS Preise oder Schutzgebühren nennen.
+ - KEINE Fakten erfinden: Verwende ausschließlich die mitgelieferten Daten (Namen, Farbschläge, Geburtsdaten, Notizen). Fehlt eine Angabe, lässt du sie weg.
+ - Sprache: Deutsch, warm und liebevoll, aber nicht kitschig-übertrieben.
+ - Gib NUR den Inserat-Text aus — keine Erklärungen, keine Markdown-Code-Blöcke.
+
+ Beispiel 1:
+ {ExampleListing1}
+
+ Beispiel 2:
+ {ExampleListing2}
+ """;
+
+ /// User prompt: the actual group data + the user's free-text hints.
+ public static string BuildUserPrompt(SaleAdRequest request)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("Erstelle ein Abgabe-Inserat für folgende Gruppe:");
+ sb.AppendLine();
+ sb.AppendLine($"Status-Zeile: {request.StatusLine}");
+ sb.AppendLine();
+ sb.AppendLine("Tiere:");
+ foreach (var animal in request.Animals)
+ {
+ sb.Append($"- Name: {animal.Name}");
+ if (!string.IsNullOrWhiteSpace(animal.Farbschlag))
+ sb.Append($" | Farbschlag: {animal.Farbschlag}");
+ if (!string.IsNullOrWhiteSpace(animal.DateOfBirth))
+ sb.Append($" | geboren am {FormatGermanDate(animal.DateOfBirth)}");
+ if (!string.IsNullOrWhiteSpace(animal.Notes))
+ sb.Append($" | Notizen: {animal.Notes}");
+ sb.AppendLine();
+ }
+ if (!string.IsNullOrWhiteSpace(request.Hints))
+ {
+ sb.AppendLine();
+ sb.AppendLine($"Wünsche/Hinweise: {request.Hints}");
+ }
+ return sb.ToString();
+ }
+
+ /// ISO "YYYY-MM-DD" -> "TT.MM.JJJJ"; anything else passes through verbatim.
+ internal static string FormatGermanDate(string isoDate) =>
+ DateOnly.TryParseExact(isoDate, "yyyy-MM-dd", CultureInfo.InvariantCulture,
+ DateTimeStyles.None, out var date)
+ ? date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)
+ : isoDate;
+ }
+}
diff --git a/GerbilManagerWebAPI/SaleAd/SaleAdService.cs b/GerbilManagerWebAPI/SaleAd/SaleAdService.cs
new file mode 100644
index 0000000..47a7c5c
--- /dev/null
+++ b/GerbilManagerWebAPI/SaleAd/SaleAdService.cs
@@ -0,0 +1,87 @@
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+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.
+ ///
+ public sealed class SaleAdService(HttpClient http, IOptions options)
+ {
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ public async Task GenerateAsync(SaleAdRequest request, CancellationToken ct = default)
+ {
+ var ai = options.Value;
+ if (!ai.IsConfigured)
+ {
+ 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"),
+ };
+ 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}");
+ }
+ }
+
+ /// {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/docs/ai-provider.md b/docs/ai-provider.md
new file mode 100644
index 0000000..139c314
--- /dev/null
+++ b/docs/ai-provider.md
@@ -0,0 +1,50 @@
+# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
+
+`POST /gerbils/sale-ad` spricht einen beliebigen **OpenAI-kompatiblen**
+Chat-Completions-Endpunkt an — kein SDK, nur drei Einstellungen (Sektion `AI`),
+gesetzt als **Umgebungsvariablen oder user-secrets, niemals in appsettings
+committen**. Fehlt eine der drei, antwortet der Endpunkt mit
+`503 {code:"AiKeyMissing"}` und die Oberfläche zeigt den deutschen Hinweis.
+
+| Variable | Bedeutung |
+|---|---|
+| `AI__BaseUrl` | Basis-URL des Anbieters (ohne `/chat/completions`) |
+| `AI__ApiKey` | API-Schlüssel (Bearer) |
+| `AI__Model` | Modellname des Anbieters |
+
+## Anbieter-Matrix (alle mit derselben Implementierung getestet)
+
+| Anbieter | `AI__BaseUrl` | Beispiel-`AI__Model` | Kosten |
+|---|---|---|---|
+| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.0-flash` | Free Tier |
+| **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | Free Tier |
+| **Mistral** | `https://api.mistral.ai/v1` | `mistral-small-latest` | Free Tier |
+| **Ollama** (lokal/TrueNAS) | `http://:11434/v1` | `llama3.2` | kostenlos, lokal |
+
+Ollama ignoriert den Schlüssel — `AI__ApiKey=ollama` als Platzhalter setzen
+(die Einstellung darf nur nicht leer sein).
+
+## Setzen der Variablen
+
+**Entwicklung (PowerShell, vor dem AppHost-Start):**
+
+```powershell
+$env:AI__BaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'
+$env:AI__ApiKey = ''
+$env:AI__Model = 'gemini-2.0-flash'
+```
+
+oder per user-secrets im API-Projekt:
+`dotnet user-secrets set "AI:ApiKey" "" --project GerbilManagerWebAPI` (usw.).
+
+**TrueNAS / Docker Compose** (für Dwights Ops-Doku): im `environment:`-Block des
+API-Containers —
+
+```yaml
+services:
+ webapi:
+ environment:
+ AI__BaseUrl: https://generativelanguage.googleapis.com/v1beta/openai
+ AI__ApiKey: ${AI_API_KEY} # Wert in der TrueNAS-App/.env hinterlegen
+ AI__Model: gemini-2.0-flash
+```