K5: Tagesbudget als harte Grenze

Es gab keine Obergrenze. Ein Agent in einer Schleife — etwa durch gegenseitige
send_message-Aufrufe — konnte unbeaufsichtigt Guthaben verbrennen; die
Credits-Anzeige war rein informativ.

Verbrauchsdaten in der Datenbank

Voraussetzung dafuer ist eine belastbare Erfassung. Bisher lag der Verbrauch in
TokenUsage.json: Bei JEDEM Agenten-Lauf wurde die gesamte Datei geladen, ergaenzt
und neu geschrieben, unter einem globalen Lock. Das waechst quadratisch und ist
der eigentliche Engpass bei vielen Agenten — unabhaengig davon, welche Datenbank
darunter liegt.

RunUsage liegt jetzt in einer eigenen Tabelle mit Ortsdatum, damit ein Tagesbudget
der Wahrnehmung des Benutzers folgt und die Abfrage ohne Zeitzonenrechnerei
auskommt. Betraege werden als Text abgelegt und als decimal gelesen: Ueber REAL zu
gehen wuerde bei Cent-Betraegen Rundungsfehler einsammeln, die sich ueber tausende
Laeufe summieren. Ein Test weist das mit 1000 Buchungen zu je 0,0001 USD nach.

BudgetGuard

Zwei Arten von Grenzen, weil sich Kosten nicht immer beziffern lassen: Liefert der
Anbieter fuer ein Modell keine Preise, greift die Kostengrenze nicht — die
Token-Grenze dagegen immer. Wer sich absichern will, setzt beide. Ist die Summe
wegen fehlender Preise unvollstaendig, steht das in der Begruendung; sonst wirkte
ein niedriger Verbrauch wie ein noch offener Spielraum.

Grenzen gibt es je Agent und je Instanz. Geprueft wird vor der ersten Anfrage,
damit ein erschoepftes Budget gar nichts mehr kostet. Neuer Endzustand
BudgetExceeded.

Nebenbei: Der Namespace Usage kollidierte mit der gleichnamigen Modellklasse fuer
Token-Angaben und heisst jetzt Accounting.

357 Tests gruen (209 Core, 148 Tools).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-28 18:16:21 +02:00
co-authored by Claude Opus 4.8
parent 4747835fa1
commit ef3e519f6c
11 changed files with 871 additions and 5 deletions
@@ -0,0 +1,58 @@
namespace ClawdDotNet.Core.Accounting;
/// <summary>Verbrauch eines abgeschlossenen Laufs.</summary>
public sealed record RunUsage
{
public long Id { get; init; }
public string AgentId { get; init; } = "";
public string Model { get; init; } = "";
public int PromptTokens { get; init; }
public int CompletionTokens { get; init; }
/// <summary>Anteil der Eingabe-Tokens aus dem Prompt-Cache — in PromptTokens enthalten.</summary>
public int CachedTokens { get; init; }
public int TotalTokens => PromptTokens + CompletionTokens;
public decimal CostUsd { get; init; }
/// <summary>
/// Ob für das Modell Preise vorlagen. Ohne diese Angabe wäre eine zu niedrige
/// Summe von einer vollständigen nicht zu unterscheiden.
/// </summary>
public bool CostIsKnown { get; init; }
public string Status { get; init; } = "";
public int StepCount { get; init; }
public long DurationMs { get; init; }
public DateTime OccurredAt { get; init; }
}
/// <summary>Verbrauch eines Tages, aggregiert.</summary>
public sealed record DailyUsage(
DateOnly Date,
int PromptTokens,
int CompletionTokens,
decimal CostUsd,
bool CostIsComplete,
int RunCount)
{
public int TotalTokens => PromptTokens + CompletionTokens;
public static DailyUsage Empty(DateOnly date) => new(date, 0, 0, 0m, true, 0);
}
public interface IUsageRepository
{
Task RecordAsync(RunUsage usage, CancellationToken ct);
/// <summary>Tagesverbrauch eines Agenten. <paramref name="agentId"/> leer = ganze Instanz.</summary>
Task<DailyUsage> GetDailyAsync(DateOnly date, string agentId, CancellationToken ct);
Task<IReadOnlyList<RunUsage>> GetRecentAsync(int limit, CancellationToken ct);
/// <summary>Entfernt Einträge älter als das angegebene Datum. Gibt die Anzahl zurück.</summary>
Task<int> PurgeBeforeAsync(DateOnly date, CancellationToken ct);
}