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;
}