diff --git a/Models/AgentSettingsViewModel.cs b/Models/AgentSettingsViewModel.cs index 05eedfb..c820ed5 100644 --- a/Models/AgentSettingsViewModel.cs +++ b/Models/AgentSettingsViewModel.cs @@ -152,6 +152,28 @@ public sealed class AgentSettingsViewModel set => _config.PromptCaching = string.IsNullOrWhiteSpace(value) ? "auto" : value; } + [Category("5 - Kosten")] + [DisplayName("Tagesbudget (USD)")] + [Description("Hartes Tageslimit für diesen Agenten in US-Dollar. 0 = kein Limit. " + + "Bei Überschreitung wird kein Lauf mehr gestartet. " + + "Greift nur, wenn für das Modell Preise vorliegen — sonst zusätzlich " + + "das Token-Tageslimit setzen.")] + public decimal DailyBudgetUsd + { + get => _config.Budget.DailyCostUsd; + set => _config.Budget.DailyCostUsd = Math.Max(0, value); + } + + [Category("5 - Kosten")] + [DisplayName("Token-Tageslimit")] + [Description("Hartes Tageslimit für diesen Agenten in Tokens. 0 = kein Limit. " + + "Wirkt auch dann, wenn für das Modell keine Preise vorliegen.")] + public long DailyTokenLimit + { + get => _config.Budget.DailyTokens; + set => _config.Budget.DailyTokens = Math.Max(0, value); + } + // ──────────────── Tools (Read-Only) ──────────────── [Category("4 - Tools")] diff --git a/Program.cs b/Program.cs index 14281c4..9551f0b 100644 --- a/Program.cs +++ b/Program.cs @@ -8,6 +8,7 @@ using ClawdDotNet.Core.Tools; using ClawdDotNet.Core.State; using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Memory; +using ClawdDotNet.Core.Accounting; using ClawdDotNet.Services; using ClawdDotNet.Tools.FileRW; using ClawdDotNet.Tools.Telegram; @@ -158,9 +159,22 @@ internal static class Program var storage = new SqliteStorage(Path.Combine(instancePath, "state.db")); var stateStore = new SqliteStateStore(storage); var memoryRepository = new SqliteMemoryRepository(storage); + var usageRepository = new SqliteUsageRepository(storage); + + // Preise fürs Budget: Ohne sie greift nur die Token-Grenze. + var pricingCatalog = new ModelPricingCatalog(); + _ = Task.Run(async () => + { + try { pricingCatalog.Load(await openRouterClient.GetAvailableModelsAsync()); } + catch { /* Ohne Preise bleibt die Kostengrenze wirkungslos, die Token-Grenze nicht. */ } + }); agentEngine = new AgentEngine( - openRouterClient, toolRegistry, permissionGate, stateStore, loggerFactory, memoryRepository); + openRouterClient, toolRegistry, permissionGate, stateStore, loggerFactory, + memoryRepository, usageRepository, pricingCatalog) + { + InstanceBudget = instanceConfig.Budget + }; agentEngine.SetAgentConfigProvider( () => instanceConfig.Agents, instanceConfig.InstanceId, diff --git a/src/ClawdDotNet.Core/Accounting/SqliteUsageRepository.cs b/src/ClawdDotNet.Core/Accounting/SqliteUsageRepository.cs new file mode 100644 index 0000000..a63364d --- /dev/null +++ b/src/ClawdDotNet.Core/Accounting/SqliteUsageRepository.cs @@ -0,0 +1,163 @@ +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; +} diff --git a/src/ClawdDotNet.Core/Accounting/UsageModels.cs b/src/ClawdDotNet.Core/Accounting/UsageModels.cs new file mode 100644 index 0000000..b60a975 --- /dev/null +++ b/src/ClawdDotNet.Core/Accounting/UsageModels.cs @@ -0,0 +1,58 @@ +namespace ClawdDotNet.Core.Accounting; + +/// Verbrauch eines abgeschlossenen Laufs. +public sealed record RunUsage +{ + public long Id { get; init; } + public string AgentId { get; init; } = ""; + public string Model { get; init; } = ""; + + public int PromptTokens { get; init; } + public int CompletionTokens { get; init; } + + /// Anteil der Eingabe-Tokens aus dem Prompt-Cache — in PromptTokens enthalten. + public int CachedTokens { get; init; } + + public int TotalTokens => PromptTokens + CompletionTokens; + + public decimal CostUsd { get; init; } + + /// + /// Ob für das Modell Preise vorlagen. Ohne diese Angabe wäre eine zu niedrige + /// Summe von einer vollständigen nicht zu unterscheiden. + /// + public bool CostIsKnown { get; init; } + + public string Status { get; init; } = ""; + public int StepCount { get; init; } + public long DurationMs { get; init; } + + public DateTime OccurredAt { get; init; } +} + +/// Verbrauch eines Tages, aggregiert. +public sealed record DailyUsage( + DateOnly Date, + int PromptTokens, + int CompletionTokens, + decimal CostUsd, + bool CostIsComplete, + int RunCount) +{ + public int TotalTokens => PromptTokens + CompletionTokens; + + public static DailyUsage Empty(DateOnly date) => new(date, 0, 0, 0m, true, 0); +} + +public interface IUsageRepository +{ + Task RecordAsync(RunUsage usage, CancellationToken ct); + + /// Tagesverbrauch eines Agenten. leer = ganze Instanz. + Task GetDailyAsync(DateOnly date, string agentId, CancellationToken ct); + + Task> GetRecentAsync(int limit, CancellationToken ct); + + /// Entfernt Einträge älter als das angegebene Datum. Gibt die Anzahl zurück. + Task PurgeBeforeAsync(DateOnly date, CancellationToken ct); +} diff --git a/src/ClawdDotNet.Core/Budget/BudgetGuard.cs b/src/ClawdDotNet.Core/Budget/BudgetGuard.cs new file mode 100644 index 0000000..2ecac6b --- /dev/null +++ b/src/ClawdDotNet.Core/Budget/BudgetGuard.cs @@ -0,0 +1,121 @@ +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Accounting; + +namespace ClawdDotNet.Core.Budget; + +/// Welche Grenze überschritten wurde. +public enum BudgetLimit +{ + None, + AgentCost, + AgentTokens, + InstanceCost, + InstanceTokens +} + +public sealed record BudgetStatus(bool IsAllowed, BudgetLimit Limit, string? Reason) +{ + public static BudgetStatus Allowed { get; } = new(true, BudgetLimit.None, null); +} + +/// +/// Wacht über das Tagesbudget. +/// +/// Hintergrund (K5): 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. +/// +/// Zwei Arten von Grenzen, weil sich Kosten nicht immer beziffern lassen: Liefert der +/// Anbieter für ein Modell keine Preise, greift die Kostengrenze nicht — die +/// Token-Grenze dagegen immer. Wer sich absichern will, setzt beide. +/// +public sealed class BudgetGuard +{ + private readonly IUsageRepository _usage; + private readonly Func _now; + + public BudgetGuard(IUsageRepository usage, Func? now = null) + { + _usage = usage; + _now = now ?? (() => DateTime.Now); + } + + /// + /// Prüft, ob der Agent heute noch laufen darf. + /// + public async Task CheckAsync( + AgentConfig agent, InstanceBudget instanceBudget, CancellationToken ct) + { + var today = DateOnly.FromDateTime(_now()); + + // Agentengrenze + var agentBudget = agent.Budget; + if (agentBudget.HasAnyLimit) + { + var used = await _usage.GetDailyAsync(today, agent.AgentId, ct); + + if (Exceeds(agentBudget.DailyCostUsd, used.CostUsd)) + { + return new BudgetStatus(false, BudgetLimit.AgentCost, + $"Tagesbudget des Agenten erschöpft: {used.CostUsd:F4} von maximal " + + $"{agentBudget.DailyCostUsd:F2} USD verbraucht." + + UnpricedHint(used)); + } + + if (Exceeds(agentBudget.DailyTokens, used.TotalTokens)) + { + return new BudgetStatus(false, BudgetLimit.AgentTokens, + $"Token-Tageslimit des Agenten erschöpft: {used.TotalTokens:N0} von maximal " + + $"{agentBudget.DailyTokens:N0}."); + } + } + + // Instanzgrenze — gilt für alle Agenten zusammen + if (instanceBudget.HasAnyLimit) + { + var used = await _usage.GetDailyAsync(today, agentId: "", ct); + + if (Exceeds(instanceBudget.DailyCostUsd, used.CostUsd)) + { + return new BudgetStatus(false, BudgetLimit.InstanceCost, + $"Tagesbudget der Instanz erschöpft: {used.CostUsd:F4} von maximal " + + $"{instanceBudget.DailyCostUsd:F2} USD verbraucht." + + UnpricedHint(used)); + } + + if (Exceeds(instanceBudget.DailyTokens, used.TotalTokens)) + { + return new BudgetStatus(false, BudgetLimit.InstanceTokens, + $"Token-Tageslimit der Instanz erschöpft: {used.TotalTokens:N0} von maximal " + + $"{instanceBudget.DailyTokens:N0}."); + } + } + + return BudgetStatus.Allowed; + } + + /// Verbleibendes Kostenbudget des Agenten für heute; null bei fehlender Grenze. + public async Task GetRemainingCostAsync(AgentConfig agent, CancellationToken ct) + { + if (agent.Budget.DailyCostUsd <= 0) + return null; + + var used = await _usage.GetDailyAsync(DateOnly.FromDateTime(_now()), agent.AgentId, ct); + return Math.Max(0m, agent.Budget.DailyCostUsd - used.CostUsd); + } + + /// 0 oder kleiner bedeutet: keine Grenze gesetzt. + private static bool Exceeds(decimal limit, decimal used) => limit > 0 && used >= limit; + + private static bool Exceeds(long limit, long used) => limit > 0 && used >= limit; + + /// + /// Ohne Preisangaben ist die verbrauchte Summe unvollständig — sonst wirkte ein + /// niedriger Wert wie ein noch offener Spielraum. + /// + private static string UnpricedHint(DailyUsage used) + => used.CostIsComplete + ? "" + : " Achtung: Für mindestens ein Modell lagen keine Preise vor, der tatsächliche " + + "Verbrauch liegt höher."; +} diff --git a/src/ClawdDotNet.Core/Config/AgentConfig.cs b/src/ClawdDotNet.Core/Config/AgentConfig.cs index 0365fd9..ef22d28 100644 --- a/src/ClawdDotNet.Core/Config/AgentConfig.cs +++ b/src/ClawdDotNet.Core/Config/AgentConfig.cs @@ -106,6 +106,42 @@ public sealed class AgentConfig /// [JsonPropertyName("maxToolResultChars")] public int MaxToolResultChars { get; set; } = 16_000; + + [JsonPropertyName("budget")] + public AgentBudget Budget { get; set; } = new(); +} + +/// +/// Tagesgrenzen eines Agenten. 0 bedeutet jeweils: keine Grenze. +/// +/// Zwei Arten, weil sich Kosten nicht immer beziffern lassen: Liefert der Anbieter für +/// ein Modell keine Preise, greift die Kostengrenze nicht — die Token-Grenze immer. +/// +public sealed class AgentBudget +{ + [JsonPropertyName("dailyCostUsd")] + public decimal DailyCostUsd { get; set; } + + [JsonPropertyName("dailyTokens")] + public long DailyTokens { get; set; } + + [JsonIgnore] + public bool HasAnyLimit => DailyCostUsd > 0 || DailyTokens > 0; +} + +/// Tagesgrenzen über alle Agenten einer Instanz hinweg. +public sealed class InstanceBudget +{ + [JsonPropertyName("dailyCostUsd")] + public decimal DailyCostUsd { get; set; } + + [JsonPropertyName("dailyTokens")] + public long DailyTokens { get; set; } + + [JsonIgnore] + public bool HasAnyLimit => DailyCostUsd > 0 || DailyTokens > 0; + + public static InstanceBudget Unlimited { get; } = new(); } public sealed class SchedulerConfig diff --git a/src/ClawdDotNet.Core/Config/InstanceConfig.cs b/src/ClawdDotNet.Core/Config/InstanceConfig.cs index fa1191b..47ef240 100644 --- a/src/ClawdDotNet.Core/Config/InstanceConfig.cs +++ b/src/ClawdDotNet.Core/Config/InstanceConfig.cs @@ -25,6 +25,10 @@ public sealed class InstanceConfig [JsonPropertyName("telegramClient")] public TelegramClientConfig? TelegramClient { get; set; } + /// Tagesgrenzen über alle Agenten der Instanz hinweg. 0 = keine Grenze. + [JsonPropertyName("budget")] + public InstanceBudget Budget { get; set; } = new(); + [JsonPropertyName("agents")] public List Agents { get; set; } = new(); diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs index c5eaefa..74926b4 100644 --- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs +++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs @@ -2,9 +2,11 @@ using System.Diagnostics; using System.Text.Json; using ClawdDotNet.Core.Api; using ClawdDotNet.Core.Api.Models; +using ClawdDotNet.Core.Budget; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Memory; using ClawdDotNet.Core.Security; +using ClawdDotNet.Core.Accounting; using ClawdDotNet.Core.Tools; using ClawdDotNet.Core.State; using Microsoft.Extensions.Logging; @@ -18,7 +20,13 @@ public sealed class AgentEngine : IAgentMessageRouter private readonly PermissionGate _permissionGate; private readonly IStateStore _stateStore; private readonly IMemoryRepository? _memoryRepository; + private readonly IUsageRepository? _usageRepository; + private readonly BudgetGuard? _budgetGuard; + private readonly ModelPricingCatalog? _pricing; private readonly ILoggerFactory _loggerFactory; + + /// Tagesgrenzen der Instanz. Wird vom Host gesetzt. + public InstanceBudget InstanceBudget { get; set; } = InstanceBudget.Unlimited; private readonly ContextCompactor _compactor; private readonly Dictionary> _chatHistories = new(); @@ -56,7 +64,9 @@ public sealed class AgentEngine : IAgentMessageRouter PermissionGate permissionGate, IStateStore stateStore, ILoggerFactory loggerFactory, - IMemoryRepository? memoryRepository = null) + IMemoryRepository? memoryRepository = null, + IUsageRepository? usageRepository = null, + ModelPricingCatalog? pricing = null) { _client = client; _toolRegistry = toolRegistry; @@ -64,6 +74,9 @@ public sealed class AgentEngine : IAgentMessageRouter _stateStore = stateStore; _loggerFactory = loggerFactory; _memoryRepository = memoryRepository; + _usageRepository = usageRepository; + _pricing = pricing; + _budgetGuard = usageRepository is null ? null : new BudgetGuard(usageRepository); _compactor = new ContextCompactor(client, loggerFactory); } @@ -87,6 +100,21 @@ public sealed class AgentEngine : IAgentMessageRouter string userMessage, string instanceId, CancellationToken externalCt) + { + // Vor der ersten Anfrage prüfen — ein erschöpftes Budget soll gar nichts kosten. + if (await CheckBudgetAsync(agentConfig, externalCt) is { } denied) + return denied; + + var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt); + await RecordUsageAsync(agentConfig, result); + return result; + } + + private async Task RunCoreAsync( + AgentConfig agentConfig, + string userMessage, + string instanceId, + CancellationToken externalCt) { var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.{agentConfig.AgentId}"); var loopGuard = new LoopGuard(agentConfig.LoopGuard); @@ -255,6 +283,9 @@ public sealed class AgentEngine : IAgentMessageRouter CancellationToken externalCt, string? source = null) { + if (await CheckBudgetAsync(agentConfig, externalCt) is { } denied) + return denied; + // Abbrechbar sein, schon bevor der Lauf an der Reihe ist — sonst hängt eine // wartende Nachricht auch dann noch, wenn der Benutzer längst abgebrochen hat. using var runCts = CancellationTokenSource.CreateLinkedTokenSource(externalCt); @@ -275,7 +306,9 @@ public sealed class AgentEngine : IAgentMessageRouter try { - return await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source); + var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source); + await RecordUsageAsync(agentConfig, result); + return result; } finally { @@ -840,6 +873,68 @@ public sealed class AgentEngine : IAgentMessageRouter } } + // ─── Budget und Verbrauchserfassung ─── + + /// + /// Prüft das Tagesbudget. Gibt ein Ergebnis zurück, wenn der Lauf nicht stattfinden + /// darf — sonst null. + /// + private async Task CheckBudgetAsync(AgentConfig agentConfig, CancellationToken ct) + { + if (_budgetGuard is null) + return null; + + var status = await _budgetGuard.CheckAsync(agentConfig, InstanceBudget, ct); + if (status.IsAllowed) + return null; + + var logger = _loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Budget"); + logger.LogWarning("Lauf abgelehnt für {AgentId}: {Reason}", agentConfig.AgentId, status.Reason); + + var result = new AgentRunResult( + agentConfig.AgentId, AgentRunStatus.BudgetExceeded, + $"[Budget erschöpft] {status.Reason}", 0, 0, TimeSpan.Zero); + + OnRunCompleted?.Invoke(agentConfig.Model, result); + return result; + } + + /// + /// Schreibt den Verbrauch eines Laufs fort. Fehler hierbei dürfen den Lauf nicht + /// nachträglich scheitern lassen — die eigentliche Arbeit ist bereits getan. + /// + private async Task RecordUsageAsync(AgentConfig agentConfig, AgentRunResult result) + { + if (_usageRepository is null) + return; + + try + { + var estimate = _pricing?.Estimate(agentConfig.Model, result.PromptTokens, result.CompletionTokens); + + await _usageRepository.RecordAsync(new RunUsage + { + AgentId = agentConfig.AgentId, + Model = agentConfig.Model, + PromptTokens = result.PromptTokens, + CompletionTokens = result.CompletionTokens, + CachedTokens = result.CachedTokens, + CostUsd = estimate?.Usd ?? 0m, + CostIsKnown = estimate?.IsKnown ?? false, + Status = result.Status.ToString(), + StepCount = result.StepCount, + DurationMs = (long)result.Duration.TotalMilliseconds, + OccurredAt = DateTime.Now + }, CancellationToken.None); + } + catch (Exception ex) + { + _loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Usage") + .LogWarning(ex, "Verbrauch konnte nicht festgehalten werden für {AgentId}", + agentConfig.AgentId); + } + } + /// Sammelt die Token-Zahlen über alle Schritte eines Runs. private sealed class TokenTally { @@ -848,7 +943,8 @@ public sealed class AgentEngine : IAgentMessageRouter public int Completion { get; private set; } public int Cached { get; private set; } - public void Add(Usage usage) + // Voll qualifiziert: "Usage" ist auch ein Namespace (ClawdDotNet.Core.Accounting). + public void Add(Api.Models.Usage usage) { Total += usage.TotalTokens; Prompt += usage.PromptTokens; diff --git a/src/ClawdDotNet.Core/Engine/AgentRunResult.cs b/src/ClawdDotNet.Core/Engine/AgentRunResult.cs index 42978bb..fdae986 100644 --- a/src/ClawdDotNet.Core/Engine/AgentRunResult.cs +++ b/src/ClawdDotNet.Core/Engine/AgentRunResult.cs @@ -28,5 +28,8 @@ public enum AgentRunStatus Completed, Failed, Cancelled, - LoopLimitExceeded + LoopLimitExceeded, + + /// Der Lauf fand nicht statt, weil das Tagesbudget erschöpft ist. + BudgetExceeded } diff --git a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs index 22209e5..b0bbacc 100644 --- a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs +++ b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs @@ -128,6 +128,30 @@ public sealed class SqliteStorage CREATE UNIQUE INDEX IF NOT EXISTS UX_Memories_Key ON Memories (Scope, OwnerId, MemoryKey) WHERE MemoryKey IS NOT NULL; + + CREATE TABLE IF NOT EXISTS RunUsage ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + AgentId TEXT NOT NULL, + Model TEXT NOT NULL, + PromptTokens INTEGER NOT NULL, + CompletionTokens INTEGER NOT NULL, + CachedTokens INTEGER NOT NULL DEFAULT 0, + CostUsd TEXT NOT NULL DEFAULT '0', + CostIsKnown INTEGER NOT NULL DEFAULT 0, + Status TEXT NOT NULL DEFAULT '', + StepCount INTEGER NOT NULL DEFAULT 0, + DurationMs INTEGER NOT NULL DEFAULT 0, + OccurredAt TEXT NOT NULL, + -- Ortsdatum, damit ein Tagesbudget der Wahrnehmung des Benutzers folgt + -- und die Abfrage ohne Zeitzonenrechnerei auskommt. + UsageDate TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS IX_RunUsage_Day + ON RunUsage (UsageDate, AgentId); + + CREATE INDEX IF NOT EXISTS IX_RunUsage_Recent + ON RunUsage (OccurredAt DESC); """; cmd.ExecuteNonQuery(); diff --git a/tests/ClawdDotNet.Core.Tests/Budget/BudgetGuardTests.cs b/tests/ClawdDotNet.Core.Tests/Budget/BudgetGuardTests.cs new file mode 100644 index 0000000..3aebd93 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Budget/BudgetGuardTests.cs @@ -0,0 +1,325 @@ +using ClawdDotNet.Core.Accounting; +using ClawdDotNet.Core.Budget; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Storage; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Budget; + +/// +/// K5 aus der Bestandsaufnahme: 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. +/// +public sealed class BudgetGuardTests : IDisposable +{ + private readonly string _directory; + private readonly SqliteUsageRepository _usage; + private readonly DateTime _heute = new(2026, 7, 28, 14, 0, 0, DateTimeKind.Local); + + public BudgetGuardTests() + { + _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + _usage = new SqliteUsageRepository(new SqliteStorage(Path.Combine(_directory, "state.db"))); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + try { Directory.Delete(_directory, recursive: true); } + catch { /* Aufräumen ist Nebensache */ } + } + + private BudgetGuard Guard() => new(_usage, () => _heute); + + private static AgentConfig Agent(decimal costLimit = 0, long tokenLimit = 0, string id = "agent-a") => new() + { + AgentId = id, + Budget = new AgentBudget { DailyCostUsd = costLimit, DailyTokens = tokenLimit } + }; + + private Task Record(decimal cost, int prompt = 1000, int completion = 100, + string agentId = "agent-a", bool costKnown = true, DateTime? when = null) + => _usage.RecordAsync(new RunUsage + { + AgentId = agentId, + Model = "test/model", + PromptTokens = prompt, + CompletionTokens = completion, + CostUsd = cost, + CostIsKnown = costKnown, + OccurredAt = when ?? _heute + }, default); + + // ═══════════════════════════════════════════════════════════ + // Ohne Grenzen + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Ohne_gesetzte_Grenze_darf_immer_gelaufen_werden() + { + await Record(cost: 1_000m); + + var status = await Guard().CheckAsync(Agent(), InstanceBudget.Unlimited, default); + + status.IsAllowed.ShouldBeTrue(); + } + + // ═══════════════════════════════════════════════════════════ + // Kostengrenze + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Unterhalb_der_Kostengrenze_laeuft_der_Agent_weiter() + { + await Record(cost: 1.50m); + + var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default); + + status.IsAllowed.ShouldBeTrue(); + } + + [Fact] + public async Task Bei_erreichter_Kostengrenze_wird_kein_Lauf_mehr_gestartet() + { + await Record(cost: 3m); + await Record(cost: 2m); + + var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default); + + status.IsAllowed.ShouldBeFalse(); + status.Limit.ShouldBe(BudgetLimit.AgentCost); + status.Reason.ShouldContain("5"); + } + + // ═══════════════════════════════════════════════════════════ + // Token-Grenze — greift auch ohne Preisangaben + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Die_Tokengrenze_wirkt_auch_wenn_keine_Preise_vorliegen() + { + // Der eigentliche Zweck der zweiten Grenze: Bei unbekanntem Modell bleibt die + // Kostensumme 0 und eine reine Kostengrenze wäre wirkungslos. + await Record(cost: 0m, prompt: 60_000, completion: 5_000, costKnown: false); + + var agent = Agent(costLimit: 10m, tokenLimit: 50_000); + var status = await Guard().CheckAsync(agent, InstanceBudget.Unlimited, default); + + status.IsAllowed.ShouldBeFalse(); + status.Limit.ShouldBe(BudgetLimit.AgentTokens); + } + + [Fact] + public async Task Fehlende_Preise_werden_in_der_Begruendung_erwaehnt() + { + // Sonst wirkte ein niedriger Verbrauch wie ein noch offener Spielraum. + await Record(cost: 0m, prompt: 10_000, completion: 1_000, costKnown: false); + await Record(cost: 5m); + + var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default); + + status.IsAllowed.ShouldBeFalse(); + status.Reason.ShouldContain("keine Preise"); + } + + // ═══════════════════════════════════════════════════════════ + // Instanzgrenze + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Die_Instanzgrenze_zaehlt_alle_Agenten_zusammen() + { + await Record(cost: 3m, agentId: "agent-a"); + await Record(cost: 3m, agentId: "agent-b"); + await Record(cost: 3m, agentId: "agent-c"); + + var instance = new InstanceBudget { DailyCostUsd = 8m }; + var status = await Guard().CheckAsync(Agent(), instance, default); + + status.IsAllowed.ShouldBeFalse(); + status.Limit.ShouldBe(BudgetLimit.InstanceCost); + } + + [Fact] + public async Task Ein_einzelner_Agent_bleibt_unter_der_Instanzgrenze_erlaubt() + { + await Record(cost: 2m, agentId: "agent-a"); + + var instance = new InstanceBudget { DailyCostUsd = 10m }; + var status = await Guard().CheckAsync(Agent(costLimit: 5m), instance, default); + + status.IsAllowed.ShouldBeTrue(); + } + + // ═══════════════════════════════════════════════════════════ + // Tageswechsel + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Der_Verbrauch_des_Vortages_zaehlt_nicht_mit() + { + await Record(cost: 100m, when: _heute.AddDays(-1)); + + var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default); + + status.IsAllowed.ShouldBeTrue(); + } + + [Fact] + public async Task Am_naechsten_Tag_steht_das_Budget_wieder_zur_Verfuegung() + { + await Record(cost: 10m); + + var morgen = new BudgetGuard(_usage, () => _heute.AddDays(1)); + var status = await morgen.CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default); + + status.IsAllowed.ShouldBeTrue(); + } + + // ═══════════════════════════════════════════════════════════ + // Restbudget + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Das_Restbudget_wird_korrekt_berechnet() + { + await Record(cost: 1.25m); + + var remaining = await Guard().GetRemainingCostAsync(Agent(costLimit: 5m), default); + + remaining.ShouldBe(3.75m); + } + + [Fact] + public async Task Ohne_Kostengrenze_gibt_es_kein_Restbudget() + { + (await Guard().GetRemainingCostAsync(Agent(), default)).ShouldBeNull(); + } + + [Fact] + public async Task Das_Restbudget_wird_nicht_negativ() + { + await Record(cost: 20m); + + (await Guard().GetRemainingCostAsync(Agent(costLimit: 5m), default)).ShouldBe(0m); + } +} + +public sealed class UsageRepositoryTests : IDisposable +{ + private readonly string _directory; + private readonly SqliteUsageRepository _usage; + private static readonly DateTime Tag = new(2026, 7, 28, 10, 0, 0, DateTimeKind.Local); + + public UsageRepositoryTests() + { + _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + _usage = new SqliteUsageRepository(new SqliteStorage(Path.Combine(_directory, "state.db"))); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + try { Directory.Delete(_directory, recursive: true); } + catch { } + } + + private Task Record(decimal cost, string agentId = "a", DateTime? when = null, bool known = true) + => _usage.RecordAsync(new RunUsage + { + AgentId = agentId, + Model = "test/model", + PromptTokens = 1000, + CompletionTokens = 200, + CostUsd = cost, + CostIsKnown = known, + Status = "Completed", + OccurredAt = when ?? Tag + }, default); + + [Fact] + public async Task Betraege_ueberstehen_den_Rundlauf_ohne_Rundungsfehler() + { + // Als Fliesskommazahl abgelegt wuerden sich Cent-Betraege ueber tausende + // Laeufe zu einem sichtbaren Fehler summieren. + for (var i = 0; i < 1000; i++) + await Record(0.0001m); + + var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), "a", default); + + daily.CostUsd.ShouldBe(0.1m); + } + + [Fact] + public async Task Der_Tagesverbrauch_summiert_Tokens_und_Laeufe() + { + await Record(1m); + await Record(2m); + + var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), "a", default); + + daily.RunCount.ShouldBe(2); + daily.PromptTokens.ShouldBe(2000); + daily.CompletionTokens.ShouldBe(400); + daily.TotalTokens.ShouldBe(2400); + } + + [Fact] + public async Task Ohne_Agentenfilter_zaehlt_die_ganze_Instanz() + { + await Record(1m, agentId: "a"); + await Record(2m, agentId: "b"); + + var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), agentId: "", default); + + daily.CostUsd.ShouldBe(3m); + daily.RunCount.ShouldBe(2); + } + + [Fact] + public async Task Ein_Lauf_ohne_Preisangabe_macht_den_Tag_unvollstaendig() + { + await Record(1m); + await Record(0m, known: false); + + var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), "a", default); + + daily.CostIsComplete.ShouldBeFalse(); + } + + [Fact] + public async Task Ein_Tag_ohne_Laeufe_ist_leer() + { + var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), "a", default); + + daily.RunCount.ShouldBe(0); + daily.CostUsd.ShouldBe(0m); + daily.CostIsComplete.ShouldBeTrue(); + } + + [Fact] + public async Task Alte_Eintraege_lassen_sich_entfernen() + { + await Record(1m, when: Tag.AddDays(-40)); + await Record(2m, when: Tag); + + var removed = await _usage.PurgeBeforeAsync(DateOnly.FromDateTime(Tag.AddDays(-30)), default); + + removed.ShouldBe(1); + (await _usage.GetRecentAsync(10, default)).Count.ShouldBe(1); + } + + [Fact] + public async Task Viele_Laeufe_koennen_gleichzeitig_erfasst_werden() + { + // Der alte Weg schrieb TokenUsage.json bei jedem Lauf komplett neu, unter einem + // globalen Lock — genau das skalierte nicht. + await Task.WhenAll(Enumerable.Range(0, 100).Select(i => Record(0.01m, agentId: $"agent-{i % 8}"))); + + var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), agentId: "", default); + + daily.RunCount.ShouldBe(100); + daily.CostUsd.ShouldBe(1.00m); + } +}