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,121 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Accounting;
|
||||
|
||||
namespace ClawdDotNet.Core.Budget;
|
||||
|
||||
/// <summary>Welche Grenze überschritten wurde.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class BudgetGuard
|
||||
{
|
||||
private readonly IUsageRepository _usage;
|
||||
private readonly Func<DateTime> _now;
|
||||
|
||||
public BudgetGuard(IUsageRepository usage, Func<DateTime>? now = null)
|
||||
{
|
||||
_usage = usage;
|
||||
_now = now ?? (() => DateTime.Now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob der Agent heute noch laufen darf.
|
||||
/// </summary>
|
||||
public async Task<BudgetStatus> 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;
|
||||
}
|
||||
|
||||
/// <summary>Verbleibendes Kostenbudget des Agenten für heute; null bei fehlender Grenze.</summary>
|
||||
public async Task<decimal?> 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);
|
||||
}
|
||||
|
||||
/// <summary>0 oder kleiner bedeutet: keine Grenze gesetzt.</summary>
|
||||
private static bool Exceeds(decimal limit, decimal used) => limit > 0 && used >= limit;
|
||||
|
||||
private static bool Exceeds(long limit, long used) => limit > 0 && used >= limit;
|
||||
|
||||
/// <summary>
|
||||
/// Ohne Preisangaben ist die verbrauchte Summe unvollständig — sonst wirkte ein
|
||||
/// niedriger Wert wie ein noch offener Spielraum.
|
||||
/// </summary>
|
||||
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.";
|
||||
}
|
||||
Reference in New Issue
Block a user