feat: Google AI provider, provider fallback with notice, model lists, adult AI chat settings

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-11 12:39:37 +02:00
1 parent 6f11f12c90
commit 0c886652cf
28 files changed
+3723 -25

No files matched your search

+6
View File
@@ -69,3 +69,9 @@ Copilot ist ausdrücklich ein Wunsch, hat aber keine offene API für Dritt-Apps
## Offene Fragen
Modell-Empfehlungen je Anbieter (Rechnen/Lesen von Handschrift), Umgang mit Anbieter-Ausfall (Fallback-Anbieter?), Streaming (nicht nötig, solange Aufgaben als Ganzes erzeugt werden).
## Nachtrag: Anbieter-Fallback, Google AI, Modellliste, KI-Chat der Erwachsenen
* **Google AI (Gemini)** ist ein eigener Anbietertyp (`AiProviderType.Google`, `generateContent`, Schlüssel im Header `x-goog-api-key`, nur https, keine Web-Suche).
* **Fallback:** `AiService.RunAsync` befragt bei Ausfall (nicht erreichbar, Zeitlimit, Fehler, Limit, unbrauchbare Antwort) die übrigen aktiven Anbieter – zuerst die dem Zweck zugeordneten, dann „Allgemein“, dann nach Name. Bei Fotos/Web-Suche nur Anbieter mit entsprechender Freigabe. Ausnahme: Foto-Prüfung wechselt nie (`AiRules.MayFailOver`). Jeder Versuch steht im Nutzungs-/KI-Protokoll. Der Wechsel wird immer angezeigt: `AiResult.FallbackNote` → Hinweis an der KI-Chat-Antwort (`ChatMessage.AiNote`), Herkunftszeile bei KI-Entwürfen.
* **Modelle:** `IAiProvider.ListModelsAsync` (Ollama: `/api/tags`); Admin-Dialog und KI-Chat zeigen die verfügbaren Modelle.
* **KI-Chat, Erwachsene:** eigener Anbieter, Modell und System-Prompt je KI-Kanal (`ChatChannel.AiProviderId/AiModel/AiSystemPrompt`); serverseitig nur Rollen mit `AiUse`, Kinder immer mit festem Prompt und Admin-Anbieter. Migration `Phase13AiFallbackChat`.
@@ -27,6 +27,7 @@ public static class AiEndpoints
admin.MapPut("/providers/{id:guid}", UpdateAsync).Validate<SaveAiProviderRequest>();
admin.MapDelete("/providers/{id:guid}", DeleteAsync);
admin.MapPost("/providers/{id:guid}/test", TestAsync);
admin.MapPost("/models", ModelsAsync);
admin.MapGet("/usage", UsageAsync);
AiExchangeEndpoints.Map(admin);
@@ -124,6 +125,45 @@ public static class AiEndpoints
}
}
/// <summary>Welche Modelle bietet der Anbieter an? (Ollama: installierte Modelle.) Fehler kommen als Meldung im Ergebnis, nicht als Statusfehler.</summary>
internal static async Task<Ok<AiModelListDto>> ModelsAsync(AiModelListRequest request, AppDbContext db, AiService ai, CancellationToken ct)
{
try
{
IReadOnlyList<string>? models;
if (request.ProviderId is { } id && await db.AiProviders.AsNoTracking().SingleOrDefaultAsync(p => p.Id == id, ct) is { } stored)
{
var (url, error) = AiRules.NormalizeBaseUrl(request.Type, request.BaseUrl);
if (url is null)
{
return TypedResults.Ok(new AiModelListDto(false, [], error));
}
// Ohne neu eingegebenen Schlüssel gilt der gespeicherte (nur bei gleichem Anbietertyp).
var key = request.ApiKey ?? (request.Type == stored.Type ? ai.StoredKey(stored) : null);
models = await ai.ListModelsAsync(request.Type, url, key, ct);
}
else
{
var (url, error) = AiRules.NormalizeBaseUrl(request.Type, request.BaseUrl);
if (url is null)
{
return TypedResults.Ok(new AiModelListDto(false, [], error));
}
models = await ai.ListModelsAsync(request.Type, url, request.ApiKey, ct);
}
return TypedResults.Ok(models is null
? new AiModelListDto(false, [], "Dieser Anbietertyp liefert keine Modellliste – bitte den Modellnamen eintragen.")
: new AiModelListDto(true, models, models.Count == 0 ? "Keine Modelle gefunden." : null));
}
catch (AiException ex)
{
return TypedResults.Ok(new AiModelListDto(true, [], ex.Message));
}
}
internal static async Task<Ok<AiUsageOverviewDto>> UsageAsync(AppDbContext db, ISettingsProvider settingsProvider, TimeProvider timeProvider, CancellationToken ct)
{
var zone = (await settingsProvider.GetAsync(ct)).GetTimeZone();
+22 -1
View File
@@ -35,7 +35,25 @@ public sealed record AiResponse(string Text, JsonElement? Json, int? InputTokens
/// <summary>Entschlüsselte Verbindungsdaten für einen einzelnen Aufruf (nie loggen oder weitergeben).</summary>
public sealed record AiProviderSettings(AiProviderType Type, string BaseUrl, string Model, string? ApiKey);
public sealed record AiResult(AiResponse Response, string ProviderName, string Model, long DurationMs);
/// <summary>Ein Anbieter, der übersprungen wurde, weil er nicht (brauchbar) geantwortet hat.</summary>
public sealed record AiFallbackAttempt(string ProviderName, string Reason);
/// <summary>
/// Ergebnis eines KI-Aufrufs. <see cref="Failed"/> nennt die Anbieter, die vorher versucht wurden und ausgefallen sind
/// (Anbieterwechsel, <see cref="FallbackNote"/>); leer, wenn der erste Anbieter geantwortet hat.
/// </summary>
public sealed record AiResult(AiResponse Response, string ProviderName, string Model, long DurationMs, IReadOnlyList<AiFallbackAttempt>? Failed = null)
{
public bool UsedFallback => Failed is { Count: > 0 };
/// <summary>Deutscher Hinweis für die Oberfläche, z. B. „Ollama war nicht erreichbar – stattdessen hat Claude geantwortet.“ (<c>null</c> ohne Wechsel).</summary>
public string? FallbackNote => UsedFallback
? $"{string.Join(", ", Failed!.Select(f => $"„{f.ProviderName}“"))} {(Failed!.Count == 1 ? "hat" : "haben")} nicht geantwortet – stattdessen hat „{ProviderName}“ ({Model}) geantwortet."
: null;
}
/// <summary>Wunsch des Aufrufers: bevorzugter Anbieter und/oder Modell (z. B. im KI-Chat der Erwachsenen). Ohne Angabe gilt die Zuordnung je Zweck.</summary>
public sealed record AiRunOptions(Guid? ProviderId = null, string? Model = null);
public enum AiErrorKind
{
@@ -76,4 +94,7 @@ public interface IAiProvider
AiProviderType Type { get; }
Task<AiResponse> CompleteAsync(AiProviderSettings settings, AiRequest request, CancellationToken ct);
/// <summary>Verfügbare Modelle beim Anbieter (<c>null</c>, wenn der Adapter keine Liste kennt).</summary>
Task<IReadOnlyList<string>?> ListModelsAsync(AiProviderSettings settings, CancellationToken ct) => Task.FromResult<IReadOnlyList<string>?>(null);
}
@@ -54,6 +54,31 @@ public sealed class OllamaProvider(IHttpClientFactory httpFactory) : IAiProvider
return Build(text, request, AiHttp.Int(json, "prompt_eval_count"), AiHttp.Int(json, "eval_count"));
}
/// <summary>Installierte Modelle (<c>GET {base}/api/tags</c>), alphabetisch.</summary>
public async Task<IReadOnlyList<string>?> ListModelsAsync(AiProviderSettings settings, CancellationToken ct)
{
using var message = new HttpRequestMessage(HttpMethod.Get, AiHttp.Combine(settings.BaseUrl, "api/tags"));
if (!string.IsNullOrEmpty(settings.ApiKey))
{
message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", settings.ApiKey);
}
var json = await AiHttp.SendAsync(httpFactory.CreateClient(AiHttp.ClientName), message, ct);
var names = new List<string>();
if (json.TryGetProperty("models", out var models) && models.ValueKind == JsonValueKind.Array)
{
foreach (var model in models.EnumerateArray())
{
if (model.TryGetProperty("name", out var name) && name.ValueKind == JsonValueKind.String && name.GetString() is { Length: > 0 } value)
{
names.Add(value);
}
}
}
return names.Order(StringComparer.OrdinalIgnoreCase).ToList();
}
internal static AiResponse Build(string text, AiRequest request, int? input, int? output) =>
new(text, request.Schema is null ? null : AiHttp.ExtractJson(text), input, output);
}
@@ -163,6 +188,65 @@ public sealed class ClaudeProvider(IHttpClientFactory httpFactory) : IAiProvider
}
}
/// <summary>
/// Google AI / Gemini (<c>POST {base}/v1beta/models/{model}:generateContent</c>). Bilder als <c>inlineData</c>, strukturierte Antwort über
/// <c>responseMimeType</c> + <c>responseJsonSchema</c>. Rollen: <c>user</c>/<c>model</c>. Keine Web-Suche (mit JSON-Schema nicht kombinierbar).
/// </summary>
public sealed class GoogleProvider(IHttpClientFactory httpFactory) : IAiProvider
{
public AiProviderType Type => AiProviderType.Google;
public async Task<AiResponse> CompleteAsync(AiProviderSettings settings, AiRequest request, CancellationToken ct)
{
var contents = new JsonArray();
foreach (var message in request.Messages)
{
var parts = new JsonArray();
foreach (var image in message.Images ?? [])
{
parts.Add(new JsonObject { ["inlineData"] = new JsonObject { ["mimeType"] = image.ContentType, ["data"] = AiHttp.Base64(image) } });
}
parts.Add(new JsonObject { ["text"] = message.Text });
contents.Add(new JsonObject { ["role"] = message.Role == "assistant" ? "model" : "user", ["parts"] = parts });
}
var generation = new JsonObject { ["maxOutputTokens"] = request.MaxOutputTokens };
if (request.Schema is { } schema)
{
generation["responseMimeType"] = "application/json";
generation["responseJsonSchema"] = schema.Schema.DeepClone();
}
var body = new JsonObject { ["contents"] = contents, ["generationConfig"] = generation };
if (!string.IsNullOrWhiteSpace(request.System))
{
body["systemInstruction"] = new JsonObject { ["parts"] = new JsonArray(new JsonObject { ["text"] = request.System }) };
}
var path = $"v1beta/models/{Uri.EscapeDataString(settings.Model)}:generateContent";
using var http = new HttpRequestMessage(HttpMethod.Post, AiHttp.Combine(settings.BaseUrl, path)) { Content = AiHttp.Json(body) };
http.Headers.Add("x-goog-api-key", settings.ApiKey ?? string.Empty);
var json = await AiHttp.SendAsync(httpFactory.CreateClient(AiHttp.ClientName), http, ct);
var text = new System.Text.StringBuilder();
if (json.TryGetProperty("candidates", out var candidates) && candidates.ValueKind == JsonValueKind.Array && candidates.GetArrayLength() > 0
&& candidates[0].TryGetProperty("content", out var content) && content.TryGetProperty("parts", out var parts2) && parts2.ValueKind == JsonValueKind.Array)
{
foreach (var part in parts2.EnumerateArray())
{
if (part.TryGetProperty("text", out var value) && value.ValueKind == JsonValueKind.String && !(part.TryGetProperty("thought", out var thought) && thought.ValueKind == JsonValueKind.True))
{
text.Append(value.GetString());
}
}
}
var usage = json.TryGetProperty("usageMetadata", out var u) ? u : default;
return OllamaProvider.Build(text.ToString(), request, AiHttp.Int(usage, "promptTokenCount"), AiHttp.Int(usage, "candidatesTokenCount"));
}
}
/// <summary>
/// OpenAI-kompatible Chat-Completions (<c>POST {base}/chat/completions</c>) – Perplexity, Azure OpenAI, GitHub Models, LM Studio u. a.
/// Bilder als <c>image_url</c> mit Data-URL, strukturierte Antwort über <c>response_format</c> (json_schema).
+94 -13
View File
@@ -62,16 +62,73 @@ public sealed class AiService(
?? (!AiRules.FallsBackToDefault(purpose) ? null : active.FirstOrDefault(p => p.Purposes.HasFlag(AiPurpose.Default)));
}
public async Task<AiResult> RunAsync(Guid userId, AiPurpose purpose, AiRequest request, CancellationToken ct)
/// <summary>
/// Führt die Anfrage mit dem Anbieter des Zwecks (oder dem gewünschten aus <paramref name="options"/>) aus. Antwortet er nicht (nicht erreichbar, Zeitlimit,
/// Fehler, Limit, unbrauchbare Antwort), werden die übrigen aktiven Anbieter der Reihe nach befragt; <see cref="AiResult.Failed"/> hält den Wechsel fest.
/// Die Foto-Prüfung wechselt nur zu Anbietern, die diesem Zweck zugeordnet sind (<see cref="AiRules.MayFailOver"/>); Anbieter ohne Bild-Freigabe werden bei Fotos übersprungen.
/// Scheitern alle, gilt der Fehler des ersten Anbieters.
/// </summary>
public async Task<AiResult> RunAsync(Guid userId, AiPurpose purpose, AiRequest request, CancellationToken ct, AiRunOptions? options = null)
{
var provider = await ResolveAsync(purpose, ct)
?? throw new AiException(AiErrorKind.NotConfigured, "Es ist noch kein KI-Anbieter eingerichtet. Das kann ein Administrator unter „Verwaltung → KI“ tun.");
return await RunWithAsync(userId, provider, purpose, request, ct);
var candidates = await CandidatesAsync(purpose, request, options, ct);
if (candidates.Count == 0)
{
throw new AiException(AiErrorKind.NotConfigured, "Es ist noch kein KI-Anbieter eingerichtet. Das kann ein Administrator unter „Verwaltung → KI“ tun.");
}
var failed = new List<AiFallbackAttempt>();
AiException? first = null;
for (var i = 0; ; i++)
{
var (provider, model) = candidates[i];
try
{
var result = await RunWithAsync(userId, provider, purpose, request, ct, model);
return failed.Count == 0 ? result : result with { Failed = failed };
}
catch (AiException ex) when (i < candidates.Count - 1 && IsFallbackWorthy(ex.Kind))
{
first ??= ex;
failed.Add(new AiFallbackAttempt(provider.Name, ex.Message));
logger.LogWarning("KI-Anbieter {Provider} ausgefallen ({Kind}), versuche den nächsten.", provider.Name, ex.Kind);
}
catch (AiException) when (first is not null)
{
throw first;
}
}
}
/// <summary>Ruft einen bestimmten Anbieter auf (Verbindungstest, Fachfunktionen mit fester Wahl).</summary>
public async Task<AiResult> RunWithAsync(Guid userId, AiProviderConfig provider, AiPurpose purpose, AiRequest request, CancellationToken ct)
/// <summary>Konfigurations-Sperren (Bilder/Suche nicht erlaubt) sind keine Ausfälle – ein anderer Anbieter würde die Regel des Admins umgehen.</summary>
private static bool IsFallbackWorthy(AiErrorKind kind) => kind is not (AiErrorKind.ImagesNotAllowed or AiErrorKind.WebSearchNotAllowed or AiErrorKind.NotConfigured);
private async Task<List<(AiProviderConfig Provider, string? Model)>> CandidatesAsync(AiPurpose purpose, AiRequest request, AiRunOptions? options, CancellationToken ct)
{
var active = await db.AiProviders.AsNoTracking().Where(p => p.IsActive).OrderBy(p => p.Name).ToListAsync(ct);
var fallsBack = AiRules.FallsBackToDefault(purpose);
var primary = options?.ProviderId is { } wanted ? active.FirstOrDefault(p => p.Id == wanted) : null;
var model = options?.ProviderId is not null && primary is null ? null : options?.Model; // Modell eines nicht mehr vorhandenen Anbieters nicht übernehmen
primary ??= active.FirstOrDefault(p => p.Purposes.HasFlag(purpose)) ?? (fallsBack ? active.FirstOrDefault(p => p.Purposes.HasFlag(AiPurpose.Default)) : null);
if (primary is null)
{
return [];
}
var list = new List<(AiProviderConfig, string?)> { (primary, string.IsNullOrWhiteSpace(model) ? null : model.Trim()) };
list.AddRange(active
.Where(p => p.Id != primary.Id && (AiRules.MayFailOver(purpose) || p.Purposes.HasFlag(purpose)))
.Where(p => (!request.HasImages || AiRules.MayReceiveImages(p.AllowImages, p.BaseUrl))
&& (!request.WebSearch || (p.AllowWebSearch && AiRules.SupportsWebSearch(p.Type))))
.OrderByDescending(p => p.Purposes.HasFlag(purpose))
.ThenByDescending(p => p.Purposes.HasFlag(AiPurpose.Default))
.Select(p => (p, (string?)null)));
return list;
}
/// <summary>Ruft einen bestimmten Anbieter auf (Verbindungstest, Fachfunktionen mit fester Wahl); <paramref name="modelOverride"/> ersetzt das Modell des Anbieters.</summary>
public async Task<AiResult> RunWithAsync(Guid userId, AiProviderConfig provider, AiPurpose purpose, AiRequest request, CancellationToken ct, string? modelOverride = null)
{
var model = string.IsNullOrWhiteSpace(modelOverride) ? provider.Model : modelOverride;
var started = timeProvider.GetUtcNow();
var stopwatch = Stopwatch.StartNew();
int? inputTokens = null;
@@ -95,7 +152,7 @@ public sealed class AiService(
var adapter = providers.FirstOrDefault(p => p.Type == provider.Type)
?? throw new AiException(AiErrorKind.Misconfigured, "Für diesen Anbietertyp gibt es keinen Adapter.");
var settings = new AiProviderSettings(provider.Type, provider.BaseUrl, provider.Model, Key(provider));
var settings = new AiProviderSettings(provider.Type, provider.BaseUrl, model, Key(provider));
for (var attempt = 1; ; attempt++)
{
@@ -110,8 +167,8 @@ public sealed class AiService(
throw new AiException(AiErrorKind.BadResponse, "Die KI hat nicht im verlangten Format geantwortet.");
}
await LogAsync(userId, provider, purpose, request, started, stopwatch.ElapsedMilliseconds, inputTokens, outputTokens, null, null, rawResponse);
return new AiResult(response, provider.Name, provider.Model, stopwatch.ElapsedMilliseconds);
await LogAsync(userId, provider, model, purpose, request, started, stopwatch.ElapsedMilliseconds, inputTokens, outputTokens, null, null, rawResponse);
return new AiResult(response, provider.Name, model, stopwatch.ElapsedMilliseconds);
}
catch (AiException ex) when (ex.Kind == AiErrorKind.BadResponse && attempt < MaxAttempts)
{
@@ -121,11 +178,35 @@ public sealed class AiService(
}
catch (AiException ex)
{
await LogAsync(userId, provider, purpose, request, started, stopwatch.ElapsedMilliseconds, inputTokens, outputTokens, ex.Label, ex.Message, rawResponse);
await LogAsync(userId, provider, model, purpose, request, started, stopwatch.ElapsedMilliseconds, inputTokens, outputTokens, ex.Label, ex.Message, rawResponse);
throw;
}
}
/// <summary>Verfügbare Modelle eines gespeicherten Anbieters (<c>null</c>: Anbietertyp liefert keine Liste).</summary>
public Task<IReadOnlyList<string>?> ListModelsAsync(AiProviderConfig provider, CancellationToken ct) =>
ListModelsAsync(provider.Type, provider.BaseUrl, Key(provider), ct);
/// <summary>Der entschlüsselte gespeicherte Schlüssel (<c>null</c> ohne Schlüssel; wirft <see cref="AiException"/>, wenn er nötig ist, aber fehlt oder nicht lesbar ist).</summary>
public string? StoredKey(AiProviderConfig provider) => Key(provider);
/// <summary>Verfügbare Modelle für die angegebenen Verbindungsdaten (Dialog vor dem Speichern). Kurzes festes Zeitlimit.</summary>
public async Task<IReadOnlyList<string>?> ListModelsAsync(AiProviderType type, string baseUrl, string? apiKey, CancellationToken ct)
{
var adapter = providers.FirstOrDefault(p => p.Type == type)
?? throw new AiException(AiErrorKind.Misconfigured, "Für diesen Anbietertyp gibt es keinen Adapter.");
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(15));
try
{
return await adapter.ListModelsAsync(new AiProviderSettings(type, baseUrl, string.Empty, apiKey), timeout.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
throw new AiException(AiErrorKind.Timeout, "Der KI-Anbieter hat nicht rechtzeitig geantwortet.");
}
}
private async Task EnsureWithinLimitAsync(Guid userId, AiProviderConfig provider, CancellationToken ct)
{
if (provider.DailyLimitPerUser <= 0)
@@ -170,7 +251,7 @@ public sealed class AiService(
}
private async Task LogAsync(
Guid userId, AiProviderConfig provider, AiPurpose purpose, AiRequest request, DateTimeOffset started, long durationMs, int? input, int? output,
Guid userId, AiProviderConfig provider, string model, AiPurpose purpose, AiRequest request, DateTimeOffset started, long durationMs, int? input, int? output,
string? error, string? errorMessage, string? response)
{
db.AiUsage.Add(new AiUsage
@@ -178,7 +259,7 @@ public sealed class AiService(
UserId = userId,
ProviderId = provider.Id,
ProviderName = provider.Name,
Model = provider.Model,
Model = model,
Purpose = purpose,
Success = error is null,
Error = error,
@@ -197,7 +278,7 @@ public sealed class AiService(
{
UserId = userId,
ProviderName = provider.Name,
Model = provider.Model,
Model = model,
Purpose = purpose,
StartedAt = started,
DurationMs = durationMs,
@@ -0,0 +1,118 @@
using System.Security.Claims;
using FamilyPlanner.Api.Features.Ai;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain.Ai;
using FamilyPlanner.Domain.Chat;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Shared.Ai;
using FamilyPlanner.Shared.Chat;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace FamilyPlanner.Api.Features.Chat;
/// <summary>
/// Einstellungen des eigenen KI-Kanals – nur für Erwachsene (nie Kinder, serverseitig geprüft): Anbieter, Modell und eigener System-Prompt.
/// Ohne Angabe gilt die Zuordnung des Admins und der feste, kindgerechte Standard-Prompt.
/// </summary>
internal static class ChatAiSettingsEndpoints
{
public static RouteGroupBuilder MapAiSettingsEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/channels/{id:guid}/ai-settings", GetAsync);
group.MapPut("/channels/{id:guid}/ai-settings", SaveAsync).Validate<SaveChatAiSettingsRequest>();
group.MapGet("/channels/{id:guid}/ai-models", ModelsAsync);
return group;
}
internal static async Task<Results<Ok<ChatAiSettingsDto>, NotFound, ForbidHttpResult>> GetAsync(
Guid id, ClaimsPrincipal user, AppDbContext db, ChatAccessService access, CancellationToken ct)
{
var actor = await access.ActorAsync(user, ct);
if (!ChatRules.CanCustomizeAiChat(actor.Roles))
{
return TypedResults.Forbid();
}
var channel = await access.ChannelAsync(actor, id, ct);
return channel is not { Kind: ChatChannelKind.Ai } ? TypedResults.NotFound() : TypedResults.Ok(await ToDtoAsync(db, channel, ct));
}
internal static async Task<Results<Ok<ChatAiSettingsDto>, NotFound, ForbidHttpResult, ValidationProblem>> SaveAsync(
Guid id, SaveChatAiSettingsRequest request, ClaimsPrincipal user, AppDbContext db, ChatAccessService access, CancellationToken ct)
{
var actor = await access.ActorAsync(user, ct);
if (!ChatRules.CanCustomizeAiChat(actor.Roles))
{
return TypedResults.Forbid();
}
if (await access.ChannelAsync(actor, id, ct) is not { Kind: ChatChannelKind.Ai })
{
return TypedResults.NotFound();
}
if (request.ProviderId is { } providerId && !await db.AiProviders.AnyAsync(p => p.Id == providerId && p.IsActive, ct))
{
return ChatEndpoints.Invalid(nameof(SaveChatAiSettingsRequest.ProviderId), "Dieser Anbieter ist nicht (mehr) verfügbar.");
}
var model = string.IsNullOrWhiteSpace(request.Model) ? null : request.Model.Trim();
var prompt = ChatRules.NormalizeText(request.SystemPrompt);
await db.ChatChannels.Where(c => c.Id == id)
.ExecuteUpdateAsync(s => s
.SetProperty(c => c.AiProviderId, request.ProviderId)
.SetProperty(c => c.AiModel, model)
.SetProperty(c => c.AiSystemPrompt, prompt), ct);
var channel = await db.ChatChannels.AsNoTracking().SingleAsync(c => c.Id == id, ct);
return TypedResults.Ok(await ToDtoAsync(db, channel, ct));
}
/// <summary>Modelle des gewählten Anbieters (ohne Angabe: der dem KI-Chat zugeordnete); Fehler als Meldung im Ergebnis.</summary>
internal static async Task<Results<Ok<AiModelListDto>, NotFound, ForbidHttpResult>> ModelsAsync(
Guid id, Guid? providerId, ClaimsPrincipal user, AppDbContext db, ChatAccessService access, AiService ai, CancellationToken ct)
{
var actor = await access.ActorAsync(user, ct);
if (!ChatRules.CanCustomizeAiChat(actor.Roles))
{
return TypedResults.Forbid();
}
if (await access.ChannelAsync(actor, id, ct) is not { Kind: ChatChannelKind.Ai })
{
return TypedResults.NotFound();
}
var provider = providerId is { } pid
? await db.AiProviders.AsNoTracking().SingleOrDefaultAsync(p => p.Id == pid && p.IsActive, ct)
: await ai.ResolveAsync(AiPurpose.ChatAssistant, ct);
if (provider is null)
{
return TypedResults.NotFound();
}
try
{
var models = await ai.ListModelsAsync(provider, ct);
return TypedResults.Ok(models is null
? new AiModelListDto(false, [], "Dieser Anbieter liefert keine Modellliste – bitte den Modellnamen eintragen.")
: new AiModelListDto(true, models, models.Count == 0 ? "Keine Modelle gefunden." : null));
}
catch (AiException ex)
{
return TypedResults.Ok(new AiModelListDto(true, [], ex.Message));
}
}
private static async Task<ChatAiSettingsDto> ToDtoAsync(AppDbContext db, ChatChannel channel, CancellationToken ct)
{
var providers = await db.AiProviders.AsNoTracking().Where(p => p.IsActive).OrderBy(p => p.Name).ToListAsync(ct);
return new ChatAiSettingsDto(
channel.AiProviderId,
channel.AiModel,
channel.AiSystemPrompt,
ChatAssistantPrompt.System,
providers.Select(p => new ChatAiProviderOptionDto(p.Id, p.Name, p.Type, p.Model, !AiRules.IsHomeNetwork(p.BaseUrl))).ToList());
}
}
@@ -34,6 +34,7 @@ public static class ChatEndpoints
group.MapGet("/features", GetFeaturesAsync);
group.MapPost("/ai", StartAiAsync);
group.MapGifEndpoints();
group.MapAiSettingsEndpoints();
return app;
}
@@ -196,7 +197,8 @@ public static class ChatEndpoints
var actor = await access.ActorAsync(user, ct);
var gif = gifs.IsConfigured && (actor.CanFamilyChat || actor.CanDirect);
var provider = actor.CanAiChat ? await ai.ResolveAsync(AiPurpose.ChatAssistant, ct) : null;
return TypedResults.Ok(new ChatFeaturesDto(gif, provider is not null, provider is not null && !AiRules.IsHomeNetwork(provider.BaseUrl)));
return TypedResults.Ok(new ChatFeaturesDto(
gif, provider is not null, provider is not null && !AiRules.IsHomeNetwork(provider.BaseUrl), actor.CanAiChat && ChatRules.CanCustomizeAiChat(actor.Roles)));
}
// ---------- Verlauf ----------
@@ -307,12 +309,19 @@ public static class ChatEndpoints
return null;
}
// Eigene Wahl (Anbieter, Modell, System-Prompt) gilt nur für Erwachsene – auch wenn die Rolle sich nach dem Speichern geändert hat.
var custom = ChatRules.CanCustomizeAiChat(actor.Roles);
var system = custom && !string.IsNullOrWhiteSpace(channel.AiSystemPrompt) ? channel.AiSystemPrompt : ChatAssistantPrompt.System;
var options = custom ? new AiRunOptions(channel.AiProviderId, channel.AiModel) : null;
string? reply;
string? note;
try
{
var result = await ai.RunAsync(
actor.UserId, AiPurpose.ChatAssistant, new AiRequest(ChatAssistantPrompt.System, messages, MaxOutputTokens: ChatRules.AiMaxOutputTokens), ct);
actor.UserId, AiPurpose.ChatAssistant, new AiRequest(system, messages, MaxOutputTokens: ChatRules.AiMaxOutputTokens), ct, options);
reply = ChatAssistantPrompt.NormalizeReply(result.Response.Text);
note = result.FallbackNote is { Length: > ChatChannel.AiNoteMaxLength } text ? text[..ChatChannel.AiNoteMaxLength] : result.FallbackNote;
}
catch (AiException ex)
{
@@ -324,7 +333,7 @@ public static class ChatEndpoints
return TypedResults.Problem(statusCode: StatusCodes.Status502BadGateway, title: "KI-Chat", detail: "Die KI hat keine Antwort geliefert. Versuch es noch einmal.");
}
var answer = new ChatMessage { ChannelId = channel.Id, SenderId = ChatRules.AiSenderId, Text = reply };
var answer = new ChatMessage { ChannelId = channel.Id, SenderId = ChatRules.AiSenderId, Text = reply, AiNote = note };
db.ChatMessages.Add(answer);
await db.SaveChangesAsync(ct);
var answeredAt = answer.CreatedAt;
@@ -662,7 +671,8 @@ public static class ChatEndpoints
m.Text,
m.HasImage ? $"/api/chat/attachments/{m.Id}" : null,
m.CreatedAt,
reactions.GetValueOrDefault(m.Id) ?? [])).ToList();
reactions.GetValueOrDefault(m.Id) ?? [],
m.AiNote)).ToList();
}
private static async Task<Dictionary<Guid, List<ChatReactionDto>>> ReactionsAsync(AppDbContext db, IReadOnlyCollection<Guid> messageIds, CancellationToken ct)
@@ -48,7 +48,8 @@ internal static class LearningAiDraftPipeline
{
Source = LearningSource.Ai,
AiPrompt = LearningAiPrompt.StoredPrompt(request),
AiProvider = Truncate(result.ProviderName, LearningSet.AiProviderMaxLength),
// Anbieterwechsel bleibt sichtbar (AiDraftNotice zeigt „Erstellt mit …“).
AiProvider = Truncate(result.UsedFallback ? $"{result.ProviderName} – Ersatz für {string.Join(", ", result.Failed!.Select(f => f.ProviderName))}" : result.ProviderName, LearningSet.AiProviderMaxLength),
AiModel = Truncate(result.Model, LearningSet.AiProviderMaxLength),
};
LearningManageEndpoints.ApplySet(set, fields);
+1
View File
@@ -47,6 +47,7 @@ builder.Services.AddSingleton<IAiSecretProtector, AiSecretProtector>();
builder.Services.AddScoped<IAiProvider, OllamaProvider>();
builder.Services.AddScoped<IAiProvider, ClaudeProvider>();
builder.Services.AddScoped<IAiProvider, OpenAiCompatibleProvider>();
builder.Services.AddScoped<IAiProvider, GoogleProvider>();
builder.Services.AddScoped<ChatNotifier>();
// GIF-Suche (ADR-0022): Giphy hinter IGifProvider; ohne Schlüssel (Giphy__ApiKey) bleibt die Funktion aus. Keine Weiterleitungen, kein Anfrage-Protokoll
@@ -12,6 +12,9 @@ public enum AiProviderType
/// <summary>Jede OpenAI-kompatible Chat-Completions-Schnittstelle (Perplexity, Azure OpenAI, GitHub Models, LM Studio …).</summary>
OpenAiCompatible = 2,
/// <summary>Google AI (Gemini API, <c>generateContent</c>).</summary>
Google = 3,
}
/// <summary>Wofür ein Anbieter eingesetzt wird. Je Zweck ist höchstens ein Anbieter zugeordnet.</summary>
+12 -4
View File
@@ -27,6 +27,8 @@ public static class AiRules
"Läuft lokal im Heimnetz; Bilder nur mit einem Vision-Modell (z. B. qwen2.5vl, llava)."),
new(AiProviderType.Claude, "Claude (Anthropic)", "https://api.anthropic.com", "claude-sonnet-5-5", true,
"Cloud-Dienst mit API-Schlüssel; Aufgaben und Fotos werden an Anthropic gesendet."),
new(AiProviderType.Google, "Google AI (Gemini)", "https://generativelanguage.googleapis.com", "gemini-2.5-flash", true,
"Cloud-Dienst mit API-Schlüssel (Google AI Studio); Aufgaben und Fotos werden an Google gesendet."),
new(AiProviderType.OpenAiCompatible, "OpenAI-kompatibel (z. B. Perplexity)", "https://api.perplexity.ai", "sonar", false,
"Für Perplexity, Azure OpenAI, GitHub Models oder lokale Server wie LM Studio. Microsoft Copilot selbst hat keine offene Schnittstelle."),
];
@@ -61,6 +63,12 @@ public static class AiRules
/// </summary>
public static bool FallsBackToDefault(AiPurpose purpose) => purpose is not (AiPurpose.LearningImageGrading or AiPurpose.ChatAssistant);
/// <summary>
/// Darf bei einem Ausfall des Anbieters ein anderer aktiver Anbieter einspringen? Ja, außer bei der Foto-Prüfung (ein Anbieter ohne Vision-Modell
/// würde Fotos „lesen“, ohne sie zu sehen). Der Wechsel wird der Person immer angezeigt (auch im KI-Chat), geht also nie unbemerkt.
/// </summary>
public static bool MayFailOver(AiPurpose purpose) => purpose != AiPurpose.LearningImageGrading;
/// <summary>Genau ein einzelner, bekannter Zweck?</summary>
public static bool IsSinglePurpose(AiPurpose purpose) => SelectablePurposes.Contains(purpose);
@@ -97,10 +105,10 @@ public static class AiRules
return (null, "Diese Adresse ist nicht erlaubt.");
}
if (uri.Scheme == "http" && (type == AiProviderType.Claude || !IsHomeNetwork(uri)))
if (uri.Scheme == "http" && (type is AiProviderType.Claude or AiProviderType.Google || !IsHomeNetwork(uri)))
{
return (null, type == AiProviderType.Claude
? "Claude ist nur über https erreichbar."
return (null, type is AiProviderType.Claude or AiProviderType.Google
? $"{Info(type).DisplayName.Split(' ')[0]} ist nur über https erreichbar."
: "Außerhalb des Heimnetzes ist nur https erlaubt.");
}
@@ -139,7 +147,7 @@ public static class AiRules
/// <summary>Braucht der Anbieter einen API-Schlüssel? Claude immer, andere nur außerhalb des Heimnetzes (Ollama nie).</summary>
public static bool RequiresApiKey(AiProviderType type, string baseUrl) => type switch
{
AiProviderType.Claude => true,
AiProviderType.Claude or AiProviderType.Google => true,
AiProviderType.Ollama => false,
_ => !IsHomeNetwork(baseUrl),
};
@@ -17,6 +17,9 @@ public enum ChatChannelKind
public sealed class ChatChannel : AuditableEntity
{
public const int DirectKeyMaxLength = 80;
public const int AiModelMaxLength = 100;
public const int AiSystemPromptMaxLength = 2000;
public const int AiNoteMaxLength = 400;
public ChatChannelKind Kind { get; set; }
@@ -25,6 +28,15 @@ public sealed class ChatChannel : AuditableEntity
/// <summary>Zeitpunkt der letzten Nachricht (Sortierung der Kanalliste); <c>null</c> solange nichts geschrieben wurde.</summary>
public DateTimeOffset? LastMessageAt { get; set; }
/// <summary>KI-Kanal, nur Erwachsene: bevorzugter Anbieter (<c>null</c> = der dem KI-Chat zugeordnete).</summary>
public Guid? AiProviderId { get; set; }
/// <summary>KI-Kanal, nur Erwachsene: Modell, das das Modell des Anbieters überschreibt (<c>null</c> = Modell des Anbieters).</summary>
public string? AiModel { get; set; }
/// <summary>KI-Kanal, nur Erwachsene: eigener System-Prompt (<c>null</c> = fester, kindgerechter Standard-Prompt).</summary>
public string? AiSystemPrompt { get; set; }
}
/// <summary>
@@ -55,6 +67,9 @@ public sealed class ChatMessage : AuditableEntity, ISoftDeletable
/// <summary>Das Bild liegt in <see cref="ChatAttachment"/> (gleiche Id wie die Nachricht).</summary>
public bool HasImage { get; set; }
/// <summary>KI-Antworten: Hinweis, wenn ein anderer Anbieter als der gewählte geantwortet hat (Anbieterwechsel).</summary>
public string? AiNote { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
@@ -63,6 +63,9 @@ public static class ChatRules
return PermissionMatrix.For(Permission.AiUse, list) != AccessLevel.None || (childrenAllowed && list.Contains(Roles.Child));
}
/// <summary>Anbieter, Modell und System-Prompt im KI-Chat ändern: nur Erwachsene (Rollen mit <see cref="Permission.AiUse"/>), nie Kinder.</summary>
public static bool CanCustomizeAiChat(IEnumerable<string> roles) => PermissionMatrix.For(Permission.AiUse, roles.ToList()) != AccessLevel.None;
/// <summary>Eindeutiger Schlüssel des KI-Kanals einer Person (je Person genau einer).</summary>
public static string AiKey(Guid userId) => $"ai:{userId:N}";
@@ -11,6 +11,8 @@ internal sealed class ChatChannelConfiguration : IEntityTypeConfiguration<ChatCh
builder.ToTable("ChatChannels");
builder.Property(c => c.Kind).HasConversion<string>().HasMaxLength(20);
builder.Property(c => c.DirectKey).HasMaxLength(ChatChannel.DirectKeyMaxLength);
builder.Property(c => c.AiModel).HasMaxLength(ChatChannel.AiModelMaxLength);
builder.Property(c => c.AiSystemPrompt).HasMaxLength(ChatChannel.AiSystemPromptMaxLength);
// Genau ein Familienchat und je Personenpaar höchstens ein Direktkanal.
builder.HasIndex(c => c.Kind).IsUnique().HasFilter("[Kind] = 'Family'");
@@ -35,6 +37,7 @@ internal sealed class ChatMessageConfiguration : IEntityTypeConfiguration<ChatMe
{
builder.ToTable("ChatMessages");
builder.Property(m => m.Text).HasMaxLength(ChatMessage.TextMaxLength);
builder.Property(m => m.AiNote).HasMaxLength(ChatChannel.AiNoteMaxLength);
builder.HasOne<ChatChannel>().WithMany().HasForeignKey(m => m.ChannelId).OnDelete(DeleteBehavior.Cascade);
// Seitenweises Laden: neueste zuerst, stabil über (CreatedAt, Id).
@@ -0,0 +1,62 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FamilyPlanner.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Phase13AiFallbackChat : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AiNote",
table: "ChatMessages",
type: "nvarchar(400)",
maxLength: 400,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "AiModel",
table: "ChatChannels",
type: "nvarchar(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "AiProviderId",
table: "ChatChannels",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "AiSystemPrompt",
table: "ChatChannels",
type: "nvarchar(2000)",
maxLength: 2000,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AiNote",
table: "ChatMessages");
migrationBuilder.DropColumn(
name: "AiModel",
table: "ChatChannels");
migrationBuilder.DropColumn(
name: "AiProviderId",
table: "ChatChannels");
migrationBuilder.DropColumn(
name: "AiSystemPrompt",
table: "ChatChannels");
}
}
}
@@ -661,6 +661,17 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("AiModel")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid?>("AiProviderId")
.HasColumnType("uniqueidentifier");
b.Property<string>("AiSystemPrompt")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
@@ -741,6 +752,10 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("AiNote")
.HasMaxLength(400)
.HasColumnType("nvarchar(400)");
b.Property<Guid>("ChannelId")
.HasColumnType("uniqueidentifier");
@@ -94,6 +94,15 @@ public sealed record AiExchangeFilter(AiPurpose? Purpose = null, bool? Success =
public sealed record AiExchangePersonDto(Guid Id, string Name);
/// <summary>
/// Modelle auflisten (Admin): entweder ein gespeicherter Anbieter (<see cref="ProviderId"/>, der gespeicherte Schlüssel wird genutzt) oder die noch
/// ungespeicherten Eingaben des Dialogs. Ein leerer <see cref="BaseUrl"/> nimmt die Voreinstellung des Typs.
/// </summary>
public sealed record AiModelListRequest(AiProviderType Type, string? BaseUrl, string? ApiKey = null, Guid? ProviderId = null);
/// <summary>Verfügbare Modelle. <see cref="Supported"/> ist <c>false</c>, wenn der Anbietertyp keine Liste liefert (dann Modell frei eingeben).</summary>
public sealed record AiModelListDto(bool Supported, IReadOnlyList<string> Models, string? Message = null);
public sealed record AiUsageOverviewDto(IReadOnlyList<AiUsageDto> Recent, int CallsToday, int FailedToday);
/// <summary>
@@ -152,6 +161,7 @@ public sealed class SaveAiProviderRequestValidator : AbstractValidator<SaveAiPro
public static class AiApiRoutes
{
public const string Providers = "api/admin/ai/providers";
public const string Models = "api/admin/ai/models";
public const string Usage = "api/admin/ai/usage";
public const string Exchanges = "api/admin/ai/exchanges";
public const string ExchangePeople = "api/admin/ai/exchanges/people";
+30 -2
View File
@@ -1,3 +1,4 @@
using FamilyPlanner.Domain.Ai;
using FamilyPlanner.Domain.Chat;
using FluentValidation;
@@ -33,7 +34,8 @@ public sealed record ChatMessageDto(
string? Text,
string? ImageUrl,
DateTimeOffset CreatedAt,
IReadOnlyList<ChatReactionDto> Reactions);
IReadOnlyList<ChatReactionDto> Reactions,
string? AiNote = null);
/// <summary>Gelesen-Stand einer Person (für „Gelesen“-Haken).</summary>
public sealed record ChatReadStateDto(Guid UserId, DateTimeOffset LastReadAt);
@@ -55,7 +57,23 @@ public sealed record SetChatReactionRequest(string Emoji, bool Active);
public sealed record ChatReactionsChangedDto(Guid ChannelId, Guid MessageId, IReadOnlyList<ChatReactionDto> Reactions);
/// <summary>Was der Chat für die angemeldete Person anbietet (Buttons erscheinen nur, wenn es wirklich geht; ADR-0022).</summary>
public sealed record ChatFeaturesDto(bool GifSearchAvailable, bool AiChatAvailable, bool AiChatSendsToCloud);
public sealed record ChatFeaturesDto(bool GifSearchAvailable, bool AiChatAvailable, bool AiChatSendsToCloud, bool CanCustomizeAi = false);
/// <summary>Ein wählbarer Anbieter im KI-Chat (nur Erwachsene).</summary>
public sealed record ChatAiProviderOptionDto(Guid Id, string Name, AiProviderType Type, string DefaultModel, bool IsCloud);
/// <summary>
/// Einstellungen des eigenen KI-Kanals (nur Erwachsene): <see cref="ProviderId"/>/<see cref="Model"/> <c>null</c> = Zuordnung des Admins,
/// <see cref="SystemPrompt"/> <c>null</c> = <see cref="DefaultSystemPrompt"/>.
/// </summary>
public sealed record ChatAiSettingsDto(
Guid? ProviderId,
string? Model,
string? SystemPrompt,
string DefaultSystemPrompt,
IReadOnlyList<ChatAiProviderOptionDto> Providers);
public sealed record SaveChatAiSettingsRequest(Guid? ProviderId, string? Model, string? SystemPrompt);
/// <summary>Ein Treffer der GIF-Suche. Die Vorschau kommt über den eigenen Server (<see cref="PreviewUrl"/>), nie direkt vom Anbieter.</summary>
public sealed record GifResultDto(string Id, string Title, string PreviewUrl);
@@ -92,6 +110,16 @@ public sealed class SendChatMessageRequestValidator : AbstractValidator<SendChat
}
}
public sealed class SaveChatAiSettingsRequestValidator : AbstractValidator<SaveChatAiSettingsRequest>
{
public SaveChatAiSettingsRequestValidator()
{
RuleFor(r => r.Model).MaximumLength(ChatChannel.AiModelMaxLength).WithMessage($"Das Modell darf höchstens {ChatChannel.AiModelMaxLength} Zeichen haben.");
RuleFor(r => r.SystemPrompt).MaximumLength(ChatChannel.AiSystemPromptMaxLength)
.WithMessage($"Der System-Prompt darf höchstens {ChatChannel.AiSystemPromptMaxLength} Zeichen haben.");
}
}
public sealed class StartDirectChatRequestValidator : AbstractValidator<StartDirectChatRequest>
{
public StartDirectChatRequestValidator()
@@ -28,6 +28,26 @@
<MudTextField @bind-Value="_model" Label="Modell" InputId="ai-model" Variant="Variant.Outlined" Placeholder="@TypeInfo.DefaultModel"
Error="@HasError("Model")" ErrorText="@ErrorFor("Model")" />
<div class="fp-ai-models">
<MudButton Variant="Variant.Text" Size="Size.Small" StartIcon="@Icons.Material.Filled.Refresh" OnClick="LoadModelsAsync" Disabled="_loadingModels"
Class="fp-ai-models-load">@(_loadingModels ? "Wird geladen …" : "Verfügbare Modelle laden")</MudButton>
@if (_modelsMessage is not null)
{
<MudText Typo="Typo.caption" Class="fp-ai-models-message" role="status">@_modelsMessage</MudText>
}
@if (_models.Count > 0)
{
<MudSelect T="string" Value="@(_models.Contains(_model) ? _model : null)" ValueChanged="m => _model = m" Label="Verfügbare Modelle"
Variant="Variant.Outlined" Class="fp-ai-model-select" HelperText="Auswahl übernimmt das Modell in das Feld oben.">
@foreach (var model in _models)
{
<MudSelectItem T="string" Value="@model">@model</MudSelectItem>
}
</MudSelect>
}
</div>
<MudTextField @bind-Value="_apiKey" Label="@(Provider?.HasApiKey == true ? "API-Schlüssel (leer lassen = unverändert)" : "API-Schlüssel")"
InputId="ai-key" Variant="Variant.Outlined" InputType="InputType.Password" Disabled="_clearKey" autocomplete="off"
HelperText="Der Schlüssel wird verschlüsselt gespeichert und nie wieder angezeigt."
@@ -70,6 +90,10 @@
{
<MudText Typo="Typo.caption" Class="fp-ai-websearch-unsupported">Online-Suche: von Ollama nicht unterstützt (die Web-Suche von Ollama braucht einen Cloud-Schlüssel).</MudText>
}
else if (_type == AiProviderType.Google)
{
<MudText Typo="Typo.caption" Class="fp-ai-websearch-unsupported">Online-Suche: für Google AI hier nicht unterstützt.</MudText>
}
else
{
<MudText Typo="Typo.caption" Class="fp-ai-websearch-builtin">
@@ -144,8 +168,33 @@
(p.Name, p.Type, p.BaseUrl, p.Model, p.TimeoutSeconds, p.DailyLimitPerUser, p.IsActive, p.AllowImages, p.Purposes, p.AllowWebSearch);
}
private List<string> _models = [];
private string? _modelsMessage;
private bool _loadingModels;
private async Task LoadModelsAsync()
{
_loadingModels = true;
_modelsMessage = null;
var result = await Api.GetModelsAsync(new AiModelListRequest(
_type, string.IsNullOrWhiteSpace(_baseUrl) ? null : _baseUrl.Trim(), string.IsNullOrEmpty(_apiKey) ? null : _apiKey.Trim(), Provider?.Id));
_loadingModels = false;
if (result.Value is { } list)
{
_models = list.Models.ToList();
_modelsMessage = list.Message ?? $"{list.Models.Count} Modelle gefunden.";
}
else
{
_models = [];
_modelsMessage = result.Problem?.Message ?? "Die Modelle konnten nicht geladen werden.";
}
}
private void OnTypeChanged(AiProviderType type)
{
_models = [];
_modelsMessage = null;
// Voreinstellungen nur übernehmen, solange der Benutzer nichts Eigenes eingetragen hat.
var oldInfo = TypeInfo;
_type = type;
@@ -0,0 +1,155 @@
@using FamilyPlanner.Domain.Chat
@using FamilyPlanner.Shared.Ai
@using FamilyPlanner.Shared.Chat
@inject IChatApi Api
@* KI-Chat-Einstellungen für Erwachsene: Anbieter, Modell und eigener System-Prompt. Kinder sehen diesen Dialog nie (die Api lehnt ab). *@
<MudDialog>
<DialogContent>
@if (_loading)
{
<LoadingIndicator />
}
else if (_settings is null)
{
<MudAlert Severity="Severity.Error" Class="fp-ai-settings-error">@(_error ?? "Die Einstellungen konnten nicht geladen werden.")</MudAlert>
}
else
{
<MudStack Spacing="3">
@if (_error is not null)
{
<MudAlert Severity="Severity.Error" role="alert" Class="fp-ai-settings-error">@_error</MudAlert>
}
<MudSelect T="Guid?" Value="_providerId" ValueChanged="OnProviderChangedAsync" Label="Anbieter" Variant="Variant.Outlined" Class="fp-ai-settings-provider"
Clearable="true" HelperText="Leer = der vom Administrator zugeordnete Anbieter. Fällt der Anbieter aus, wechselt der Chat automatisch (mit Hinweis).">
@foreach (var provider in _settings.Providers)
{
<MudSelectItem T="Guid?" Value="@((Guid?)provider.Id)">@provider.Name@(provider.IsCloud ? " (Internet)" : string.Empty)</MudSelectItem>
}
</MudSelect>
<MudTextField T="string" @bind-Value="_model" Label="Modell" Variant="Variant.Outlined" Class="fp-ai-settings-model" MaxLength="@ChatChannel.AiModelMaxLength"
Placeholder="@(CurrentDefaultModel ?? "Modell des Anbieters")" HelperText="Leer = Modell des Anbieters." />
<div>
<MudButton Variant="Variant.Text" Size="Size.Small" StartIcon="@Icons.Material.Filled.Refresh" OnClick="LoadModelsAsync" Disabled="_loadingModels"
Class="fp-ai-settings-models-load">@(_loadingModels ? "Wird geladen …" : "Verfügbare Modelle laden")</MudButton>
@if (_modelsMessage is not null)
{
<MudText Typo="Typo.caption" role="status">@_modelsMessage</MudText>
}
@if (_models.Count > 0)
{
<MudSelect T="string" Value="@(_model is not null && _models.Contains(_model) ? _model : null)" ValueChanged="m => _model = m" Label="Verfügbare Modelle"
Variant="Variant.Outlined" Class="fp-ai-settings-model-select">
@foreach (var model in _models)
{
<MudSelectItem T="string" Value="@model">@model</MudSelectItem>
}
</MudSelect>
}
</div>
<MudTextField T="string" @bind-Value="_prompt" Label="System-Prompt" Variant="Variant.Outlined" Lines="6" Sizing="InputSizing.Auto" MaxLength="@ChatChannel.AiSystemPromptMaxLength"
Class="fp-ai-settings-prompt" Placeholder="@_settings.DefaultSystemPrompt"
HelperText="Leer = fester, kindgerechter Standard-Prompt. Gilt nur für deinen eigenen KI-Chat." />
<MudButton Variant="Variant.Text" Size="Size.Small" OnClick="() => _prompt = _settings.DefaultSystemPrompt" Class="fp-ai-settings-prompt-default">Standard-Prompt einfügen</MudButton>
</MudStack>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="() => Dialog?.Cancel()" Disabled="_busy">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveAsync" Disabled="@(_busy || _settings is null)" Class="fp-dialog-save">Speichern</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? Dialog { get; set; }
[Parameter, EditorRequired]
public Guid ChannelId { get; set; }
private ChatAiSettingsDto? _settings;
private Guid? _providerId;
private string? _model;
private string? _prompt;
private List<string> _models = [];
private string? _modelsMessage;
private string? _error;
private bool _loading = true;
private bool _loadingModels;
private bool _busy;
private string? CurrentDefaultModel => _settings?.Providers.FirstOrDefault(p => p.Id == _providerId)?.DefaultModel;
protected override async Task OnInitializedAsync()
{
var result = await Api.GetAiSettingsAsync(ChannelId);
_loading = false;
if (result.Value is { } settings)
{
(_settings, _providerId, _model, _prompt) = (settings, settings.ProviderId, settings.Model, settings.SystemPrompt);
}
else
{
_error = result.Problem?.Message;
}
}
private Task OnProviderChangedAsync(Guid? providerId)
{
_providerId = providerId;
_models = [];
_modelsMessage = null;
_model = null; // das Modell gehört zum Anbieter
return Task.CompletedTask;
}
private async Task LoadModelsAsync()
{
_loadingModels = true;
_modelsMessage = null;
var result = await Api.GetAiModelsAsync(ChannelId, _providerId);
_loadingModels = false;
if (result.Value is { } list)
{
_models = list.Models.ToList();
_modelsMessage = list.Message ?? $"{list.Models.Count} Modelle gefunden.";
}
else
{
_models = [];
_modelsMessage = result.Problem?.Message ?? "Die Modelle konnten nicht geladen werden.";
}
}
private async Task SaveAsync()
{
_error = null;
var request = new SaveChatAiSettingsRequest(
_providerId,
string.IsNullOrWhiteSpace(_model) ? null : _model.Trim(),
string.IsNullOrWhiteSpace(_prompt) || _prompt.Trim() == _settings?.DefaultSystemPrompt ? null : _prompt.Trim());
var validation = new SaveChatAiSettingsRequestValidator().Validate(request);
if (!validation.IsValid)
{
_error = string.Join(" ", validation.Errors.Select(e => e.ErrorMessage));
return;
}
_busy = true;
var result = await Api.SaveAiSettingsAsync(ChannelId, request);
_busy = false;
if (result.Problem is { } problem)
{
_error = problem.Errors is { Count: > 0 } errors ? string.Join(" ", errors.SelectMany(e => e.Value)) : problem.Message;
return;
}
Dialog?.Close(DialogResult.Ok(true));
}
}
@@ -22,6 +22,10 @@
{
<span class="fp-chat-text">@Message.Text</span>
}
@if (!string.IsNullOrWhiteSpace(Message.AiNote))
{
<span class="fp-chat-ai-note" role="note"><MudIcon Icon="@Icons.Material.Filled.SwapHoriz" Size="Size.Small" /> Anbieter gewechselt: @Message.AiNote</span>
}
<span class="fp-chat-meta">
<span class="fp-chat-time">@ChatFormat.Time(Message.CreatedAt)</span>
@if (IsOwn)
@@ -117,6 +117,11 @@
<header class="fp-chat-header">
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="chat" Class="fp-chat-back" aria-label="Zurück zu den Unterhaltungen" />
<MudText Typo="Typo.h6" HtmlTag="h2">@channel.Title</MudText>
@if (channel.Kind == ChatChannelKind.Ai && Store.Features.CanCustomizeAi)
{
<MudSpacer />
<MudIconButton Icon="@Icons.Material.Filled.Tune" OnClick="OpenAiSettingsAsync" aria-label="KI-Einstellungen (Anbieter, Modell, Prompt)" Class="fp-chat-ai-settings" />
}
</header>
@if (channel.Kind == ChatChannelKind.Ai)
@@ -448,6 +453,21 @@
}
}
private async Task OpenAiSettingsAsync()
{
if (ChannelId is not { } id)
{
return;
}
var parameters = new DialogParameters<ChatAiSettingsDialog> { { d => d.ChannelId, id } };
var dialog = await DialogService.ShowAsync<ChatAiSettingsDialog>("KI-Einstellungen", parameters, new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
if (await dialog.Result is { Canceled: false })
{
Snackbar.Add("KI-Einstellungen gespeichert.", Severity.Success);
}
}
private async Task StartAiAsync()
{
if (await Store.StartAiAsync() is { } channel)
@@ -21,6 +21,10 @@ public interface IAiApi
Task<ApiResult<AiStatusDto>> GetStatusAsync(CancellationToken ct = default);
/// <summary>Verfügbare Modelle beim Anbieter (z. B. installierte Ollama-Modelle). Standardimplementierung für Testdoubles.</summary>
Task<ApiResult<AiModelListDto>> GetModelsAsync(AiModelListRequest request, CancellationToken ct = default) =>
Task.FromResult(ApiResult<AiModelListDto>.Ok(new AiModelListDto(false, [])));
/// <summary>KI-Protokoll (Admin): Seite der Aufrufe, gefiltert. Standardimplementierungen für Testdoubles.</summary>
Task<ApiResult<PagedResult<AiExchangeListItemDto>>> GetExchangesAsync(AiExchangeFilter filter, CancellationToken ct = default) =>
Task.FromResult(ApiResult<PagedResult<AiExchangeListItemDto>>.Ok(new PagedResult<AiExchangeListItemDto>([], 0, filter.Page, filter.PageSize)));
@@ -105,6 +109,9 @@ public sealed class AiApi(HttpClient http) : IAiApi
public Task<ApiResult<AiStatusDto>> GetStatusAsync(CancellationToken ct = default) =>
Send<AiStatusDto>(() => http.GetAsync(AiApiRoutes.Status, ct), ct);
public Task<ApiResult<AiModelListDto>> GetModelsAsync(AiModelListRequest request, CancellationToken ct = default) =>
Send<AiModelListDto>(() => http.PostAsJsonAsync(AiApiRoutes.Models, request, ct), ct);
/// <summary>Wie <see cref="ApiCall"/>, aber 409 liefert die Fehlermeldung des Servers (kein aktueller Stand im Body).</summary>
private static async Task<ApiResult<T>> Send<T>(Func<Task<HttpResponseMessage>> send, CancellationToken ct)
{
@@ -1,5 +1,6 @@
using System.Net.Http.Json;
using FamilyPlanner.Shared.Admin;
using FamilyPlanner.Shared.Ai;
using FamilyPlanner.Shared.Chat;
namespace FamilyPlanner.Web.Client.Services;
@@ -17,6 +18,17 @@ public interface IChatApi
/// <summary>Der eigene KI-Kanal (wird beim ersten Aufruf angelegt).</summary>
Task<ApiResult<ChatChannelDto>> StartAiAsync(CancellationToken ct = default);
/// <summary>Anbieter, Modell und System-Prompt des eigenen KI-Kanals (nur Erwachsene). Standardimplementierungen für Testdoubles.</summary>
Task<ApiResult<ChatAiSettingsDto>> GetAiSettingsAsync(Guid channelId, CancellationToken ct = default) =>
Task.FromResult(ApiResult<ChatAiSettingsDto>.Fail(ApiProblem.Offline));
Task<ApiResult<ChatAiSettingsDto>> SaveAiSettingsAsync(Guid channelId, SaveChatAiSettingsRequest request, CancellationToken ct = default) =>
Task.FromResult(ApiResult<ChatAiSettingsDto>.Fail(ApiProblem.Offline));
/// <summary>Modelle des Anbieters (<paramref name="providerId"/> <c>null</c> = der dem KI-Chat zugeordnete).</summary>
Task<ApiResult<AiModelListDto>> GetAiModelsAsync(Guid channelId, Guid? providerId, CancellationToken ct = default) =>
Task.FromResult(ApiResult<AiModelListDto>.Ok(new AiModelListDto(false, [])));
Task<ApiResult<GifSearchResultDto>> SearchGifsAsync(string query, CancellationToken ct = default);
Task<ApiResult<ChatMessageDto>> SendGifAsync(Guid channelId, SendGifRequest request, CancellationToken ct = default);
@@ -53,6 +65,16 @@ public sealed class ChatApi(HttpClient http) : IChatApi
public Task<ApiResult<ChatChannelDto>> StartAiAsync(CancellationToken ct = default) =>
ApiCall.SendAsync<ChatChannelDto>(() => http.PostAsync($"{Base}/ai", null, ct), ct);
public Task<ApiResult<ChatAiSettingsDto>> GetAiSettingsAsync(Guid channelId, CancellationToken ct = default) =>
ApiCall.SendAsync<ChatAiSettingsDto>(() => http.GetAsync($"{Base}/channels/{channelId}/ai-settings", ct), ct);
public Task<ApiResult<ChatAiSettingsDto>> SaveAiSettingsAsync(Guid channelId, SaveChatAiSettingsRequest request, CancellationToken ct = default) =>
ApiCall.SendAsync<ChatAiSettingsDto>(() => http.PutAsJsonAsync($"{Base}/channels/{channelId}/ai-settings", request, ct), ct);
public Task<ApiResult<AiModelListDto>> GetAiModelsAsync(Guid channelId, Guid? providerId, CancellationToken ct = default) =>
ApiCall.SendAsync<AiModelListDto>(
() => http.GetAsync($"{Base}/channels/{channelId}/ai-models{(providerId is { } id ? $"?providerId={id}" : string.Empty)}", ct), ct);
public Task<ApiResult<GifSearchResultDto>> SearchGifsAsync(string query, CancellationToken ct = default) =>
ApiCall.SendAsync<GifSearchResultDto>(() => http.GetAsync($"{Base}/gifs?q={Uri.EscapeDataString(query)}", ct), ct);
+8
View File
@@ -1369,6 +1369,14 @@ button.fp-meal-dish {
border-radius: 8px;
}
.fp-chat-ai-note {
display: block;
margin-top: 0.35rem;
font-size: 0.75rem;
opacity: 0.85;
font-style: italic;
}
.fp-chat-meta {
align-self: flex-end;
display: inline-flex;
@@ -172,6 +172,116 @@ public sealed class ChatExtrasTests(AppFixture fixture)
// ---------- KI-Kanal: Gespräch ----------
[Fact]
public async Task Failing_provider_is_replaced_by_the_next_one_and_the_switch_is_shown_on_the_answer()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var (_, mom) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _m = mom;
await using var broken = FakeAiServer.Start();
await using var working = FakeAiServer.Start();
broken.Respond = _ => (500, "{}", TimeSpan.Zero);
working.Respond = _ => (200, OllamaReply("Antwort vom Ersatz."), TimeSpan.Zero);
var original = await SetAiChatAsync(admin, enabled: true);
try
{
await ClearProvidersAsync(admin);
foreach (var (name, url, purposes) in new[] { ("Kaputt", broken.BaseUrl, AiPurpose.ChatAssistant), ("Ersatz", working.BaseUrl, AiPurpose.Default) })
{
var created = await admin.PostAsJsonAsync("/api/admin/ai/providers",
new SaveAiProviderRequest(name, AiProviderType.Ollama, url, "fake-model", null, false, 30, true, false, purposes, 0), Ct);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
}
var channel = await StartAiAsync(mom);
Assert.Equal(HttpStatusCode.Created, (await SendRawAsync(mom, channel.Id, "Hallo?")).StatusCode);
var answer = (await PageAsync(mom, channel.Id)).Messages.Last();
Assert.Equal("Antwort vom Ersatz.", answer.Text);
Assert.Contains("Kaputt", answer.AiNote);
Assert.Contains("Ersatz", answer.AiNote);
Assert.NotEmpty(broken.Requests);
Assert.NotEmpty(working.Requests);
// Ohne Ausfall gibt es keinen Hinweis
broken.Respond = _ => (200, OllamaReply("Alles gut."), TimeSpan.Zero);
await SendRawAsync(mom, channel.Id, "Noch eine Frage");
Assert.Null((await PageAsync(mom, channel.Id)).Messages.Last().AiNote);
}
finally
{
await admin.PutAsJsonAsync("/api/admin/settings", original, Ct);
await ClearProvidersAsync(admin);
}
}
[Fact]
public async Task Adults_can_choose_provider_model_and_prompt_for_their_ai_channel_children_cannot()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var (_, mom) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var (_, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
using var _m = mom;
using var _k = kid;
await using var chat = FakeAiServer.Start();
await using var other = FakeAiServer.Start();
chat.Respond = _ => (200, OllamaReply("vom Chat-Anbieter"), TimeSpan.Zero);
other.Respond = request => request.Path.EndsWith("/api/tags", StringComparison.Ordinal)
? (200, """{"models":[{"name":"zeta"},{"name":"alpha"}]}""", TimeSpan.Zero)
: (200, OllamaReply("vom gewählten Anbieter"), TimeSpan.Zero);
var original = await SetAiChatAsync(admin, enabled: true, children: true);
try
{
await ClearProvidersAsync(admin);
AiProviderDto otherProvider = null!;
foreach (var (name, url, purposes) in new[] { ("Chat", chat.BaseUrl, AiPurpose.ChatAssistant), ("Anderer", other.BaseUrl, AiPurpose.None) })
{
var created = await admin.PostAsJsonAsync("/api/admin/ai/providers",
new SaveAiProviderRequest(name, AiProviderType.Ollama, url, "fake-model", null, false, 30, true, false, purposes, 0), Ct);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
if (name == "Anderer")
{
otherProvider = (await created.Content.ReadFromJsonAsync<AiProviderDto>(Ct))!;
}
}
Assert.True((await FeaturesAsync(mom)).CanCustomizeAi);
Assert.False((await FeaturesAsync(kid)).CanCustomizeAi);
var kidChannel = await StartAiAsync(kid);
Assert.Equal(HttpStatusCode.Forbidden, (await kid.GetAsync($"/api/chat/channels/{kidChannel.Id}/ai-settings", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden,
(await kid.PutAsJsonAsync($"/api/chat/channels/{kidChannel.Id}/ai-settings", new SaveChatAiSettingsRequest(otherProvider.Id, "x", "Böse"), Ct)).StatusCode);
var channel = await StartAiAsync(mom);
var settings = (await mom.GetFromJsonAsync<ChatAiSettingsDto>($"/api/chat/channels/{channel.Id}/ai-settings", Ct))!;
Assert.Null(settings.ProviderId);
Assert.Contains(settings.Providers, p => p.Name == "Anderer");
var models = (await mom.GetFromJsonAsync<AiModelListDto>($"/api/chat/channels/{channel.Id}/ai-models?providerId={otherProvider.Id}", Ct))!;
Assert.Equal(["alpha", "zeta"], models.Models);
var saved = await mom.PutAsJsonAsync($"/api/chat/channels/{channel.Id}/ai-settings", new SaveChatAiSettingsRequest(otherProvider.Id, "alpha", "Antworte wie ein Pirat."), Ct);
Assert.Equal(HttpStatusCode.OK, saved.StatusCode);
Assert.Equal(HttpStatusCode.Created, (await SendRawAsync(mom, channel.Id, "Ahoi")).StatusCode);
Assert.Equal("vom gewählten Anbieter", (await PageAsync(mom, channel.Id)).Messages.Last().Text);
Assert.Empty(chat.Requests);
var body = other.Requests.Last(r => !r.Path.EndsWith("/api/tags", StringComparison.Ordinal)).Body ?? string.Empty;
Assert.Contains("\"alpha\"", body);
Assert.Contains("Pirat", body);
Assert.DoesNotContain("freundlicher Assistent", body);
// Unbekannter Anbieter wird abgelehnt
Assert.Equal(HttpStatusCode.BadRequest,
(await mom.PutAsJsonAsync($"/api/chat/channels/{channel.Id}/ai-settings", new SaveChatAiSettingsRequest(Guid.NewGuid(), null, null), Ct)).StatusCode);
}
finally
{
await admin.PutAsJsonAsync("/api/admin/settings", original, Ct);
await ClearProvidersAsync(admin);
}
}
[Fact]
public async Task Question_is_saved_and_answered_by_the_ai_with_a_fixed_prompt_context_and_a_log_without_content()
{
@@ -65,6 +65,49 @@ public sealed class AiAdapterTests
Assert.Equal((11, 4), (response.InputTokens, response.OutputTokens));
}
[Fact]
public async Task Ollama_lists_installed_models_sorted()
{
var (provider, handler) = Make(f => new OllamaProvider(f), HttpStatusCode.OK, """{"models":[{"name":"qwen3:8b"},{"name":"llama3.1:latest"},{"model":"x"}]}""");
var models = await ((IAiProvider)provider).ListModelsAsync(new AiProviderSettings(AiProviderType.Ollama, "http://nas:11434/", string.Empty, null), TestContext.Current.CancellationToken);
Assert.Equal("http://nas:11434/api/tags", Assert.Single(handler.Calls).Request.RequestUri!.ToString());
Assert.Equal(["llama3.1:latest", "qwen3:8b"], models);
}
[Fact]
public async Task Providers_without_a_model_list_return_null()
{
var (provider, _) = Make(f => new ClaudeProvider(f), HttpStatusCode.OK, "{}");
Assert.Null(await ((IAiProvider)provider).ListModelsAsync(new AiProviderSettings(AiProviderType.Claude, "https://api.anthropic.com", string.Empty, "k"), TestContext.Current.CancellationToken));
}
[Fact]
public async Task Google_sends_generate_content_with_key_header_images_and_schema()
{
var (provider, handler) = Make(f => new GoogleProvider(f), HttpStatusCode.OK,
"""{"candidates":[{"content":{"parts":[{"text":"{\"items\":[1]}"}]}}],"usageMetadata":{"promptTokenCount":7,"candidatesTokenCount":3}}""");
var request = new AiRequest("Sei Lehrer.", [AiMessage.User("Was siehst du?", Image), new AiMessage("assistant", "Ein Bild."), AiMessage.User("Danke")], Schema, 400);
var response = await provider.CompleteAsync(new AiProviderSettings(AiProviderType.Google, "https://generativelanguage.googleapis.com/", "gemini-2.5-flash", "KEY"), request, TestContext.Current.CancellationToken);
var (sent, body) = Assert.Single(handler.Calls);
Assert.Equal("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent", sent.RequestUri!.ToString());
Assert.Equal("KEY", Assert.Single(sent.Headers.GetValues("x-goog-api-key")));
using var json = Json(body);
var root = json.RootElement;
Assert.Equal("Sei Lehrer.", root.GetProperty("systemInstruction").GetProperty("parts")[0].GetProperty("text").GetString());
Assert.Equal(400, root.GetProperty("generationConfig").GetProperty("maxOutputTokens").GetInt32());
Assert.Equal("application/json", root.GetProperty("generationConfig").GetProperty("responseMimeType").GetString());
var contents = root.GetProperty("contents");
Assert.Equal(["user", "model", "user"], contents.EnumerateArray().Select(c => c.GetProperty("role").GetString()));
Assert.Equal(Convert.ToBase64String([1, 2, 3]), contents[0].GetProperty("parts")[0].GetProperty("inlineData").GetProperty("data").GetString());
Assert.Equal(1, response.Json!.Value.GetProperty("items").GetArrayLength());
Assert.Equal((7, 3), (response.InputTokens, response.OutputTokens));
}
[Fact]
public async Task Ollama_sends_bearer_only_when_a_key_is_configured_and_plain_text_has_no_json()
{