#608: Ein wegen erschoepften Budgets abgelehnter Lauf hinterlaesst jetzt einen Beleg (Status BudgetExceeded) an Aufgabe und Verlauf; die Log-Warnung kommt einmal je Agent, Grenze und Tag. Die Kostenprognose zaehlt Ablehnungen nicht als Lauf und warnt, wenn die geplanten Aufgaben eines Agenten sein Tagesbudget uebersteigen (API + Verbrauchsansicht). #607: Passt schon der erste Treffer nicht in den Abruf, wird er gekuerzt (Anfang und Ende bleiben) statt ganz zu fehlen. remember weist auf gleichnamige Eintraege ohne Schluessel und auf Eintraege nahe der Abrufgrenze hin. Version 0.1.9. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
256 lines
11 KiB
C#
256 lines
11 KiB
C#
using ClawdDotNet.Core.Api;
|
|
using ClawdDotNet.Core.Audit;
|
|
using ClawdDotNet.Core.Config;
|
|
using ClawdDotNet.Core.Engine;
|
|
using ClawdDotNet.Core.Tasks;
|
|
|
|
namespace ClawdDotNet.Core.Accounting;
|
|
|
|
/// <summary>Worauf die Kosten je Lauf einer Aufgabe beruhen.</summary>
|
|
public enum PrognoseGrundlage
|
|
{
|
|
/// <summary>Mittelwert der letzten Laeufe dieser Aufgabe.</summary>
|
|
Laeufe,
|
|
|
|
/// <summary>Noch kein Lauf mit Preis - Mittelwert anderer Aufgaben desselben Agenten.</summary>
|
|
Agent,
|
|
|
|
/// <summary>Weder noch - grobe Schaetzung aus Promptlaenge und Modellpreis.</summary>
|
|
Schaetzung,
|
|
|
|
/// <summary>Nichts, woraus sich rechnen liesse (z.B. Modellpreis unbekannt).</summary>
|
|
Unbekannt
|
|
}
|
|
|
|
/// <summary>Die Prognose einer einzelnen Aufgabe.</summary>
|
|
public sealed record AufgabenPrognose(
|
|
string TaskId,
|
|
string Title,
|
|
string AgentId,
|
|
string Model,
|
|
double LaeufeProTag,
|
|
bool EinmaligOffen,
|
|
decimal? KostenJeLauf,
|
|
PrognoseGrundlage Grundlage,
|
|
int Stichprobe,
|
|
decimal KostenProTag);
|
|
|
|
/// <summary>
|
|
/// Ein Agent, dessen Aufgaben laut Prognose mehr kosten, als sein Tagesbudget erlaubt (#608).
|
|
/// Die spaeten Laeufe eines solchen Tages lehnt die Engine ab.
|
|
/// </summary>
|
|
public sealed record BudgetWarnung(string AgentId, decimal KostenProTag, decimal BudgetProTag);
|
|
|
|
/// <summary>Die Prognose der ganzen Instanz.</summary>
|
|
public sealed record InstanzPrognose(
|
|
IReadOnlyList<AufgabenPrognose> Aufgaben,
|
|
decimal AufgabenProTag,
|
|
decimal SonstigeProTag,
|
|
decimal SummeProTag,
|
|
decimal SummeProMonat,
|
|
decimal EinmaligOffen,
|
|
int OhneKostenbasis,
|
|
IReadOnlyList<BudgetWarnung> Budgetwarnungen);
|
|
|
|
/// <summary>
|
|
/// Rechnet aus Terminen und bisherigen Laeufen hoch, was eine Instanz kuenftig kostet (#595).
|
|
///
|
|
/// <para>Drei Stufen je Aufgabe, von belastbar zu grob: der Mittelwert ihrer letzten Laeufe;
|
|
/// ohne solche der Mittelwert anderer Aufgaben desselben Agenten; ohne beides eine Schaetzung
|
|
/// aus Promptlaenge und Modellpreis. Welche Stufe gegriffen hat, steht an jeder Zeile - eine
|
|
/// Prognose, die nicht sagt, worauf sie beruht, ist eine Zahl ohne Wert.</para>
|
|
///
|
|
/// <para>Die Schaetzung rechnet mit Listenpreis ohne Cache-Rabatt und liegt damit eher zu hoch.
|
|
/// Laeufe ausserhalb von Aufgaben (Chat, Nachrichten zwischen Agenten) gehen als Tagesmittel
|
|
/// der letzten sieben Tage ein.</para>
|
|
///
|
|
/// <para>Rein und ohne Zugriffe: Was sie braucht, bekommt sie uebergeben. So bleibt sie
|
|
/// testbar, und die Oberflaechen entscheiden, woher die Daten kommen.</para>
|
|
/// </summary>
|
|
public static class KostenPrognose
|
|
{
|
|
public const int Stichprobe = 10;
|
|
public const int TageRueckblick = 7;
|
|
public const int TageJeMonat = 30;
|
|
|
|
// Annahmen fuer die Schaetzung vor dem ersten Lauf.
|
|
internal const int AnnahmeSchritte = 3;
|
|
internal const int AnnahmeWerkzeugTokens = 1_500;
|
|
internal const int AnnahmeAntwortTokensJeSchritt = 400;
|
|
|
|
public static InstanzPrognose Berechne(
|
|
IReadOnlyList<TaskItem> aufgaben,
|
|
IReadOnlyDictionary<string, IReadOnlyList<RunReceipt>> belegeJeAufgabe,
|
|
IReadOnlyList<RunUsage> verbrauch,
|
|
IReadOnlyList<AgentConfig> agenten,
|
|
Func<string, int, int, CostEstimate>? schaetzen,
|
|
DateTime jetztUtc)
|
|
{
|
|
jetztUtc = DateTime.SpecifyKind(jetztUtc, DateTimeKind.Utc);
|
|
var seit = jetztUtc.AddDays(-TageRueckblick);
|
|
|
|
// #608: Eine Ablehnung wegen erschoepften Budgets hinterlaesst einen Beleg ueber 0 USD.
|
|
// Sie ist kein Lauf und darf den Schnitt nicht druecken.
|
|
belegeJeAufgabe = belegeJeAufgabe.ToDictionary(
|
|
e => e.Key, e => (IReadOnlyList<RunReceipt>)e.Value.Where(IstLauf).ToList());
|
|
|
|
var agentNachId = agenten
|
|
.GroupBy(a => a.AgentId, StringComparer.OrdinalIgnoreCase)
|
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
|
|
|
var alleBelege = belegeJeAufgabe.Values.SelectMany(b => b).ToList();
|
|
|
|
var bekannteJeAgent = alleBelege
|
|
.Where(b => b.CostIsKnown)
|
|
.GroupBy(b => b.AgentId, StringComparer.OrdinalIgnoreCase)
|
|
.ToDictionary(
|
|
g => g.Key,
|
|
g => g.OrderByDescending(b => Utc(b.OccurredAt)).Take(Stichprobe).ToList(),
|
|
StringComparer.OrdinalIgnoreCase);
|
|
|
|
var zeilen = new List<AufgabenPrognose>();
|
|
|
|
foreach (var aufgabe in aufgaben)
|
|
{
|
|
if (aufgabe.Status is not (TaskItemStatus.Todo or TaskItemStatus.InProgress))
|
|
continue; // pausiert, erledigt, blockiert: kostet vorerst nichts
|
|
|
|
if (TaskAssignee.KindOf(aufgabe.Assignee) == TaskAssigneeKind.Human)
|
|
continue;
|
|
|
|
var agentId = TaskAssignee.AgentId(aufgabe.Assignee);
|
|
if (string.IsNullOrWhiteSpace(agentId) && agenten.Count == 1)
|
|
agentId = agenten[0].AgentId;
|
|
|
|
agentNachId.TryGetValue(agentId, out var agent);
|
|
var belege = belegeJeAufgabe.GetValueOrDefault(aufgabe.Id) ?? [];
|
|
|
|
zeilen.Add(aufgabe.Type == TaskItemType.ToolJob
|
|
? WerkzeugJob(aufgabe, agentId, agent, belege, seit)
|
|
: Arbeit(aufgabe, agentId, agent, belege, bekannteJeAgent, schaetzen, jetztUtc));
|
|
}
|
|
|
|
var aufgabenProTag = zeilen.Sum(z => z.KostenProTag);
|
|
|
|
// Was nicht zu einer Aufgabe gehoert: Gesamtverbrauch der letzten Tage minus die
|
|
// Belege der Aufgaben im selben Zeitraum. Nie negativ - aeltere Belege koennen
|
|
// hoeher liegen als der Verbrauch (#599).
|
|
var verbrauchImFenster = verbrauch
|
|
.Where(u => u.CostIsKnown && Utc(u.OccurredAt) >= seit)
|
|
.Sum(u => u.CostUsd);
|
|
var aufgabenImFenster = alleBelege
|
|
.Where(b => b.CostIsKnown && Utc(b.OccurredAt) >= seit)
|
|
.Sum(b => b.CostUsd);
|
|
var sonstigeProTag = Math.Max(0m, (verbrauchImFenster - aufgabenImFenster) / TageRueckblick);
|
|
|
|
var summeProTag = aufgabenProTag + sonstigeProTag;
|
|
|
|
return new InstanzPrognose(
|
|
zeilen.OrderByDescending(z => z.KostenProTag).ThenBy(z => z.Title).ToList(),
|
|
Runde(aufgabenProTag),
|
|
Runde(sonstigeProTag),
|
|
Runde(summeProTag),
|
|
Runde(summeProTag * TageJeMonat),
|
|
Runde(zeilen.Where(z => z.EinmaligOffen).Sum(z => z.KostenJeLauf ?? 0m)),
|
|
zeilen.Count(z => z.Grundlage == PrognoseGrundlage.Unbekannt && (z.LaeufeProTag > 0 || z.EinmaligOffen)),
|
|
Budgetwarnungen(zeilen, agentNachId));
|
|
}
|
|
|
|
/// <summary>
|
|
/// #608: Reicht das Tagesbudget eines Agenten fuer seine geplanten Aufgaben nicht, soll das
|
|
/// vorher auffallen - nicht erst am Abend, wenn die ersten Laeufe abgelehnt werden.
|
|
/// Gezaehlt werden nur die Aufgaben; sonstige Laeufe lassen sich keinem Agenten sicher
|
|
/// zuordnen und kaemen noch hinzu.
|
|
/// </summary>
|
|
private static List<BudgetWarnung> Budgetwarnungen(
|
|
IEnumerable<AufgabenPrognose> zeilen, IReadOnlyDictionary<string, AgentConfig> agentNachId)
|
|
=> zeilen
|
|
.GroupBy(z => z.AgentId, StringComparer.OrdinalIgnoreCase)
|
|
.Select(g => (Agent: agentNachId.GetValueOrDefault(g.Key), Summe: g.Sum(z => z.KostenProTag)))
|
|
.Where(x => x.Agent is { Budget.DailyCostUsd: > 0 } a && x.Summe > a.Budget.DailyCostUsd)
|
|
.Select(x => new BudgetWarnung(x.Agent!.AgentId, Runde(x.Summe), x.Agent.Budget.DailyCostUsd))
|
|
.OrderByDescending(w => w.KostenProTag - w.BudgetProTag)
|
|
.ToList();
|
|
|
|
private static AufgabenPrognose Arbeit(
|
|
TaskItem aufgabe, string agentId, AgentConfig? agent, IReadOnlyList<RunReceipt> belege,
|
|
IReadOnlyDictionary<string, List<RunReceipt>> bekannteJeAgent,
|
|
Func<string, int, int, CostEstimate>? schaetzen, DateTime jetztUtc)
|
|
{
|
|
var laeufeProTag = aufgabe.IsRecurring ? TaskSchedule.RunsPerDay(aufgabe, jetztUtc) : 0;
|
|
var einmaligOffen = !aufgabe.IsRecurring && aufgabe.LastOccurrence is null;
|
|
|
|
var eigene = belege
|
|
.Where(b => b.CostIsKnown)
|
|
.OrderByDescending(b => Utc(b.OccurredAt))
|
|
.Take(Stichprobe)
|
|
.ToList();
|
|
|
|
decimal? jeLauf;
|
|
PrognoseGrundlage grundlage;
|
|
int stichprobe;
|
|
|
|
if (eigene.Count > 0)
|
|
{
|
|
(jeLauf, grundlage, stichprobe) = (eigene.Average(b => b.CostUsd), PrognoseGrundlage.Laeufe, eigene.Count);
|
|
}
|
|
else if (bekannteJeAgent.TryGetValue(agentId, out var desAgenten) && desAgenten.Count > 0)
|
|
{
|
|
(jeLauf, grundlage, stichprobe) = (desAgenten.Average(b => b.CostUsd), PrognoseGrundlage.Agent, desAgenten.Count);
|
|
}
|
|
else if (agent is not null && schaetzen is not null
|
|
&& schaetzen(agent.Model, GeschaetztePromptTokens(aufgabe, agent), AnnahmeSchritte * AnnahmeAntwortTokensJeSchritt)
|
|
is { IsKnown: true } schaetzung)
|
|
{
|
|
(jeLauf, grundlage, stichprobe) = (schaetzung.Usd, PrognoseGrundlage.Schaetzung, 0);
|
|
}
|
|
else
|
|
{
|
|
(jeLauf, grundlage, stichprobe) = (null, PrognoseGrundlage.Unbekannt, 0);
|
|
}
|
|
|
|
var proTag = jeLauf is { } k ? k * (decimal)laeufeProTag : 0m;
|
|
|
|
return new AufgabenPrognose(
|
|
aufgabe.Id, aufgabe.Title, agentId, agent?.Model ?? "",
|
|
Math.Round(laeufeProTag, 2), einmaligOffen,
|
|
jeLauf is { } j ? Runde(j) : null, grundlage, stichprobe, Runde(proTag));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ein Werkzeug-Job tickt oft, weckt den Agenten aber nur, wenn es etwas gibt. Laeufe
|
|
/// je Termin mal Kosten je Lauf waere hier falsch - gezaehlt wird, was in den letzten
|
|
/// Tagen tatsaechlich an Belegen entstand.
|
|
/// </summary>
|
|
private static AufgabenPrognose WerkzeugJob(
|
|
TaskItem aufgabe, string agentId, AgentConfig? agent, IReadOnlyList<RunReceipt> belege, DateTime seit)
|
|
{
|
|
var imFenster = belege.Where(b => b.CostIsKnown && Utc(b.OccurredAt) >= seit).ToList();
|
|
decimal? jeLauf = imFenster.Count > 0 ? imFenster.Average(b => b.CostUsd) : null;
|
|
|
|
return new AufgabenPrognose(
|
|
aufgabe.Id, aufgabe.Title, agentId, agent?.Model ?? "",
|
|
Math.Round(imFenster.Count / (double)TageRueckblick, 2), false,
|
|
jeLauf is { } j ? Runde(j) : null,
|
|
imFenster.Count > 0 ? PrognoseGrundlage.Laeufe : PrognoseGrundlage.Unbekannt,
|
|
imFenster.Count,
|
|
Runde(imFenster.Sum(b => b.CostUsd) / TageRueckblick));
|
|
}
|
|
|
|
/// <summary>Kontext je Schritt grob aus Zeichen/4, dazu die Werkzeugdefinitionen, ueber die angenommenen Schritte.</summary>
|
|
internal static int GeschaetztePromptTokens(TaskItem aufgabe, AgentConfig agent)
|
|
{
|
|
var zeichen = agent.FullSystemPrompt.Length + aufgabe.Title.Length + aufgabe.Body.Length + aufgabe.Acceptance.Length + 500;
|
|
var jeSchritt = zeichen / 4 + AnnahmeWerkzeugTokens;
|
|
return jeSchritt * AnnahmeSchritte;
|
|
}
|
|
|
|
private static bool IstLauf(RunReceipt beleg)
|
|
=> beleg.Status != nameof(AgentRunStatus.BudgetExceeded);
|
|
|
|
private static DateTime Utc(DateTime wert)
|
|
=> wert.Kind == DateTimeKind.Utc ? wert : wert.ToUniversalTime();
|
|
|
|
private static decimal Runde(decimal wert) => Math.Round(wert, 4);
|
|
}
|