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:
co-authored by
Claude Opus 5.5
parent
7eb211fd06
commit
da34b215f7
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user