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,163 @@
using System.Globalization;
using ClawdDotNet.Core.Storage;
using Microsoft.Data.Sqlite;
namespace ClawdDotNet.Core.Accounting;
/// <summary>
/// Ablage der Verbrauchsdaten in der Instanz-Datenbank.
///
/// Vorher lag das in TokenUsage.json: Bei JEDEM Agenten-Lauf wurde die gesamte Datei
/// geladen, ergänzt und neu geschrieben — unter einem globalen Lock. Das wächst
/// quadratisch und ist der eigentliche Engpass bei vielen Agenten, unabhängig davon,
/// welche Datenbank darunter liegt.
///
/// Beträge werden als Text abgelegt und als decimal gelesen. SQLite kennt keinen
/// exakten Dezimaltyp; über REAL zu gehen würde bei Cent-Betraegen Rundungsfehler
/// einsammeln, die sich über tausende Läufe summieren.
/// </summary>
public sealed class SqliteUsageRepository : IUsageRepository
{
private readonly SqliteStorage _storage;
public SqliteUsageRepository(SqliteStorage storage) => _storage = storage;
public Task RecordAsync(RunUsage usage, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
var occurredAt = usage.OccurredAt == default ? DateTime.Now : usage.OccurredAt;
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO RunUsage
(AgentId, Model, PromptTokens, CompletionTokens, CachedTokens,
CostUsd, CostIsKnown, Status, StepCount, DurationMs, OccurredAt, UsageDate)
VALUES
(@agentId, @model, @prompt, @completion, @cached,
@cost, @costKnown, @status, @steps, @duration, @occurredAt, @usageDate)
""";
cmd.Parameters.AddWithValue("@agentId", usage.AgentId);
cmd.Parameters.AddWithValue("@model", usage.Model);
cmd.Parameters.AddWithValue("@prompt", usage.PromptTokens);
cmd.Parameters.AddWithValue("@completion", usage.CompletionTokens);
cmd.Parameters.AddWithValue("@cached", usage.CachedTokens);
cmd.Parameters.AddWithValue("@cost", FormatDecimal(usage.CostUsd));
cmd.Parameters.AddWithValue("@costKnown", usage.CostIsKnown ? 1 : 0);
cmd.Parameters.AddWithValue("@status", usage.Status);
cmd.Parameters.AddWithValue("@steps", usage.StepCount);
cmd.Parameters.AddWithValue("@duration", usage.DurationMs);
cmd.Parameters.AddWithValue("@occurredAt", occurredAt.ToString("O"));
cmd.Parameters.AddWithValue("@usageDate", FormatDate(DateOnly.FromDateTime(occurredAt)));
await cmd.ExecuteNonQueryAsync(ct);
}, ct);
public async Task<DailyUsage> GetDailyAsync(DateOnly date, string agentId, CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
var filterAgent = !string.IsNullOrWhiteSpace(agentId);
cmd.CommandText = $"""
SELECT COALESCE(SUM(PromptTokens), 0),
COALESCE(SUM(CompletionTokens), 0),
COUNT(*),
COALESCE(SUM(CASE WHEN CostIsKnown = 0 THEN 1 ELSE 0 END), 0)
FROM RunUsage
WHERE UsageDate = @date {(filterAgent ? "AND AgentId = @agentId" : "")}
""";
cmd.Parameters.AddWithValue("@date", FormatDate(date));
if (filterAgent)
cmd.Parameters.AddWithValue("@agentId", agentId);
int prompt = 0, completion = 0, runs = 0, unpriced = 0;
await using (var reader = await cmd.ExecuteReaderAsync(ct))
{
if (await reader.ReadAsync(ct))
{
prompt = reader.GetInt32(0);
completion = reader.GetInt32(1);
runs = reader.GetInt32(2);
unpriced = reader.GetInt32(3);
}
}
// Beträge einzeln summieren, weil sie als Text abgelegt sind.
using var costCmd = conn.CreateCommand();
costCmd.CommandText = $"""
SELECT CostUsd FROM RunUsage
WHERE UsageDate = @date {(filterAgent ? "AND AgentId = @agentId" : "")}
""";
costCmd.Parameters.AddWithValue("@date", FormatDate(date));
if (filterAgent)
costCmd.Parameters.AddWithValue("@agentId", agentId);
decimal cost = 0m;
await using (var reader = await costCmd.ExecuteReaderAsync(ct))
{
while (await reader.ReadAsync(ct))
cost += ParseDecimal(reader.GetString(0));
}
return new DailyUsage(date, prompt, completion, cost, unpriced == 0, runs);
}
public async Task<IReadOnlyList<RunUsage>> GetRecentAsync(int limit, CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT Id, AgentId, Model, PromptTokens, CompletionTokens, CachedTokens,
CostUsd, CostIsKnown, Status, StepCount, DurationMs, OccurredAt
FROM RunUsage
ORDER BY OccurredAt DESC
LIMIT @limit
""";
cmd.Parameters.AddWithValue("@limit", Math.Clamp(limit, 1, 1000));
var results = new List<RunUsage>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
results.Add(Read(reader));
return results;
}
public Task<int> PurgeBeforeAsync(DateOnly date, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM RunUsage WHERE UsageDate < @date";
cmd.Parameters.AddWithValue("@date", FormatDate(date));
return await cmd.ExecuteNonQueryAsync(ct);
}, ct);
// ─── Hilfsfunktionen ───
private static RunUsage Read(SqliteDataReader reader) => new()
{
Id = reader.GetInt64(0),
AgentId = reader.GetString(1),
Model = reader.GetString(2),
PromptTokens = reader.GetInt32(3),
CompletionTokens = reader.GetInt32(4),
CachedTokens = reader.GetInt32(5),
CostUsd = ParseDecimal(reader.GetString(6)),
CostIsKnown = reader.GetInt32(7) != 0,
Status = reader.GetString(8),
StepCount = reader.GetInt32(9),
DurationMs = reader.GetInt64(10),
OccurredAt = DateTime.TryParse(reader.GetString(11), null,
DateTimeStyles.RoundtripKind, out var dt) ? dt : DateTime.MinValue
};
private static string FormatDate(DateOnly date) => date.ToString("yyyy-MM-dd");
private static string FormatDecimal(decimal value)
=> value.ToString(CultureInfo.InvariantCulture);
private static decimal ParseDecimal(string value)
=> decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) ? d : 0m;
}