Merge feature/feat-12a-backend: provider-agnostic AI sale-ad endpoint (Gemini/Groq/Mistral/Ollama via OpenAI-compat, env config, graceful 503) [god-QA: 37/37]

This commit is contained in:
2026-06-06 07:39:26 +02:00
9 changed files with 585 additions and 0 deletions

View File

@@ -0,0 +1,225 @@
using System.Net;
using System.Text;
using System.Text.Json;
using GerbilManagerWebAPI.SaleAd;
using Microsoft.Extensions.Options;
namespace GerbilManager.Tests
{
/// <summary>
/// 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).
/// </summary>
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"),
};
}
/// <summary>HttpMessageHandler-Stub, der Request + Body zur Inspektion festhält.</summary>
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
: HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
public string? LastRequestBody { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
LastRequestBody = request.Content is null
? null
: await request.Content.ReadAsStringAsync(cancellationToken);
return respond(request);
}
}
}
}

View File

@@ -0,0 +1,34 @@
using GerbilManagerWebAPI.SaleAd;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// 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.
/// </summary>
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;
}
}
}

View File

@@ -30,4 +30,9 @@
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" /> <EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
<InternalsVisibleTo Include="GerbilManager.Tests" />
</ItemGroup>
</Project> </Project>

View File

@@ -35,6 +35,13 @@ builder.Services.AddCors(options => options.AddPolicy(LanCorsPolicy, policy =>
// OpenAPI document for the Scalar API reference (Swashbuckle removed). // OpenAPI document for the Scalar API reference (Swashbuckle removed).
builder.Services.AddOpenApi(); 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<GerbilManagerWebAPI.SaleAd.AiOptions>()
.BindConfiguration(GerbilManagerWebAPI.SaleAd.AiOptions.SectionName);
builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
http => http.Timeout = TimeSpan.FromSeconds(60));
var app = builder.Build(); var app = builder.Build();
app.MapDefaultEndpoints(); app.MapDefaultEndpoints();
@@ -62,5 +69,6 @@ app.MapHealthRecordEndpoints();
app.MapWeightRecordEndpoints(); app.MapWeightRecordEndpoints();
app.MapInbreedingEndpoints(); app.MapInbreedingEndpoints();
app.MapPhotoEndpoints(); app.MapPhotoEndpoints();
app.MapSaleAdEndpoints();
app.Run(); app.Run();

View File

@@ -0,0 +1,30 @@
namespace GerbilManagerWebAPI.SaleAd
{
/// <summary>
/// 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://&lt;host&gt;:11434/v1 (ApiKey beliebig, z. B. "ollama")
/// </summary>
public sealed class AiOptions
{
public const string SectionName = "AI";
public string? BaseUrl { get; set; }
public string? ApiKey { get; set; }
public string? Model { get; set; }
/// <summary>All three settings present -> the sale-ad endpoint is live.</summary>
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(BaseUrl)
&& !string.IsNullOrWhiteSpace(ApiKey)
&& !string.IsNullOrWhiteSpace(Model);
}
}

View File

@@ -0,0 +1,39 @@
namespace GerbilManagerWebAPI.SaleAd
{
/// <summary>
/// FEAT-12a: request/response contract of POST /gerbils/sale-ad — FROZEN,
/// mirror of the frontend (gerbil-manager-web/src/api/saleAd.ts).
/// </summary>
public sealed record SaleAdAnimal(
string Name,
string? Farbschlag,
/// <summary>ISO "YYYY-MM-DD" (frontend sends the DTO string verbatim).</summary>
string? DateOfBirth,
string? Notes);
public sealed record SaleAdRequest(
List<SaleAdAnimal> Animals,
/// <summary>e.g. "FREI" / "LOCKER RESERVIERT Anna" / "RESERVIERT".</summary>
string StatusLine,
/// <summary>Free-text style hints/wishes from the user.</summary>
string Hints);
public sealed record SaleAdResponse(string Text);
/// <summary>
/// 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.
/// </summary>
public sealed record SaleAdError(string Code, string Message);
public enum SaleAdStatus
{
Ok,
/// <summary>AI section not (fully) configured -> HTTP 503, code "AiKeyMissing".</summary>
NotConfigured,
/// <summary>Provider call failed -> HTTP 502, code "AiUpstreamError".</summary>
UpstreamError,
}
public sealed record SaleAdResult(SaleAdStatus Status, string? Text, string? Error = null);
}

View File

@@ -0,0 +1,107 @@
using System.Globalization;
using System.Text;
namespace GerbilManagerWebAPI.SaleAd
{
/// <summary>
/// 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.
/// </summary>
public static class SaleAdPromptBuilder
{
/// <summary>
/// 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.
/// </summary>
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.
""";
/// <summary>System prompt: role, style description, hard rules, few-shot examples.</summary>
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}
""";
/// <summary>User prompt: the actual group data + the user's free-text hints.</summary>
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();
}
/// <summary>ISO "YYYY-MM-DD" -> "TT.MM.JJJJ"; anything else passes through verbatim.</summary>
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;
}
}

View File

@@ -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
{
/// <summary>
/// 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.
/// </summary>
public sealed class SaleAdService(HttpClient http, IOptions<AiOptions> options)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public async Task<SaleAdResult> 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<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>
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);
}
}

50
docs/ai-provider.md Normal file
View File

@@ -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://<host>: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 = '<schlüssel>'
$env:AI__Model = 'gemini-2.0-flash'
```
oder per user-secrets im API-Projekt:
`dotnet user-secrets set "AI:ApiKey" "<schlüssel>" --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
```