using System.Globalization; using ClawdDotNet.Core.Storage; using Microsoft.Data.Sqlite; namespace ClawdDotNet.Core.Accounting; /// /// 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. /// 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 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> 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(); await using var reader = await cmd.ExecuteReaderAsync(ct); while (await reader.ReadAsync(ct)) results.Add(Read(reader)); return results; } public Task 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; }