using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Accounting; namespace ClawdDotNet.Core.Budget; /// Welche Grenze überschritten wurde. public enum BudgetLimit { None, AgentCost, AgentTokens, InstanceCost, InstanceTokens } /// #716: What a run is expected to use — the agent's average over recent runs. public readonly record struct RunEstimate(decimal CostUsd, long Tokens) { public static RunEstimate operator +(RunEstimate a, RunEstimate b) => new(a.CostUsd + b.CostUsd, a.Tokens + b.Tokens); public static RunEstimate operator -(RunEstimate a, RunEstimate b) => new(a.CostUsd - b.CostUsd, a.Tokens - b.Tokens); } 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; /// How many recent runs of an agent make up its per-run estimate (#716). public const int EstimateSample = 20; /// /// #716: Check and reserve happen under this lock. Without it, two runs starting at /// the same moment would both read the same "used" figure and both pass. /// private readonly SemaphoreSlim _reserveGate = new(1, 1); private readonly Lock _reservedLock = new(); private readonly Dictionary _reservedByAgent = new(StringComparer.OrdinalIgnoreCase); private RunEstimate _reservedInstance; /// #717: What runs in flight have reserved of each special budget. private readonly Dictionary _reservedByPot = new(StringComparer.OrdinalIgnoreCase); public BudgetGuard(IUsageRepository usage, Func? now = null) { _usage = usage; _now = now ?? (() => DateTime.Now); } /// /// Prüft, ob der Agent heute noch laufen darf. /// public Task CheckAsync( AgentConfig agent, InstanceBudget instanceBudget, CancellationToken ct) => CheckCoreAsync(agent, instanceBudget, default, default, ct); /// /// #716: Checks like and, if the run may start, reserves its /// estimated usage until the returned handle is disposed — i.e. until the run's usage /// is recorded. /// /// What changes. Before, each run was checked on its own against what was /// already recorded. Runs in flight were invisible, so several starting together all /// passed and exceeded the budget together (#682 capped how many; this closes it). /// Now the runs in flight count with their estimate. /// /// What does not change. A single run is still refused only when the /// budget is already used up — its own estimate is not added. Otherwise a small task /// would be refused because the agent's average run is large. /// public async Task<(BudgetStatus Status, IDisposable? Reservation)> ReserveAsync( AgentConfig agent, InstanceBudget instanceBudget, CancellationToken ct) { if (!agent.Budget.HasAnyLimit && !instanceBudget.HasAnyLimit) return (BudgetStatus.Allowed, null); var estimate = await EstimateRunAsync(agent.AgentId, ct); await _reserveGate.WaitAsync(ct); try { RunEstimate agentReserved, instanceReserved; lock (_reservedLock) { agentReserved = _reservedByAgent.GetValueOrDefault(agent.AgentId); instanceReserved = _reservedInstance; } var status = await CheckCoreAsync(agent, instanceBudget, agentReserved, instanceReserved, ct); if (!status.IsAllowed) return (status, null); lock (_reservedLock) { _reservedByAgent[agent.AgentId] = _reservedByAgent.GetValueOrDefault(agent.AgentId) + estimate; _reservedInstance += estimate; } return (status, new Reservation(() => Release(agent.AgentId, estimate))); } finally { _reserveGate.Release(); } } /// /// #717: Reserves a run on a special budget instead of the daily limits. Refused once what /// the pot's runs used, plus what runs in flight reserved, has reached the amount — the /// same rule as for the daily limit: a run is not refused for its own estimate. /// public async Task ReservePotAsync(SpecialBudget pot, decimal spent, string agentId, CancellationToken ct) { var estimate = await EstimateRunAsync(agentId, ct); await _reserveGate.WaitAsync(ct); try { lock (_reservedLock) { var reserved = _reservedByPot.GetValueOrDefault(pot.TaskId); if (spent + reserved.CostUsd >= pot.AmountUsd) return null; _reservedByPot[pot.TaskId] = reserved + estimate; } return new Reservation(() => ReleasePot(pot.TaskId, estimate)); } finally { _reserveGate.Release(); } } private void ReleasePot(string potId, RunEstimate estimate) { lock (_reservedLock) { var left = _reservedByPot.GetValueOrDefault(potId) - estimate; if (left.CostUsd <= 0 && left.Tokens <= 0) _reservedByPot.Remove(potId); else _reservedByPot[potId] = left; } } /// Today's usage of an agent, or with an empty id of the whole instance. public Task GetUsedTodayAsync(string agentId, CancellationToken ct) => _usage.GetDailyForBudgetAsync(DateOnly.FromDateTime(_now()), agentId, ct); /// What is currently reserved by runs in flight — for the agent, or with an /// empty id for the whole instance. public RunEstimate Reserved(string agentId) { lock (_reservedLock) return agentId.Length == 0 ? _reservedInstance : _reservedByAgent.GetValueOrDefault(agentId); } /// /// The average of the agent's recent runs. Runs without usage (refusals, aborted /// before the first call) do not count — they would drag the estimate towards zero. /// public async Task EstimateRunAsync(string agentId, CancellationToken ct) { var recent = await _usage.GetRecentForAgentAsync(agentId, EstimateSample, ct); var runs = recent.Where(r => r.TotalTokens > 0).ToList(); if (runs.Count == 0) return default; return new RunEstimate( runs.Average(r => r.CostUsd), (long)Math.Ceiling(runs.Average(r => (double)r.TotalTokens))); } private void Release(string agentId, RunEstimate estimate) { lock (_reservedLock) { var left = _reservedByAgent.GetValueOrDefault(agentId) - estimate; if (left.CostUsd <= 0 && left.Tokens <= 0) _reservedByAgent.Remove(agentId); else _reservedByAgent[agentId] = left; _reservedInstance -= estimate; } } private sealed class Reservation(Action release) : IDisposable { private int _disposed; public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) == 0) release(); } } private async Task CheckCoreAsync( AgentConfig agent, InstanceBudget instanceBudget, RunEstimate agentReserved, RunEstimate instanceReserved, CancellationToken ct) { var today = DateOnly.FromDateTime(_now()); // Agentengrenze var agentBudget = agent.Budget; if (agentBudget.HasAnyLimit) { var used = await _usage.GetDailyForBudgetAsync(today, agent.AgentId, ct); if (Exceeds(agentBudget.DailyCostUsd, used.CostUsd + agentReserved.CostUsd)) { return new BudgetStatus(false, BudgetLimit.AgentCost, $"Tagesbudget des Agenten erschöpft: {used.CostUsd:F4} von maximal " + $"{agentBudget.DailyCostUsd:F2} USD verbraucht." + ReservedHint(agentReserved.CostUsd) + UnpricedHint(used)); } if (Exceeds(agentBudget.DailyTokens, used.TotalTokens + agentReserved.Tokens)) { return new BudgetStatus(false, BudgetLimit.AgentTokens, $"Token-Tageslimit des Agenten erschöpft: {used.TotalTokens:N0} von maximal " + $"{agentBudget.DailyTokens:N0}." + ReservedTokensHint(agentReserved.Tokens)); } } // Instanzgrenze — gilt für alle Agenten zusammen if (instanceBudget.HasAnyLimit) { var used = await _usage.GetDailyForBudgetAsync(today, agentId: "", ct); if (Exceeds(instanceBudget.DailyCostUsd, used.CostUsd + instanceReserved.CostUsd)) { return new BudgetStatus(false, BudgetLimit.InstanceCost, $"Tagesbudget der Instanz erschöpft: {used.CostUsd:F4} von maximal " + $"{instanceBudget.DailyCostUsd:F2} USD verbraucht." + ReservedHint(instanceReserved.CostUsd) + UnpricedHint(used)); } if (Exceeds(instanceBudget.DailyTokens, used.TotalTokens + instanceReserved.Tokens)) { return new BudgetStatus(false, BudgetLimit.InstanceTokens, $"Token-Tageslimit der Instanz erschöpft: {used.TotalTokens:N0} von maximal " + $"{instanceBudget.DailyTokens:N0}." + ReservedTokensHint(instanceReserved.Tokens)); } } 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.GetDailyForBudgetAsync(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; /// #716: Without this, a refusal at "3.20 of 5.00 USD used" would look wrong. private static string ReservedHint(decimal reserved) => reserved > 0 ? $" Weitere {reserved:F4} USD sind für laufende Läufe reserviert." : ""; private static string ReservedTokensHint(long reserved) => reserved > 0 ? $" Weitere {reserved:N0} Tokens sind für laufende Läufe reserviert." : ""; /// /// 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."; }