Kosten je Einheit aufrechnen und Abweichungen melden (#699)

- IUsageRepository.GetDailyByAgentAsync: Tagessummen je Agent, Abo-Tokens getrennt
- UnitCosts: je Einheit samt Untereinheiten heute/7/30 Tage, Monat, Vormonat,
  Durchschnittstag (28 Tage, ab 7 Tagen Verlauf), Maximum = Summe der Agentengrenzen
- Meldungen: Abweichung vom eigenen Verlauf, Naehe zur Grenzsumme, Monatsschwellen
  der Instanz; CostWatch alle 15 min, je Einheit/Tag einmal (Tabelle CostAlerts),
  als Aufgabe an den Menschen; schaltet nichts ab
- API /usage/units, /usage/cost-alerts; Zeitreihe gruppiert nach Einheit
- WebUI: Karte "Kosten je Einheit" mit Meldungen in der Verbrauchsansicht; i18n en/de
- Einstellungen instance.json costWatch
- Tests: UnitCostsTests (8)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-01 09:09:42 +02:00
co-authored by Claude Opus 5.5
parent 7eb211fd06
commit da34b215f7
15 changed files with 667 additions and 2 deletions
@@ -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<CostAlertDto>());
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)