#608: Ein wegen erschoepften Budgets abgelehnter Lauf hinterlaesst jetzt einen Beleg (Status BudgetExceeded) an Aufgabe und Verlauf; die Log-Warnung kommt einmal je Agent, Grenze und Tag. Die Kostenprognose zaehlt Ablehnungen nicht als Lauf und warnt, wenn die geplanten Aufgaben eines Agenten sein Tagesbudget uebersteigen (API + Verbrauchsansicht). #607: Passt schon der erste Treffer nicht in den Abruf, wird er gekuerzt (Anfang und Ende bleiben) statt ganz zu fehlen. remember weist auf gleichnamige Eintraege ohne Schluessel und auf Eintraege nahe der Abrufgrenze hin. Version 0.1.9. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
173 lines
6.7 KiB
C#
173 lines
6.7 KiB
C#
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;
|
|
|
|
/// <summary>Die Kostenprognose (#595): Grundlage je Aufgabe, Hochrechnung, sonstige Laeufe.</summary>
|
|
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<string, IReadOnlyList<RunReceipt>> Belege(params (string Id, RunReceipt[] Liste)[] eintraege)
|
|
=> eintraege.ToDictionary(e => e.Id, e => (IReadOnlyList<RunReceipt>)e.Liste);
|
|
|
|
private static InstanzPrognose Rechne(
|
|
IReadOnlyList<TaskItem> aufgaben,
|
|
Dictionary<string, IReadOnlyList<RunReceipt>>? belege = null,
|
|
IReadOnlyList<RunUsage>? verbrauch = null,
|
|
Func<string, int, int, CostEstimate>? 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);
|
|
}
|
|
}
|