diff --git a/docs/Organisation-Konzept.md b/docs/Organisation-Konzept.md index 845aae7..2d5c737 100644 --- a/docs/Organisation-Konzept.md +++ b/docs/Organisation-Konzept.md @@ -1311,6 +1311,17 @@ wie möglich" ist die Voreinstellung, nicht die einzige Möglichkeit. 2026-09-19) widerspricht dieser Linie. Sie gehört auf „unbegrenzt" umgestellt; die Möglichkeit, je Agent kürzer oder gar nicht aufzuzeichnen, bleibt unverändert. +> **Umgesetzt (#699).** `UnitCosts` rechnet je Einheit samt allem darunter: heute, 7 und +> 30 Tage, dieser Monat, Vormonat, Durchschnittstag der letzten 28 Tage (erst ab 7 Tagen +> Verlauf) und das Maximum als Summe der Agentengrenzen — fehlt einem Agenten die Grenze, +> gibt es kein Maximum. Abo-Läufe stehen als Kontingent (Tokens), nicht als „kostenlos". +> `CostWatch` prüft alle 15 Minuten und meldet je Einheit und Tag einmal: Abweichung +> (Faktor 2 über dem Durchschnitt, ab 1 $), Nähe zur Grenzsumme (80 %), dazu je Monat +> überschrittene Instanzschwellen (`costWatch.monthlyThresholdsUsd`, Vorgabe keine). Die +> Meldung geht als Aufgabe an den Menschen und steht in der Verbrauchsansicht; abgeschaltet +> wird nichts. Die Zeitreihe gruppiert zusätzlich nach Einheit. **Offen:** verdichtete +> Tagessummen für lange Zeiträume (#701) — die Auswertung liest heute die Laufzeilen. + --- ## 9 — Was eine Organisation braucht und ein Agentenhaufen nicht diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index 1ccd4ac..4e80e25 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -129,6 +129,9 @@ public sealed class AppHost : IAsyncDisposable /// #660: Usage analysis, run details and prompt history for the user interfaces. public RunInsights? Insights { get; private init; } + /// #699: Costs per unit and the alerts on them. + public Core.Accounting.CostWatch? CostWatch { get; private set; } + /// Modellpreise von OpenRouter - fuer Budget und Kostenprognose (#595). Laedt im Hintergrund. public ModelPricingCatalog? Pricing { get; private init; } @@ -704,6 +707,14 @@ public sealed class AppHost : IAsyncDisposable historyKeeper.Start(); host._shutdown.Add(historyKeeper.DisposeAsync); + // #699: costs per unit, and what changed in them. + var costWatch = new Core.Accounting.CostWatch( + storage, usage, () => Core.Organisation.OrgChart.From(instance), () => instance.CostWatch, board, + loggerFactory.CreateLogger("ClawdDotNet.Core.Accounting.CostWatch")); + host.CostWatch = costWatch; + costWatch.Start(); + host._shutdown.Add(costWatch.DisposeAsync); + // #593: Angelegt wurde der Dienst schon immer, gestartet nie — weder kopflos noch // mit Fenster. Die Kopfzeile der Weboberflaeche stand deshalb dauerhaft auf // "OpenRouter: —" und /api/status auf "Prüfe…". diff --git a/src/ClawdDotNet.Core/Accounting/CostWatch.cs b/src/ClawdDotNet.Core/Accounting/CostWatch.cs new file mode 100644 index 0000000..d42f8b4 --- /dev/null +++ b/src/ClawdDotNet.Core/Accounting/CostWatch.cs @@ -0,0 +1,134 @@ +using ClawdDotNet.Core.Organisation; +using ClawdDotNet.Core.Storage; +using ClawdDotNet.Core.Tasks; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Core.Accounting; + +/// #699: An alert as it was raised — for the usage view. +public sealed record RaisedCostAlert(string Period, string Scope, CostAlertKind Kind, string Message, DateTime RaisedAt); + +/// +/// #699: Looks at the costs of the units every few minutes and raises what changed — once per +/// day and unit (a monthly threshold once per month). An alert goes to the human as a task and +/// stays in the usage view; it switches nothing off. +/// +public sealed class CostWatch( + SqliteStorage storage, + IUsageRepository usage, + Func organisation, + Func settings, + TaskboardService? board, + ILogger logger, + TimeProvider? clock = null) : IAsyncDisposable +{ + public static readonly TimeSpan Interval = TimeSpan.FromMinutes(15); + + private readonly TimeProvider _clock = clock ?? TimeProvider.System; + private readonly CancellationTokenSource _cts = new(); + private Task? _loop; + + /// The day the budget counts — the same local day as the BudgetGuard's. + private DateOnly Today => DateOnly.FromDateTime(_clock.GetLocalNow().DateTime); + + public async Task> ReportAsync(CancellationToken ct) + { + var today = Today; + var rows = await usage.GetDailyByAgentAsync(new DateOnly(today.Year, today.Month, 1).AddMonths(-1).AddDays(-UnitCosts.AverageDays), today, ct); + return UnitCosts.Build(organisation(), rows, today); + } + + /// Raises what is new today. Returns the alerts raised in this pass. + public async Task> CheckAsync(CancellationToken ct) + { + if (!settings().Enabled) + return []; + + var raised = new List(); + foreach (var alert in UnitCosts.Alerts(await ReportAsync(ct), settings(), Today)) + { + var period = alert.Kind == CostAlertKind.MonthlyThreshold ? alert.Date.ToString("yyyy-MM") : alert.Date.ToString("yyyy-MM-dd"); + if (!await TryRecordAsync(period, alert, ct)) + continue; + raised.Add(alert); + logger.LogWarning("Cost alert ({Kind}, {Scope}): {Message}", alert.Kind, alert.Scope, alert.Message); + + if (board is not null) + await board.CreateAsync(new TaskItem + { + Title = alert.Kind switch + { + CostAlertKind.Deviation => $"Cost alert: unusual day for {alert.Scope}", + CostAlertKind.NearLimit => $"Cost alert: {alert.Scope} near its limits", + _ => "Cost alert: monthly total crossed" + }, + Body = alert.Message + "\n\nLook at the usage view (costs by unit). Nothing was switched off.", + Assignee = TaskAssignee.Human, + Priority = alert.Kind == CostAlertKind.NearLimit ? 4 : 3 + }, ct); + } + return raised; + } + + public async Task> RecentAsync(int days, CancellationToken ct) + { + await using var conn = await storage.OpenConnectionAsync(ct); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT Period, Scope, Kind, Message, RaisedAt FROM CostAlerts WHERE RaisedAt >= @since ORDER BY RaisedAt DESC LIMIT 200"; + cmd.Parameters.AddWithValue("@since", _clock.GetUtcNow().UtcDateTime.AddDays(-Math.Max(1, days)).ToString("O")); + var result = new List(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + result.Add(new RaisedCostAlert(reader.GetString(0), reader.GetString(1), + Enum.TryParse(reader.GetString(2), out var k) ? k : CostAlertKind.Deviation, + reader.GetString(3), DateTime.Parse(reader.GetString(4), null, System.Globalization.DateTimeStyles.RoundtripKind))); + return result; + } + + private Task TryRecordAsync(string period, CostAlert alert, CancellationToken ct) + => storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT OR IGNORE INTO CostAlerts (Period, Scope, Kind, Message, RaisedAt) + VALUES (@period, @scope, @kind, @message, @at) + """; + cmd.Parameters.AddWithValue("@period", period); + cmd.Parameters.AddWithValue("@scope", alert.Scope); + cmd.Parameters.AddWithValue("@kind", alert.Kind.ToString()); + cmd.Parameters.AddWithValue("@message", alert.Message); + cmd.Parameters.AddWithValue("@at", _clock.GetUtcNow().UtcDateTime.ToString("O")); + return await cmd.ExecuteNonQueryAsync(ct) == 1; + }, ct); + + public void Start() => _loop ??= RunAsync(_cts.Token); + + private async Task RunAsync(CancellationToken ct) + { + try { await Task.Delay(TimeSpan.FromMinutes(1), ct); } + catch (OperationCanceledException) { return; } + + while (!ct.IsCancellationRequested) + { + try { await CheckAsync(ct); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Cost watch failed — next attempt in {Minutes} min", Interval.TotalMinutes); + } + + try { await Task.Delay(Interval, ct); } + catch (OperationCanceledException) { return; } + } + } + + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + if (_loop is not null) + { + try { await _loop; } + catch (OperationCanceledException) { } + } + _cts.Dispose(); + } +} diff --git a/src/ClawdDotNet.Core/Accounting/SqliteUsageRepository.cs b/src/ClawdDotNet.Core/Accounting/SqliteUsageRepository.cs index 6258df9..49cf8d9 100644 --- a/src/ClawdDotNet.Core/Accounting/SqliteUsageRepository.cs +++ b/src/ClawdDotNet.Core/Accounting/SqliteUsageRepository.cs @@ -193,6 +193,41 @@ public sealed class SqliteUsageRepository : IUsageRepository OnSubscription = reader.GetInt32(13) != 0 }; + public async Task> GetDailyByAgentAsync(DateOnly from, DateOnly to, CancellationToken ct) + { + await using var conn = await _storage.OpenConnectionAsync(ct); + using var cmd = conn.CreateCommand(); + // Amounts are stored as text — summed here, like GetDailyAsync does. + cmd.CommandText = """ + SELECT UsageDate, AgentId, CostUsd, PromptTokens + CompletionTokens, OnSubscription + FROM RunUsage + WHERE UsageDate >= @from AND UsageDate <= @to + """; + cmd.Parameters.AddWithValue("@from", FormatDate(from)); + cmd.Parameters.AddWithValue("@to", FormatDate(to)); + + var sums = new Dictionary<(string Date, string Agent), (decimal Cost, long Tokens, long Sub, int Runs)>(); + await using (var reader = await cmd.ExecuteReaderAsync(ct)) + { + while (await reader.ReadAsync(ct)) + { + var key = (reader.GetString(0), reader.GetString(1)); + var cost = decimal.TryParse(reader.GetValue(2)?.ToString(), System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var c) ? c : 0m; + var tokens = reader.GetInt64(3); + var sub = reader.GetInt64(4) != 0; + var current = sums.GetValueOrDefault(key); + sums[key] = (current.Cost + cost, current.Tokens + tokens, current.Sub + (sub ? tokens : 0), current.Runs + 1); + } + } + + return sums.Select(e => new AgentDayUsage( + DateOnly.ParseExact(e.Key.Date, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture), + e.Key.Agent, e.Value.Cost, e.Value.Tokens, e.Value.Sub, e.Value.Runs)) + .OrderBy(u => u.Date).ThenBy(u => u.AgentId, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + private static string FormatDate(DateOnly date) => date.ToString("yyyy-MM-dd"); private static string FormatDecimal(decimal value) diff --git a/src/ClawdDotNet.Core/Accounting/UnitCosts.cs b/src/ClawdDotNet.Core/Accounting/UnitCosts.cs new file mode 100644 index 0000000..6b9e929 --- /dev/null +++ b/src/ClawdDotNet.Core/Accounting/UnitCosts.cs @@ -0,0 +1,182 @@ +using System.Text.Json.Serialization; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Organisation; + +namespace ClawdDotNet.Core.Accounting; + +/// #699: When a change in cost is worth a look. Nothing here switches anything off. +public sealed class CostWatchSettings +{ + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = true; + + /// A day this many times the unit's average of the weeks before is a deviation. + [JsonPropertyName("deviationFactor")] + public decimal DeviationFactor { get; set; } = 2m; + + /// Below this many dollars a day nothing deviates — 3 cents after 1 cent is noise. + [JsonPropertyName("deviationMinimumUsd")] + public decimal DeviationMinimumUsd { get; set; } = 1m; + + /// Share of the sum of the agents' daily limits at which a unit is near its limit. + [JsonPropertyName("nearLimitShare")] + public decimal NearLimitShare { get; set; } = 0.8m; + + /// Monthly totals of the instance that are reported when crossed — they report, they do not stop. + [JsonPropertyName("monthlyThresholdsUsd")] + public List MonthlyThresholdsUsd { get; set; } = []; +} + +/// #699: A unit's cost — itself and everything below it. +public sealed record UnitCostLine( + string? UnitId, + string Name, + string Path, + int Depth, + int Agents, + decimal Today, + decimal Last7Days, + decimal Last30Days, + decimal ThisMonth, + decimal PreviousMonth, + // The sum of the agents' daily dollar limits; null when an agent below has none — then there is no maximum. + decimal? MaxPerDay, + // The average day of the 28 days before today; null without a week of history. + decimal? AverageDay, + long SubscriptionTokensToday, + long SubscriptionTokens30Days) +{ + public decimal? ShareOfMaxToday => MaxPerDay is > 0 ? Today / MaxPerDay : null; +} + +public enum CostAlertKind +{ + /// Today far above the unit's own course. + Deviation, + + /// The agents of a unit together close to their limits — before they run into them one by one. + NearLimit, + + /// The instance crossed a monthly total. + MonthlyThreshold +} + +public sealed record CostAlert(DateOnly Date, string Scope, CostAlertKind Kind, string Message); + +/// +/// #699: Costs per unit, added up — instead of budgets on the unit (decision of 2026-09-22). +/// +/// Limits stay at the agent; the unit reckons. No unit limit, so a move changes no +/// limit at all. The maximum shown is the sum of the agents' limits below. +/// +/// The alert matters more than the limit. A limit says when it is over; what is +/// interesting is when something changes: a unit that needed 30 $ a day for weeks and needs +/// 80 $ today has exceeded nothing and is exactly the case to look at. Recognising is code — an +/// average and a threshold. Judging it is for the supervision (#680) or the human. +/// +public static class UnitCosts +{ + /// The scope name of the instance as a whole. + public const string Instance = "instance"; + + /// Days of history behind the average. + public const int AverageDays = 28; + + /// Days with usage needed before an average means anything. + public const int MinimumHistoryDays = 7; + + public static IReadOnlyList Build(OrgChart org, IReadOnlyList usage, DateOnly today) + { + var lines = new List(); + var agents = org.Agents.ToList(); + + // The instance first, then the tree from the top, then the agents on the top level. + lines.Add(Line(null, "Instance", "", 0, agents.Select(a => a.AgentId), agents, usage, today)); + void Walk(string? parent, int depth) + { + foreach (var unit in org.Children(parent).OrderBy(u => u.Name, StringComparer.OrdinalIgnoreCase)) + { + var below = Subtree(org, unit.Id).SelectMany(u => org.MembersOf(u.Id)).ToList(); + lines.Add(Line(unit.Id, unit.Name, org.PathOf(unit.Id), depth, below.Select(a => a.AgentId), below, usage, today)); + Walk(unit.Id, depth + 1); + } + } + if (org.HasUnits) + { + Walk(null, 1); + var topLevel = org.MembersOf(null); + if (topLevel.Count > 0) + lines.Add(Line("", "Top level", "", 1, topLevel.Select(a => a.AgentId), topLevel, usage, today)); + } + return lines; + } + + private static IEnumerable Subtree(OrgChart org, string unitId) + { + if (org.Unit(unitId) is not { } unit) + yield break; + yield return unit; + foreach (var child in org.Children(unitId)) + foreach (var u in Subtree(org, child.Id)) + yield return u; + } + + private static UnitCostLine Line( + string? unitId, string name, string path, int depth, IEnumerable agentIds, + IReadOnlyList agents, IReadOnlyList usage, DateOnly today) + { + var ids = agentIds.ToHashSet(StringComparer.OrdinalIgnoreCase); + // The instance line counts everything — also agents removed since. + var rows = unitId is null ? usage : usage.Where(u => ids.Contains(u.AgentId)).ToList(); + + decimal Sum(DateOnly from, DateOnly to) => rows.Where(r => r.Date >= from && r.Date <= to).Sum(r => r.CostUsd); + var monthStart = new DateOnly(today.Year, today.Month, 1); + var previousStart = monthStart.AddMonths(-1); + + var history = rows.Where(r => r.Date >= today.AddDays(-AverageDays) && r.Date < today).ToList(); + var historyDays = history.Select(r => r.Date).Distinct().Count(); + decimal? average = historyDays >= MinimumHistoryDays ? history.Sum(r => r.CostUsd) / AverageDays : null; + + decimal? max = agents.Count > 0 && agents.All(a => a.Budget.DailyCostUsd > 0) ? agents.Sum(a => a.Budget.DailyCostUsd) : null; + + return new UnitCostLine( + unitId, name, path, depth, agents.Count, + Sum(today, today), Sum(today.AddDays(-6), today), Sum(today.AddDays(-29), today), + Sum(monthStart, today), Sum(previousStart, monthStart.AddDays(-1)), + max, average, + rows.Where(r => r.Date == today).Sum(r => r.SubscriptionTokens), + rows.Where(r => r.Date >= today.AddDays(-29)).Sum(r => r.SubscriptionTokens)); + } + + /// What is worth a look today — one alert per scope and kind. + public static IReadOnlyList Alerts(IReadOnlyList lines, CostWatchSettings settings, DateOnly today) + { + var alerts = new List(); + foreach (var line in lines) + { + var scope = line.UnitId is null ? Instance : line.UnitId.Length == 0 ? "top-level" : line.UnitId; + var label = line.UnitId is null ? "The instance" : $"'{(line.Path.Length > 0 ? line.Path : line.Name)}'"; + + if (line.AverageDay is { } average && line.Today >= settings.DeviationMinimumUsd + && line.Today >= average * settings.DeviationFactor) + alerts.Add(new CostAlert(today, scope, CostAlertKind.Deviation, + $"{label} has used {line.Today:0.00} $ today; its average day of the last {AverageDays} days is {average:0.00} $. " + + "Nothing is exceeded — but something changed: a loop, a task off course, a more expensive model?")); + + if (line.MaxPerDay is > 0 && line.ShareOfMaxToday >= settings.NearLimitShare) + alerts.Add(new CostAlert(today, scope, CostAlertKind.NearLimit, + $"{label} has used {line.Today:0.00} $ of the {line.MaxPerDay:0.00} $ its agents may spend in a day together " + + $"({line.ShareOfMaxToday:P0}). Its agents are about to run into their limits.")); + } + + if (lines.FirstOrDefault(l => l.UnitId is null) is { } instance) + { + var crossed = settings.MonthlyThresholdsUsd.Where(t => t > 0 && instance.ThisMonth >= t).DefaultIfEmpty().Max(); + if (crossed > 0) + alerts.Add(new CostAlert(today, $"{Instance}:{crossed:0.##}", CostAlertKind.MonthlyThreshold, + $"The instance has used {instance.ThisMonth:0.00} $ this month and crossed {crossed:0.00} $ " + + $"(last month in all: {instance.PreviousMonth:0.00} $).")); + } + return alerts; + } +} diff --git a/src/ClawdDotNet.Core/Accounting/UsageModels.cs b/src/ClawdDotNet.Core/Accounting/UsageModels.cs index bcd0b46..be1bc5d 100644 --- a/src/ClawdDotNet.Core/Accounting/UsageModels.cs +++ b/src/ClawdDotNet.Core/Accounting/UsageModels.cs @@ -80,6 +80,16 @@ public interface IUsageRepository Task> GetRecentForAgentAsync(string agentId, int limit, CancellationToken ct) => Task.FromResult>([]); + /// + /// #699: Each agent's usage per day in a range — what the units are added up from. Runs on a + /// subscription cost 0 $ but use quota; their tokens come apart. + /// + Task> GetDailyByAgentAsync(DateOnly from, DateOnly to, CancellationToken ct) + => Task.FromResult>([]); + /// Entfernt Einträge älter als das angegebene Datum. Gibt die Anzahl zurück. Task PurgeBeforeAsync(DateOnly date, CancellationToken ct); } + +/// #699: One agent's usage on one day. +public sealed record AgentDayUsage(DateOnly Date, string AgentId, decimal CostUsd, long Tokens, long SubscriptionTokens, int Runs); diff --git a/src/ClawdDotNet.Core/Config/InstanceConfig.cs b/src/ClawdDotNet.Core/Config/InstanceConfig.cs index d682d58..b627a61 100644 --- a/src/ClawdDotNet.Core/Config/InstanceConfig.cs +++ b/src/ClawdDotNet.Core/Config/InstanceConfig.cs @@ -159,6 +159,10 @@ public sealed class InstanceConfig [JsonPropertyName("fileHistory")] public Files.FileHistorySettings FileHistory { get; set; } = new(); + /// #699: When a change in the units' costs is reported. + [JsonPropertyName("costWatch")] + public Accounting.CostWatchSettings CostWatch { get; set; } = new(); + [JsonPropertyName("maxDelegationDepth")] public int MaxDelegationDepth { get; set; } = 3; diff --git a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs index bcb96fd..397e42a 100644 --- a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs +++ b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs @@ -284,6 +284,17 @@ public sealed class SqliteStorage -- Staging (A2): eingefrorene, freigabepflichtige Tool-Aufrufe. Ausgeführt wird -- genau der gespeicherte Argument-JSON (Plan-Freeze). + -- #699: cost alerts, once per period (day, or month for a threshold), scope and kind. + CREATE TABLE IF NOT EXISTS CostAlerts ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + Period TEXT NOT NULL, + Scope TEXT NOT NULL, + Kind TEXT NOT NULL, + Message TEXT NOT NULL, + RaisedAt TEXT NOT NULL, + UNIQUE (Period, Scope, Kind) + ); + CREATE TABLE IF NOT EXISTS StagedCalls ( Id INTEGER PRIMARY KEY AUTOINCREMENT, RunId TEXT NOT NULL, diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json index a8cc688..f5430e5 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json @@ -671,6 +671,22 @@ "usage.cost": "Kosten", "usage.unknownPrice": "(Preis unbekannt)", "usage.notAvailable": "Verbrauchsdaten nicht verfügbar", + "usage.units.title": "Kosten je Einheit", + "usage.units.hint": "Jede Einheit mit allem, was darunter liegt. Das Maximum ist die Summe der Tagesgrenzen ihrer Agenten — Grenzen bleiben am Agenten. Wichtig ist die Veränderung: Ein Tag weit über dem eigenen Durchschnitt der Einheit wird markiert, auch wenn nichts überschritten ist.", + "usage.units.unit": "Einheit", + "usage.units.today": "Heute", + "usage.units.average": "Durchschnittstag", + "usage.units.max": "Max. je Tag", + "usage.units.last7": "7 Tage", + "usage.units.thisMonth": "Dieser Monat", + "usage.units.previousMonth": "Vormonat", + "usage.units.quota": "Kontingent (30 T.)", + "usage.units.instance": "Instanz", + "usage.units.topLevel": "Oberste Ebene", + "usage.units.alerts": "Meldungen der letzten 7 Tage", + "usage.units.kind.Deviation": "Ungewöhnlicher Tag", + "usage.units.kind.NearLimit": "Nahe an den Grenzen", + "usage.units.kind.MonthlyThreshold": "Monatssumme überschritten", "usage.forecastTitle": "Kostenprognose", "usage.forecastDay": "Prognose je Tag", "usage.forecastMonth": "Prognose je Monat (30 Tage)", @@ -783,6 +799,7 @@ "timeline.metric.runs": "Läufe", "timeline.groupBy": "Nach", "timeline.groupBy.agent": "Agent", + "timeline.groupBy.unit": "Einheit", "timeline.groupBy.model": "Modell", "timeline.groupBy.source": "Ereignis", "timeline.groupBy.budget": "Budget", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json index 2f84e4c..3cda882 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json @@ -671,6 +671,22 @@ "usage.cost": "Cost", "usage.unknownPrice": "(price unknown)", "usage.notAvailable": "Usage data not available", + "usage.units.title": "Costs by unit", + "usage.units.hint": "Each unit with everything below it. The maximum is the sum of its agents' daily limits — limits stay at the agents. What matters is the change: a day far above the unit's own average is marked even when nothing is exceeded.", + "usage.units.unit": "Unit", + "usage.units.today": "Today", + "usage.units.average": "Average day", + "usage.units.max": "Max per day", + "usage.units.last7": "7 days", + "usage.units.thisMonth": "This month", + "usage.units.previousMonth": "Last month", + "usage.units.quota": "Quota (30 d)", + "usage.units.instance": "Instance", + "usage.units.topLevel": "Top level", + "usage.units.alerts": "Alerts of the last 7 days", + "usage.units.kind.Deviation": "Unusual day", + "usage.units.kind.NearLimit": "Near the limits", + "usage.units.kind.MonthlyThreshold": "Monthly total crossed", "usage.forecastTitle": "Cost Forecast", "usage.forecastDay": "Forecast per day", "usage.forecastMonth": "Forecast per month (30 days)", @@ -783,6 +799,7 @@ "timeline.metric.runs": "Runs", "timeline.groupBy": "By", "timeline.groupBy.agent": "Agent", + "timeline.groupBy.unit": "Unit", "timeline.groupBy.model": "Model", "timeline.groupBy.source": "Event", "timeline.groupBy.budget": "Budget", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/usage-timeline.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/usage-timeline.js index 597d213..ada7239 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/usage-timeline.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/usage-timeline.js @@ -91,7 +91,7 @@ function buildControls(filters, bucket, groupBy, metric) { bar.append( group("metric", ["cost", "tokens", "runs"], metric), - group("groupBy", ["agent", "model", "source", "budget"], groupBy), + group("groupBy", ["agent", "unit", "model", "source", "budget"], groupBy), group("bucket", ["hour", "day"], bucket)); return bar; } diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/usage.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/usage.js index e9299d9..8f5bbee 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/usage.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/usage.js @@ -44,8 +44,9 @@ async function ladeVerbrauch() { ); try { - const [laeufe, prognose] = await Promise.all([api("/usage?limit=100"), erstellePrognoseKarte()]); + const [laeufe, prognose, einheiten] = await Promise.all([api("/usage?limit=100"), erstellePrognoseKarte(), erstelleEinheitenKarte()]); container.replaceChildren(); + if (einheiten) container.append(einheiten); if (prognose) container.append(prognose); if (!laeufe || laeufe.length === 0) { @@ -164,6 +165,59 @@ async function ladeVerbrauch() { } } +// ─── Costs by unit (#699) ─── + +/** + * What each unit costs — itself and everything below it — against the sum of its agents' + * limits and against its own course. The alert matters more than the limit: a unit far + * above its average has exceeded nothing and is exactly the one to look at. + */ +async function erstelleEinheitenKarte() { + let zeilen, warnungen; + try { + [zeilen, warnungen] = await Promise.all([api("/usage/units"), api("/usage/cost-alerts?days=7")]); + } catch { + return null; + } + if (!Array.isArray(zeilen) || zeilen.length === 0) return null; + + const usd = (v) => (v == null ? "—" : `${Number(v).toFixed(2)} $`); + const tabelle = erstelleElement("table", { klasse: "daten-tabelle einheiten-kosten" }); + tabelle.append(erstelleElement("thead", {}, erstelleElement("tr", {}, + ...["usage.units.unit", "usage.units.today", "usage.units.average", "usage.units.max", "usage.units.last7", "usage.units.thisMonth", "usage.units.previousMonth", "usage.units.quota"] + .map((k) => erstelleElement("th", { klasse: k === "usage.units.unit" ? "" : "zahl", text: t(k) }))))); + + const koerper = erstelleElement("tbody"); + for (const z of zeilen) { + const name = z.unitId == null ? t("usage.units.instance") : z.unitId === "" ? t("usage.units.topLevel") : z.name; + const auffaellig = z.averageDay != null && z.today >= 1 && z.today >= 2 * z.averageDay; + const nahe = z.shareOfMaxToday != null && z.shareOfMaxToday >= 0.8; + const anteil = z.shareOfMaxToday == null ? "" : ` (${Math.round(z.shareOfMaxToday * 100)} %)`; + koerper.append(erstelleElement("tr", { klasse: auffaellig || nahe ? "zeile-warnung" : undefined }, + erstelleElement("td", { stil: `padding-left: ${14 + z.depth * 16}px`, text: name, title: z.path || "" }), + erstelleElement("td", { klasse: "zahl", text: usd(z.today) + anteil }), + erstelleElement("td", { klasse: "zahl", text: usd(z.averageDay) }), + erstelleElement("td", { klasse: "zahl", text: usd(z.maxPerDay) }), + erstelleElement("td", { klasse: "zahl", text: usd(z.last7Days) }), + erstelleElement("td", { klasse: "zahl", text: usd(z.thisMonth) }), + erstelleElement("td", { klasse: "zahl", text: usd(z.previousMonth) }), + // #712: a subscription costs 0 $ but uses quota — shown as tokens, not as "free". + erstelleElement("td", { klasse: "zahl", text: z.subscriptionTokens30Days ? formatiereTokens(z.subscriptionTokens30Days) : "—" }))); + } + tabelle.append(koerper); + + const inhalt = [erstelleElement("p", { klasse: "gedaempft text-klein", text: t("usage.units.hint") }), erstelleElement("div", { klasse: "tabelle-scroll" }, tabelle)]; + if (Array.isArray(warnungen) && warnungen.length > 0) { + inhalt.push(erstelleElement("h3", { klasse: "fall-verlauf-titel", text: t("usage.units.alerts") })); + for (const w of warnungen) { + inhalt.push(erstelleElement("div", { klasse: "hinweis-box warnung" }, + erstelleElement("strong", { text: `${formatiereDatumZeit(w.raisedAt)} · ${t(`usage.units.kind.${w.kind}`)}` }), + erstelleElement("div", { text: w.message }))); + } + } + return erstelleKarte(t("usage.units.title"), inhalt); +} + // ─── Kostenprognose (#595) ─── function getGrundlageText(basis, sample) { diff --git a/src/ClawdDotNet.Web/Contracts/Dtos.cs b/src/ClawdDotNet.Web/Contracts/Dtos.cs index cc76c05..7ee6ecb 100644 --- a/src/ClawdDotNet.Web/Contracts/Dtos.cs +++ b/src/ClawdDotNet.Web/Contracts/Dtos.cs @@ -395,3 +395,13 @@ public sealed record FileVersionDto(string? Text, bool Binary, bool TooLarge, bo /// #719: Putting a version back — which one, and why. Without a reason it is refused. public sealed record FileRestoreInputDto(string? Version, string? Reason); + +/// #699: A unit's costs, itself and everything below it. UnitId null: the instance; "": the top level. +public sealed record UnitCostDto( + string? UnitId, string Name, string Path, int Depth, int Agents, + decimal Today, decimal Last7Days, decimal Last30Days, decimal ThisMonth, decimal PreviousMonth, + decimal? MaxPerDay, decimal? AverageDay, decimal? ShareOfMaxToday, + long SubscriptionTokensToday, long SubscriptionTokens30Days); + +/// #699: A cost alert as it was raised. +public sealed record CostAlertDto(string Period, string Scope, string Kind, string Message, DateTime RaisedAt); diff --git a/src/ClawdDotNet.Web/Endpoints/AnalyticsEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/AnalyticsEndpoints.cs index 90de1dc..b949202 100644 --- a/src/ClawdDotNet.Web/Endpoints/AnalyticsEndpoints.cs +++ b/src/ClawdDotNet.Web/Endpoints/AnalyticsEndpoints.cs @@ -58,9 +58,39 @@ public static class AnalyticsEndpoints groupBy is "model" or "source" or "budget" ? groupBy : "agent", TimeSpan.FromMinutes(Math.Clamp(tzOffset, -14 * 60, 14 * 60)), ct); + + // #699: "What does marketing cost me?" — the agents' series, added up by their unit as it is now. + if (groupBy == "unit") + { + var org = host.Organisation.Current(); + return Results.Ok(points + .GroupBy(p => (p.BucketStart, Unit: org.UnitOf(p.Group) is { } u ? org.PathOf(u.Id) : "—")) + .Select(g => new SeriesPointDto(g.Key.BucketStart, g.Key.Unit, g.Sum(p => p.Runs), g.Sum(p => p.Tokens), g.Sum(p => p.CostUsd))) + .OrderBy(p => p.BucketStart) + .ToList()); + } return Results.Ok(points.Select(p => new SeriesPointDto(p.BucketStart, p.Group, p.Runs, p.Tokens, p.CostUsd)).ToList()); }); + // #699: Costs per unit — itself and everything below it — with the alerts raised on them. + api.MapGet("/usage/units", async (AppHost host, CancellationToken ct) => + { + if (host.CostWatch is not { } watch) + return ApiEndpoints.Unavailable("No usage data: agents are not active."); + var lines = await watch.ReportAsync(ct); + return Results.Ok(lines.Select(l => new UnitCostDto( + l.UnitId, l.Name, l.Path, l.Depth, l.Agents, l.Today, l.Last7Days, l.Last30Days, l.ThisMonth, l.PreviousMonth, + l.MaxPerDay, l.AverageDay, l.ShareOfMaxToday, l.SubscriptionTokensToday, l.SubscriptionTokens30Days)).ToList()); + }); + + api.MapGet("/usage/cost-alerts", async (AppHost host, CancellationToken ct, int days = 30) => + { + if (host.CostWatch is not { } watch) + return Results.Ok(Array.Empty()); + return Results.Ok((await watch.RecentAsync(days, ct)) + .Select(a => new CostAlertDto(a.Period, a.Scope, a.Kind.ToString(), a.Message, a.RaisedAt)).ToList()); + }); + api.MapGet("/usage/events", async (AppHost host, HttpRequest request, CancellationToken ct) => { if (host.Insights is not { } insights) diff --git a/tests/ClawdDotNet.Core.Tests/Accounting/UnitCostsTests.cs b/tests/ClawdDotNet.Core.Tests/Accounting/UnitCostsTests.cs new file mode 100644 index 0000000..89c0c37 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Accounting/UnitCostsTests.cs @@ -0,0 +1,139 @@ +using ClawdDotNet.Core.Accounting; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Organisation; +using ClawdDotNet.Core.Storage; +using ClawdDotNet.Core.Tasks; +using ClawdDotNet.Core.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Accounting; + +/// +/// #699: Costs per unit are added up, not capped — and what changed is reported, once. +/// +public sealed class UnitCostsTests : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "unitcosts-" + Guid.NewGuid().ToString("N")[..8]); + private static readonly DateOnly Today = new(2027, 3, 10); + + /// marketing (ads, listing) › amazon (seller); bookkeeper on the top level. + private static OrgChart Org(decimal adsLimit = 10) => new( + [new UnitConfig { Id = "marketing", Name = "Marketing" }, new UnitConfig { Id = "amazon", Name = "Amazon", ParentId = "marketing" }], + [ + new AgentConfig { AgentId = "ads", UnitId = "marketing", Budget = { DailyCostUsd = adsLimit } }, + new AgentConfig { AgentId = "listing", UnitId = "marketing", Budget = { DailyCostUsd = 5 } }, + new AgentConfig { AgentId = "seller", UnitId = "amazon", Budget = { DailyCostUsd = 5 } }, + new AgentConfig { AgentId = "bookkeeper", Budget = { DailyCostUsd = 2 } } + ]); + + public void Dispose() + { + SqliteStorage.ReleasePooledConnections(_directory); + try { Directory.Delete(_directory, recursive: true); } + catch { /* cleaning up is secondary */ } + } + + private static AgentDayUsage Day(int daysAgo, string agent, decimal cost, long subscriptionTokens = 0) + => new(Today.AddDays(-daysAgo), agent, cost, 1000, subscriptionTokens, 1); + + /// Four weeks of the same course, then today. + private static List Course(decimal perDay, decimal today, string agent = "ads") + { + var rows = Enumerable.Range(1, 28).Select(d => Day(d, agent, perDay)).ToList(); + rows.Add(Day(0, agent, today)); + return rows; + } + + [Fact] + public void A_unit_adds_up_itself_and_everything_below_it() + { + var lines = UnitCosts.Build(Org(), [Day(0, "ads", 3), Day(0, "seller", 2), Day(0, "bookkeeper", 1)], Today); + + lines.Single(l => l.UnitId is null).Today.ShouldBe(6); + lines.Single(l => l.UnitId == "marketing").Today.ShouldBe(5, "amazon is below marketing"); + lines.Single(l => l.UnitId == "amazon").Today.ShouldBe(2); + lines.Single(l => l.UnitId == "").Today.ShouldBe(1, "the top level stands apart"); + lines.Select(l => l.Depth).ShouldBe([0, 1, 2, 1]); + } + + [Fact] + public void The_maximum_is_the_sum_of_the_agents_limits_and_unknown_when_one_has_none() + { + UnitCosts.Build(Org(), [], Today).Single(l => l.UnitId == "marketing").MaxPerDay.ShouldBe(20); + UnitCosts.Build(Org(adsLimit: 0), [], Today).Single(l => l.UnitId == "marketing").MaxPerDay.ShouldBeNull(); + } + + [Fact] + public void Months_are_compared_and_subscription_runs_count_as_quota_not_as_free() + { + var lines = UnitCosts.Build(Org(), [Day(0, "ads", 4, subscriptionTokens: 900), Day(12, "ads", 6)], Today); + var marketing = lines.Single(l => l.UnitId == "marketing"); + + marketing.ThisMonth.ShouldBe(4); + marketing.PreviousMonth.ShouldBe(6, "12 days before 10 March lies in February"); + marketing.SubscriptionTokensToday.ShouldBe(900); + } + + [Fact] + public void A_day_far_above_the_own_course_is_reported_though_nothing_is_exceeded() + { + var alerts = UnitCosts.Alerts(UnitCosts.Build(Org(), Course(1.5m, 4m), Today), new CostWatchSettings(), Today); + + alerts.ShouldContain(a => a.Kind == CostAlertKind.Deviation && a.Scope == "marketing"); + alerts.ShouldNotContain(a => a.Kind == CostAlertKind.NearLimit, "4 $ of 20 $ is far from the limits"); + } + + [Fact] + public void Small_amounts_and_a_short_history_raise_nothing() + { + UnitCosts.Alerts(UnitCosts.Build(Org(), Course(0.01m, 0.5m), Today), new CostWatchSettings(), Today) + .ShouldNotContain(a => a.Kind == CostAlertKind.Deviation, "below a dollar a day nothing deviates"); + + var shortHistory = new List { Day(1, "ads", 1), Day(2, "ads", 1), Day(0, "ads", 9) }; + UnitCosts.Alerts(UnitCosts.Build(Org(), shortHistory, Today), new CostWatchSettings(), Today) + .ShouldNotContain(a => a.Kind == CostAlertKind.Deviation, "two days are no course"); + } + + [Fact] + public void A_unit_near_the_sum_of_its_limits_is_reported_before_its_agents_run_into_them() + { + var alerts = UnitCosts.Alerts(UnitCosts.Build(Org(), [Day(0, "ads", 9), Day(0, "listing", 4), Day(0, "seller", 4)], Today), + new CostWatchSettings(), Today); + + alerts.ShouldContain(a => a.Kind == CostAlertKind.NearLimit && a.Scope == "marketing"); + } + + [Fact] + public void A_monthly_total_is_reported_at_the_highest_threshold_crossed() + { + var settings = new CostWatchSettings { MonthlyThresholdsUsd = [5, 10, 50] }; + + var alert = UnitCosts.Alerts(UnitCosts.Build(Org(), [Day(0, "ads", 7), Day(3, "seller", 5)], Today), settings, Today) + .Single(a => a.Kind == CostAlertKind.MonthlyThreshold); + + alert.Scope.ShouldBe("instance:10"); + } + + [Fact] + public async Task The_watch_raises_an_alert_once_and_hands_it_to_the_human() + { + var storage = new SqliteStorage(Path.Combine(_directory, "state.db")); + var usage = new SqliteUsageRepository(storage); + var tasks = new SqliteTaskRepository(storage); + var board = new TaskboardService(tasks, Path.Combine(_directory, "tasks")); + var now = DateTime.Now; + foreach (var d in Enumerable.Range(1, 28)) + await usage.RecordAsync(new RunUsage { AgentId = "ads", Model = "m", CostUsd = 1m, CostIsKnown = true, Status = "Completed", OccurredAt = now.AddDays(-d) }, default); + await usage.RecordAsync(new RunUsage { AgentId = "ads", Model = "m", CostUsd = 5m, CostIsKnown = true, Status = "Completed", OccurredAt = now }, default); + + var watch = new CostWatch(storage, usage, () => Org(), () => new CostWatchSettings(), board, NullLogger.Instance); + + (await watch.CheckAsync(default)).ShouldContain(a => a.Kind == CostAlertKind.Deviation && a.Scope == "marketing"); + (await watch.CheckAsync(default)).ShouldBeEmpty("once a day, not every quarter of an hour"); + + (await board.ListAsync(new TaskQuery { Assignee = TaskAssignee.Human }, default)).ShouldContain(t => t.Title.Contains("marketing")); + (await watch.RecentAsync(7, default)).ShouldNotBeEmpty(); + (await watch.ReportAsync(default)).Single(l => l.UnitId == "marketing").Today.ShouldBe(5); + } +}