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
@@ -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;
/// <summary>
/// #699: Costs per unit are added up, not capped — and what changed is reported, once.
/// </summary>
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);
/// <summary>marketing (ads, listing) › amazon (seller); bookkeeper on the top level.</summary>
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);
/// <summary>Four weeks of the same course, then today.</summary>
private static List<AgentDayUsage> 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<AgentDayUsage> { 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);
}
}