Files
ClawdDotNet/src/ClawdDotNet.Core/Accounting/SqliteUsageRepository.cs
T
RichardandClaude Opus 5.5 8a83a67fb4 Budgetende, Stufe 2: laufende Laeufe reservieren ihr Budget (#716)
Der offene Rest aus #682: Jeder Lauf pruefte das Budget einzeln gegen den
bisher erfassten Verbrauch. Laufende Laeufe waren unsichtbar, gleichzeitig
startende passierten die Pruefung gemeinsam und ueberzogen gemeinsam.

- BudgetGuard.ReserveAsync prueft und reserviert unter einer Sperre. Die
  Reservierung ist der Mittelwert der letzten 20 Laeufe des Agenten (Laeufe
  ohne Verbrauch zaehlen nicht mit) und gilt, bis der Verbrauch des Laufs
  erfasst ist.
- Gegen die Grenze zaehlt: verbraucht + in laufenden Laeufen reserviert. Die
  eigene Schaetzung eines Laufs zaehlt bewusst nicht mit; ein einzelner Lauf
  wird weiter nur abgelehnt, wenn das Budget schon verbraucht ist. Sonst fiele
  eine kleine Aufgabe wegen eines grossen Durchschnitts aus.
- Gilt fuer Kosten- und Tokengrenze, je Agent und je Instanz.
- Die Ablehnungsmeldung nennt den reservierten Anteil, sonst wirkte "0,60 von
  1,00 USD verbraucht" als Ablehnungsgrund falsch.
- IUsageRepository.GetRecentForAgentAsync, mit leerer Vorgabe im Interface,
  damit bestehende Attrappen unberuehrt bleiben.

Tests: BudgetReservationTests (8 neu), darunter zehn gleichzeitige Starts, von
denen genau vier passieren. 1.246 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:06:07 +02:00

187 lines
7.7 KiB
C#

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 async Task<IReadOnlyList<RunUsage>> GetRecentForAgentAsync(string agentId, 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
WHERE AgentId = @agent COLLATE NOCASE
ORDER BY OccurredAt DESC
LIMIT @limit
""";
cmd.Parameters.AddWithValue("@agent", agentId);
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;
}