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:
co-authored by
Claude Opus 4.8
parent
4747835fa1
commit
ef3e519f6c
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user