using ClawdDotNet.Core.Accounting; using ClawdDotNet.Core.Api; using ClawdDotNet.Core.Audit; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Tasks; using Shouldly; namespace ClawdDotNet.Core.Tests.Accounting; /// Die Kostenprognose (#595): Grundlage je Aufgabe, Hochrechnung, sonstige Laeufe. public sealed class KostenPrognoseTests { private static readonly DateTime Jetzt = new(2026, 9, 15, 10, 0, 0, DateTimeKind.Utc); private static readonly AgentConfig Nachrichten = new() { AgentId = "nachrichten", Model = "anthropic/claude-haiku-4.5", SystemPrompt = new string('x', 4000) }; private static TaskItem Stuendlich(string id, string assignee = "@new:nachrichten", TaskItemStatus status = TaskItemStatus.Todo) => new() { Id = id, Title = id, Assignee = assignee, Status = status, When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "5 * * * *", TimeZone = "Europe/Berlin" } }; private static RunReceipt Beleg(string taskId, decimal usd, int stundenZurueck = 1) => new() { TaskId = taskId, AgentId = "nachrichten", CostUsd = usd, CostIsKnown = true, OccurredAt = Jetzt.AddHours(-stundenZurueck) }; private static Dictionary> Belege(params (string Id, RunReceipt[] Liste)[] eintraege) => eintraege.ToDictionary(e => e.Id, e => (IReadOnlyList)e.Liste); private static InstanzPrognose Rechne( IReadOnlyList aufgaben, Dictionary>? belege = null, IReadOnlyList? verbrauch = null, Func? schaetzen = null) => KostenPrognose.Berechne(aufgaben, belege ?? [], verbrauch ?? [], [Nachrichten], schaetzen, Jetzt); [Fact] public void Mittelwert_der_letzten_Laeufe_mal_Laeufe_je_Tag() { var p = Rechne([Stuendlich("t-1")], Belege(("t-1", [Beleg("t-1", 0.01m), Beleg("t-1", 0.03m, 2)]))); var zeile = p.Aufgaben.Single(); zeile.Grundlage.ShouldBe(PrognoseGrundlage.Laeufe); zeile.Stichprobe.ShouldBe(2); zeile.KostenJeLauf.ShouldBe(0.02m); zeile.LaeufeProTag.ShouldBe(24); zeile.KostenProTag.ShouldBe(0.48m); p.SummeProMonat.ShouldBe(14.4m); } [Fact] public void Ohne_eigene_Laeufe_gilt_der_Schnitt_des_Agenten() { var p = Rechne([Stuendlich("t-1"), Stuendlich("t-2")], Belege(("t-1", [Beleg("t-1", 0.02m)]))); var neu = p.Aufgaben.Single(z => z.TaskId == "t-2"); neu.Grundlage.ShouldBe(PrognoseGrundlage.Agent); neu.KostenJeLauf.ShouldBe(0.02m); } [Fact] public void Vor_dem_ersten_Lauf_schaetzt_sie_aus_dem_Modellpreis() { var aufgabe = Stuendlich("t-1"); CostEstimate Preis(string modell, int prompt, int completion) => new(prompt / 1_000_000m + completion * 5 / 1_000_000m, true, modell); var p = Rechne([aufgabe], schaetzen: Preis); var zeile = p.Aufgaben.Single(); zeile.Grundlage.ShouldBe(PrognoseGrundlage.Schaetzung); var erwartet = KostenPrognose.GeschaetztePromptTokens(aufgabe, Nachrichten) / 1_000_000m + KostenPrognose.AnnahmeSchritte * KostenPrognose.AnnahmeAntwortTokensJeSchritt * 5 / 1_000_000m; zeile.KostenJeLauf.ShouldBe(Math.Round(erwartet, 4)); } [Fact] public void Ohne_Preis_und_Laeufe_bleibt_sie_ehrlich_unbekannt() { var p = Rechne([Stuendlich("t-1")], schaetzen: (m, _, _) => new CostEstimate(0m, false, m)); var zeile = p.Aufgaben.Single(); zeile.Grundlage.ShouldBe(PrognoseGrundlage.Unbekannt); zeile.KostenJeLauf.ShouldBeNull(); zeile.KostenProTag.ShouldBe(0m); p.OhneKostenbasis.ShouldBe(1); } [Fact] public void Pausierte_und_menschliche_Aufgaben_kosten_nichts() => Rechne([Stuendlich("t-p", status: TaskItemStatus.Backlog), Stuendlich("t-h", "@human")]) .Aufgaben.ShouldBeEmpty(); [Fact] public void Sonstige_Laeufe_sind_das_Tagesmittel_ausserhalb_der_Aufgaben() { var p = Rechne( [Stuendlich("t-1")], Belege(("t-1", [Beleg("t-1", 0.14m)])), [new RunUsage { AgentId = "nachrichten", CostUsd = 0.70m, CostIsKnown = true, OccurredAt = Jetzt.AddDays(-1) }]); p.SonstigeProTag.ShouldBe(0.08m); // (0,70 - 0,14) / 7 p.AufgabenProTag.ShouldBe(3.36m); // 0,14 * 24 p.SummeProTag.ShouldBe(3.44m); } [Fact] public void Budgetablehnungen_druecken_den_Schnitt_nicht() { // #608: Eine Ablehnung steht als Beleg ueber 0 USD im Verlauf - sie ist kein Lauf. var abgelehnt = Beleg("t-1", 0m) with { Status = "BudgetExceeded" }; var zeile = Rechne([Stuendlich("t-1")], Belege(("t-1", [Beleg("t-1", 0.02m, 2), abgelehnt]))) .Aufgaben.Single(); zeile.KostenJeLauf.ShouldBe(0.02m); zeile.Stichprobe.ShouldBe(1); } [Fact] public void Warnt_wenn_die_Aufgaben_das_Tagesbudget_des_Agenten_uebersteigen() { var knapp = new AgentConfig { AgentId = "nachrichten", Model = Nachrichten.Model, SystemPrompt = "x" }; knapp.Budget.DailyCostUsd = 0.40m; var p = KostenPrognose.Berechne( [Stuendlich("t-1")], Belege(("t-1", [Beleg("t-1", 0.02m)])), [], [knapp], null, Jetzt); var warnung = p.Budgetwarnungen.ShouldHaveSingleItem(); warnung.AgentId.ShouldBe("nachrichten"); warnung.KostenProTag.ShouldBe(0.48m); warnung.BudgetProTag.ShouldBe(0.40m); } [Theory] [InlineData(0)] // keine Grenze gesetzt [InlineData(1.0)] // Budget reicht public void Ohne_Grenze_oder_mit_Luft_keine_Budgetwarnung(double budget) { var agent = new AgentConfig { AgentId = "nachrichten", Model = Nachrichten.Model, SystemPrompt = "x" }; agent.Budget.DailyCostUsd = (decimal)budget; KostenPrognose.Berechne([Stuendlich("t-1")], Belege(("t-1", [Beleg("t-1", 0.02m)])), [], [agent], null, Jetzt) .Budgetwarnungen.ShouldBeEmpty(); } [Fact] public void Werkzeugjob_zaehlt_nur_tatsaechliche_Weckungen() { var job = new TaskItem { Id = "tj", Title = "Telegram", Assignee = "@nachrichten", Type = TaskItemType.ToolJob, Status = TaskItemStatus.Todo, When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "* * * * *", TimeZone = "UTC" } }; var p = Rechne([job], Belege(("tj", [Beleg("tj", 0.07m), Beleg("tj", 0.07m, 30)]))); var zeile = p.Aufgaben.Single(); zeile.KostenProTag.ShouldBe(0.02m, "zwei Weckungen in sieben Tagen, nicht 1.440 Ticks je Tag"); zeile.LaeufeProTag.ShouldBe(0.29); } }