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,325 @@
using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Budget;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Budget;
/// <summary>
/// K5 aus der Bestandsaufnahme: 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.
/// </summary>
public sealed class BudgetGuardTests : IDisposable
{
private readonly string _directory;
private readonly SqliteUsageRepository _usage;
private readonly DateTime _heute = new(2026, 7, 28, 14, 0, 0, DateTimeKind.Local);
public BudgetGuardTests()
{
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_usage = new SqliteUsageRepository(new SqliteStorage(Path.Combine(_directory, "state.db")));
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
private BudgetGuard Guard() => new(_usage, () => _heute);
private static AgentConfig Agent(decimal costLimit = 0, long tokenLimit = 0, string id = "agent-a") => new()
{
AgentId = id,
Budget = new AgentBudget { DailyCostUsd = costLimit, DailyTokens = tokenLimit }
};
private Task Record(decimal cost, int prompt = 1000, int completion = 100,
string agentId = "agent-a", bool costKnown = true, DateTime? when = null)
=> _usage.RecordAsync(new RunUsage
{
AgentId = agentId,
Model = "test/model",
PromptTokens = prompt,
CompletionTokens = completion,
CostUsd = cost,
CostIsKnown = costKnown,
OccurredAt = when ?? _heute
}, default);
// ═══════════════════════════════════════════════════════════
// Ohne Grenzen
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Ohne_gesetzte_Grenze_darf_immer_gelaufen_werden()
{
await Record(cost: 1_000m);
var status = await Guard().CheckAsync(Agent(), InstanceBudget.Unlimited, default);
status.IsAllowed.ShouldBeTrue();
}
// ═══════════════════════════════════════════════════════════
// Kostengrenze
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Unterhalb_der_Kostengrenze_laeuft_der_Agent_weiter()
{
await Record(cost: 1.50m);
var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default);
status.IsAllowed.ShouldBeTrue();
}
[Fact]
public async Task Bei_erreichter_Kostengrenze_wird_kein_Lauf_mehr_gestartet()
{
await Record(cost: 3m);
await Record(cost: 2m);
var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default);
status.IsAllowed.ShouldBeFalse();
status.Limit.ShouldBe(BudgetLimit.AgentCost);
status.Reason.ShouldContain("5");
}
// ═══════════════════════════════════════════════════════════
// Token-Grenze — greift auch ohne Preisangaben
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Die_Tokengrenze_wirkt_auch_wenn_keine_Preise_vorliegen()
{
// Der eigentliche Zweck der zweiten Grenze: Bei unbekanntem Modell bleibt die
// Kostensumme 0 und eine reine Kostengrenze wäre wirkungslos.
await Record(cost: 0m, prompt: 60_000, completion: 5_000, costKnown: false);
var agent = Agent(costLimit: 10m, tokenLimit: 50_000);
var status = await Guard().CheckAsync(agent, InstanceBudget.Unlimited, default);
status.IsAllowed.ShouldBeFalse();
status.Limit.ShouldBe(BudgetLimit.AgentTokens);
}
[Fact]
public async Task Fehlende_Preise_werden_in_der_Begruendung_erwaehnt()
{
// Sonst wirkte ein niedriger Verbrauch wie ein noch offener Spielraum.
await Record(cost: 0m, prompt: 10_000, completion: 1_000, costKnown: false);
await Record(cost: 5m);
var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default);
status.IsAllowed.ShouldBeFalse();
status.Reason.ShouldContain("keine Preise");
}
// ═══════════════════════════════════════════════════════════
// Instanzgrenze
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Die_Instanzgrenze_zaehlt_alle_Agenten_zusammen()
{
await Record(cost: 3m, agentId: "agent-a");
await Record(cost: 3m, agentId: "agent-b");
await Record(cost: 3m, agentId: "agent-c");
var instance = new InstanceBudget { DailyCostUsd = 8m };
var status = await Guard().CheckAsync(Agent(), instance, default);
status.IsAllowed.ShouldBeFalse();
status.Limit.ShouldBe(BudgetLimit.InstanceCost);
}
[Fact]
public async Task Ein_einzelner_Agent_bleibt_unter_der_Instanzgrenze_erlaubt()
{
await Record(cost: 2m, agentId: "agent-a");
var instance = new InstanceBudget { DailyCostUsd = 10m };
var status = await Guard().CheckAsync(Agent(costLimit: 5m), instance, default);
status.IsAllowed.ShouldBeTrue();
}
// ═══════════════════════════════════════════════════════════
// Tageswechsel
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Der_Verbrauch_des_Vortages_zaehlt_nicht_mit()
{
await Record(cost: 100m, when: _heute.AddDays(-1));
var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default);
status.IsAllowed.ShouldBeTrue();
}
[Fact]
public async Task Am_naechsten_Tag_steht_das_Budget_wieder_zur_Verfuegung()
{
await Record(cost: 10m);
var morgen = new BudgetGuard(_usage, () => _heute.AddDays(1));
var status = await morgen.CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default);
status.IsAllowed.ShouldBeTrue();
}
// ═══════════════════════════════════════════════════════════
// Restbudget
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Das_Restbudget_wird_korrekt_berechnet()
{
await Record(cost: 1.25m);
var remaining = await Guard().GetRemainingCostAsync(Agent(costLimit: 5m), default);
remaining.ShouldBe(3.75m);
}
[Fact]
public async Task Ohne_Kostengrenze_gibt_es_kein_Restbudget()
{
(await Guard().GetRemainingCostAsync(Agent(), default)).ShouldBeNull();
}
[Fact]
public async Task Das_Restbudget_wird_nicht_negativ()
{
await Record(cost: 20m);
(await Guard().GetRemainingCostAsync(Agent(costLimit: 5m), default)).ShouldBe(0m);
}
}
public sealed class UsageRepositoryTests : IDisposable
{
private readonly string _directory;
private readonly SqliteUsageRepository _usage;
private static readonly DateTime Tag = new(2026, 7, 28, 10, 0, 0, DateTimeKind.Local);
public UsageRepositoryTests()
{
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_usage = new SqliteUsageRepository(new SqliteStorage(Path.Combine(_directory, "state.db")));
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_directory, recursive: true); }
catch { }
}
private Task Record(decimal cost, string agentId = "a", DateTime? when = null, bool known = true)
=> _usage.RecordAsync(new RunUsage
{
AgentId = agentId,
Model = "test/model",
PromptTokens = 1000,
CompletionTokens = 200,
CostUsd = cost,
CostIsKnown = known,
Status = "Completed",
OccurredAt = when ?? Tag
}, default);
[Fact]
public async Task Betraege_ueberstehen_den_Rundlauf_ohne_Rundungsfehler()
{
// Als Fliesskommazahl abgelegt wuerden sich Cent-Betraege ueber tausende
// Laeufe zu einem sichtbaren Fehler summieren.
for (var i = 0; i < 1000; i++)
await Record(0.0001m);
var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), "a", default);
daily.CostUsd.ShouldBe(0.1m);
}
[Fact]
public async Task Der_Tagesverbrauch_summiert_Tokens_und_Laeufe()
{
await Record(1m);
await Record(2m);
var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), "a", default);
daily.RunCount.ShouldBe(2);
daily.PromptTokens.ShouldBe(2000);
daily.CompletionTokens.ShouldBe(400);
daily.TotalTokens.ShouldBe(2400);
}
[Fact]
public async Task Ohne_Agentenfilter_zaehlt_die_ganze_Instanz()
{
await Record(1m, agentId: "a");
await Record(2m, agentId: "b");
var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), agentId: "", default);
daily.CostUsd.ShouldBe(3m);
daily.RunCount.ShouldBe(2);
}
[Fact]
public async Task Ein_Lauf_ohne_Preisangabe_macht_den_Tag_unvollstaendig()
{
await Record(1m);
await Record(0m, known: false);
var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), "a", default);
daily.CostIsComplete.ShouldBeFalse();
}
[Fact]
public async Task Ein_Tag_ohne_Laeufe_ist_leer()
{
var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), "a", default);
daily.RunCount.ShouldBe(0);
daily.CostUsd.ShouldBe(0m);
daily.CostIsComplete.ShouldBeTrue();
}
[Fact]
public async Task Alte_Eintraege_lassen_sich_entfernen()
{
await Record(1m, when: Tag.AddDays(-40));
await Record(2m, when: Tag);
var removed = await _usage.PurgeBeforeAsync(DateOnly.FromDateTime(Tag.AddDays(-30)), default);
removed.ShouldBe(1);
(await _usage.GetRecentAsync(10, default)).Count.ShouldBe(1);
}
[Fact]
public async Task Viele_Laeufe_koennen_gleichzeitig_erfasst_werden()
{
// Der alte Weg schrieb TokenUsage.json bei jedem Lauf komplett neu, unter einem
// globalen Lock — genau das skalierte nicht.
await Task.WhenAll(Enumerable.Range(0, 100).Select(i => Record(0.01m, agentId: $"agent-{i % 8}")));
var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), agentId: "", default);
daily.RunCount.ShouldBe(100);
daily.CostUsd.ShouldBe(1.00m);
}
}