using ClawdDotNet.Core.Api; using ClawdDotNet.Core.Audit; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Engine; using ClawdDotNet.Core.Tasks; namespace ClawdDotNet.Core.Accounting; /// Worauf die Kosten je Lauf einer Aufgabe beruhen. public enum PrognoseGrundlage { /// Mittelwert der letzten Laeufe dieser Aufgabe. Laeufe, /// Noch kein Lauf mit Preis - Mittelwert anderer Aufgaben desselben Agenten. Agent, /// Weder noch - grobe Schaetzung aus Promptlaenge und Modellpreis. Schaetzung, /// Nichts, woraus sich rechnen liesse (z.B. Modellpreis unbekannt). Unbekannt } /// Die Prognose einer einzelnen Aufgabe. public sealed record AufgabenPrognose( string TaskId, string Title, string AgentId, string Model, double LaeufeProTag, bool EinmaligOffen, decimal? KostenJeLauf, PrognoseGrundlage Grundlage, int Stichprobe, decimal KostenProTag); /// /// Ein Agent, dessen Aufgaben laut Prognose mehr kosten, als sein Tagesbudget erlaubt (#608). /// Die spaeten Laeufe eines solchen Tages lehnt die Engine ab. /// public sealed record BudgetWarnung(string AgentId, decimal KostenProTag, decimal BudgetProTag); /// Die Prognose der ganzen Instanz. public sealed record InstanzPrognose( IReadOnlyList Aufgaben, decimal AufgabenProTag, decimal SonstigeProTag, decimal SummeProTag, decimal SummeProMonat, decimal EinmaligOffen, int OhneKostenbasis, IReadOnlyList Budgetwarnungen); /// /// Rechnet aus Terminen und bisherigen Laeufen hoch, was eine Instanz kuenftig kostet (#595). /// /// 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. /// /// 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. /// /// Rein und ohne Zugriffe: Was sie braucht, bekommt sie uebergeben. So bleibt sie /// testbar, und die Oberflaechen entscheiden, woher die Daten kommen. /// 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 aufgaben, IReadOnlyDictionary> belegeJeAufgabe, IReadOnlyList verbrauch, IReadOnlyList agenten, Func? 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)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(); 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)); } /// /// #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. /// private static List Budgetwarnungen( IEnumerable zeilen, IReadOnlyDictionary 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 belege, IReadOnlyDictionary> bekannteJeAgent, Func? 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)); } /// /// 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. /// private static AufgabenPrognose WerkzeugJob( TaskItem aufgabe, string agentId, AgentConfig? agent, IReadOnlyList 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)); } /// Kontext je Schritt grob aus Zeichen/4, dazu die Werkzeugdefinitionen, ueber die angenommenen Schritte. 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); }