Stufe 4c (#764): Bezeichner auf Englisch - Typen, Methoden, Felder, Parameter, Locals, Testnamen
Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt). Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e): Properties, Record-Parameter, Enum-Member sowie die Parameter der Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
837a634a22
commit
77062facc9
@@ -0,0 +1,172 @@
|
||||
using ClawdDotNet.Core.Accounting;
|
||||
using ClawdDotNet.Core.Api;
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Accounting;
|
||||
|
||||
/// <summary>The cost forecast (#595): basis per task, projection, other runs.</summary>
|
||||
public sealed class CostForecastTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 9, 15, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static readonly AgentConfig Messages = new()
|
||||
{
|
||||
AgentId = "nachrichten",
|
||||
Model = "anthropic/claude-haiku-4.5",
|
||||
SystemPrompt = new string('x', 4000)
|
||||
};
|
||||
|
||||
private static TaskItem Hourly(string id, string assignee = "@new:nachrichten", TaskItemStatus status = TaskItemStatus.Todo)
|
||||
=> new()
|
||||
{
|
||||
Id = id, Title = id, Assignee = assignee, Status = status,
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "5 * * * *", TimeZone = "Europe/Berlin" }
|
||||
};
|
||||
|
||||
private static RunReceipt Receipt(string taskId, decimal usd, int hoursBack = 1)
|
||||
=> new()
|
||||
{
|
||||
TaskId = taskId, AgentId = "nachrichten", CostUsd = usd, CostIsKnown = true,
|
||||
OccurredAt = Now.AddHours(-hoursBack)
|
||||
};
|
||||
|
||||
private static Dictionary<string, IReadOnlyList<RunReceipt>> Receipts(params (string Id, RunReceipt[] Liste)[] entries)
|
||||
=> entries.ToDictionary(e => e.Id, e => (IReadOnlyList<RunReceipt>)e.Liste);
|
||||
|
||||
private static InstanceForecast Compute(
|
||||
IReadOnlyList<TaskItem> tasks,
|
||||
Dictionary<string, IReadOnlyList<RunReceipt>>? receipts = null,
|
||||
IReadOnlyList<RunUsage>? usage = null,
|
||||
Func<string, int, int, CostEstimate>? estimate = null)
|
||||
=> CostForecast.Calculate(tasks, receipts ?? [], usage ?? [], [Messages], estimate, Now);
|
||||
|
||||
[Fact]
|
||||
public void Average_of_the_last_runs_times_runs_per_day()
|
||||
{
|
||||
var p = Compute([Hourly("t-1")], Receipts(("t-1", [Receipt("t-1", 0.01m), Receipt("t-1", 0.03m, 2)])));
|
||||
|
||||
var line = p.Aufgaben.Single();
|
||||
line.Grundlage.ShouldBe(ForecastBasis.Laeufe);
|
||||
line.Stichprobe.ShouldBe(2);
|
||||
line.KostenJeLauf.ShouldBe(0.02m);
|
||||
line.LaeufeProTag.ShouldBe(24);
|
||||
line.KostenProTag.ShouldBe(0.48m);
|
||||
p.SummeProMonat.ShouldBe(14.4m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_own_runs_the_agents_average_applies()
|
||||
{
|
||||
var p = Compute([Hourly("t-1"), Hourly("t-2")], Receipts(("t-1", [Receipt("t-1", 0.02m)])));
|
||||
|
||||
var newOne = p.Aufgaben.Single(z => z.TaskId == "t-2");
|
||||
newOne.Grundlage.ShouldBe(ForecastBasis.Agent);
|
||||
newOne.KostenJeLauf.ShouldBe(0.02m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Before_the_first_run_it_estimates_from_the_model_price()
|
||||
{
|
||||
var task = Hourly("t-1");
|
||||
CostEstimate Price(string model, int prompt, int completion)
|
||||
=> new(prompt / 1_000_000m + completion * 5 / 1_000_000m, true, model);
|
||||
|
||||
var p = Compute([task], estimate: Price);
|
||||
|
||||
var line = p.Aufgaben.Single();
|
||||
line.Grundlage.ShouldBe(ForecastBasis.Schaetzung);
|
||||
var expected = CostForecast.EstimatedPromptTokens(task, Messages) / 1_000_000m
|
||||
+ CostForecast.AssumedSteps * CostForecast.AssumedAnswerTokensPerStep * 5 / 1_000_000m;
|
||||
line.KostenJeLauf.ShouldBe(Math.Round(expected, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_price_and_runs_it_stays_honestly_unknown()
|
||||
{
|
||||
var p = Compute([Hourly("t-1")], estimate: (m, _, _) => new CostEstimate(0m, false, m));
|
||||
|
||||
var line = p.Aufgaben.Single();
|
||||
line.Grundlage.ShouldBe(ForecastBasis.Unbekannt);
|
||||
line.KostenJeLauf.ShouldBeNull();
|
||||
line.KostenProTag.ShouldBe(0m);
|
||||
p.OhneKostenbasis.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Paused_and_human_tasks_cost_nothing()
|
||||
=> Compute([Hourly("t-p", status: TaskItemStatus.Backlog), Hourly("t-h", "@human")])
|
||||
.Aufgaben.ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void Other_runs_are_the_daily_average_outside_the_tasks()
|
||||
{
|
||||
var p = Compute(
|
||||
[Hourly("t-1")],
|
||||
Receipts(("t-1", [Receipt("t-1", 0.14m)])),
|
||||
[new RunUsage { AgentId = "nachrichten", CostUsd = 0.70m, CostIsKnown = true, OccurredAt = Now.AddDays(-1) }]);
|
||||
|
||||
p.SonstigeProTag.ShouldBe(0.08m); // (0,70 - 0,14) / 7
|
||||
p.AufgabenProTag.ShouldBe(3.36m); // 0,14 * 24
|
||||
p.SummeProTag.ShouldBe(3.44m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Budget_rejections_do_not_lower_the_average()
|
||||
{
|
||||
// #608: A rejection stands as a receipt of 0 USD in the history - it is not a run.
|
||||
var rejected = Receipt("t-1", 0m) with { Status = "BudgetExceeded" };
|
||||
|
||||
var line = Compute([Hourly("t-1")], Receipts(("t-1", [Receipt("t-1", 0.02m, 2), rejected])))
|
||||
.Aufgaben.Single();
|
||||
|
||||
line.KostenJeLauf.ShouldBe(0.02m);
|
||||
line.Stichprobe.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Warns_when_the_tasks_exceed_the_agents_daily_budget()
|
||||
{
|
||||
var tight = new AgentConfig { AgentId = "nachrichten", Model = Messages.Model, SystemPrompt = "x" };
|
||||
tight.Budget.DailyCostUsd = 0.40m;
|
||||
|
||||
var p = CostForecast.Calculate(
|
||||
[Hourly("t-1")], Receipts(("t-1", [Receipt("t-1", 0.02m)])), [], [tight], null, Now);
|
||||
|
||||
var warning = p.Budgetwarnungen.ShouldHaveSingleItem();
|
||||
warning.AgentId.ShouldBe("nachrichten");
|
||||
warning.KostenProTag.ShouldBe(0.48m);
|
||||
warning.BudgetProTag.ShouldBe(0.40m);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)] // no limit set
|
||||
[InlineData(1.0)] // budget suffices
|
||||
public void Without_limit_or_with_headroom_no_budget_warning(double budget)
|
||||
{
|
||||
var agent = new AgentConfig { AgentId = "nachrichten", Model = Messages.Model, SystemPrompt = "x" };
|
||||
agent.Budget.DailyCostUsd = (decimal)budget;
|
||||
|
||||
CostForecast.Calculate([Hourly("t-1")], Receipts(("t-1", [Receipt("t-1", 0.02m)])), [], [agent], null, Now)
|
||||
.Budgetwarnungen.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tool_job_counts_only_actual_wakeups()
|
||||
{
|
||||
var job = new TaskItem
|
||||
{
|
||||
Id = "tj", Title = "Telegram", Assignee = "@nachrichten", Type = TaskItemType.ToolJob,
|
||||
Status = TaskItemStatus.Todo,
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "* * * * *", TimeZone = "UTC" }
|
||||
};
|
||||
|
||||
var p = Compute([job], Receipts(("tj", [Receipt("tj", 0.07m), Receipt("tj", 0.07m, 30)])));
|
||||
|
||||
var line = p.Aufgaben.Single();
|
||||
line.KostenProTag.ShouldBe(0.02m, "zwei Weckungen in sieben Tagen, nicht 1.440 Ticks je Tag");
|
||||
line.LaeufeProTag.ShouldBe(0.29);
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
using ClawdDotNet.Core.Accounting;
|
||||
using ClawdDotNet.Core.Api;
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Accounting;
|
||||
|
||||
/// <summary>The cost forecast (#595): basis per task, projection, other runs.</summary>
|
||||
public sealed class KostenPrognoseTests
|
||||
{
|
||||
private static readonly DateTime Jetzt = new(2026, 9, 15, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static readonly AgentConfig Nachrichten = new()
|
||||
{
|
||||
AgentId = "nachrichten",
|
||||
Model = "anthropic/claude-haiku-4.5",
|
||||
SystemPrompt = new string('x', 4000)
|
||||
};
|
||||
|
||||
private static TaskItem Stuendlich(string id, string assignee = "@new:nachrichten", TaskItemStatus status = TaskItemStatus.Todo)
|
||||
=> new()
|
||||
{
|
||||
Id = id, Title = id, Assignee = assignee, Status = status,
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "5 * * * *", TimeZone = "Europe/Berlin" }
|
||||
};
|
||||
|
||||
private static RunReceipt Beleg(string taskId, decimal usd, int stundenZurueck = 1)
|
||||
=> new()
|
||||
{
|
||||
TaskId = taskId, AgentId = "nachrichten", CostUsd = usd, CostIsKnown = true,
|
||||
OccurredAt = Jetzt.AddHours(-stundenZurueck)
|
||||
};
|
||||
|
||||
private static Dictionary<string, IReadOnlyList<RunReceipt>> Belege(params (string Id, RunReceipt[] Liste)[] eintraege)
|
||||
=> eintraege.ToDictionary(e => e.Id, e => (IReadOnlyList<RunReceipt>)e.Liste);
|
||||
|
||||
private static InstanzPrognose Rechne(
|
||||
IReadOnlyList<TaskItem> aufgaben,
|
||||
Dictionary<string, IReadOnlyList<RunReceipt>>? belege = null,
|
||||
IReadOnlyList<RunUsage>? verbrauch = null,
|
||||
Func<string, int, int, CostEstimate>? schaetzen = null)
|
||||
=> KostenPrognose.Berechne(aufgaben, belege ?? [], verbrauch ?? [], [Nachrichten], schaetzen, Jetzt);
|
||||
|
||||
[Fact]
|
||||
public void Mittelwert_der_letzten_Laeufe_mal_Laeufe_je_Tag()
|
||||
{
|
||||
var p = Rechne([Stuendlich("t-1")], Belege(("t-1", [Beleg("t-1", 0.01m), Beleg("t-1", 0.03m, 2)])));
|
||||
|
||||
var zeile = p.Aufgaben.Single();
|
||||
zeile.Grundlage.ShouldBe(PrognoseGrundlage.Laeufe);
|
||||
zeile.Stichprobe.ShouldBe(2);
|
||||
zeile.KostenJeLauf.ShouldBe(0.02m);
|
||||
zeile.LaeufeProTag.ShouldBe(24);
|
||||
zeile.KostenProTag.ShouldBe(0.48m);
|
||||
p.SummeProMonat.ShouldBe(14.4m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ohne_eigene_Laeufe_gilt_der_Schnitt_des_Agenten()
|
||||
{
|
||||
var p = Rechne([Stuendlich("t-1"), Stuendlich("t-2")], Belege(("t-1", [Beleg("t-1", 0.02m)])));
|
||||
|
||||
var neu = p.Aufgaben.Single(z => z.TaskId == "t-2");
|
||||
neu.Grundlage.ShouldBe(PrognoseGrundlage.Agent);
|
||||
neu.KostenJeLauf.ShouldBe(0.02m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vor_dem_ersten_Lauf_schaetzt_sie_aus_dem_Modellpreis()
|
||||
{
|
||||
var aufgabe = Stuendlich("t-1");
|
||||
CostEstimate Preis(string modell, int prompt, int completion)
|
||||
=> new(prompt / 1_000_000m + completion * 5 / 1_000_000m, true, modell);
|
||||
|
||||
var p = Rechne([aufgabe], schaetzen: Preis);
|
||||
|
||||
var zeile = p.Aufgaben.Single();
|
||||
zeile.Grundlage.ShouldBe(PrognoseGrundlage.Schaetzung);
|
||||
var erwartet = KostenPrognose.GeschaetztePromptTokens(aufgabe, Nachrichten) / 1_000_000m
|
||||
+ KostenPrognose.AnnahmeSchritte * KostenPrognose.AnnahmeAntwortTokensJeSchritt * 5 / 1_000_000m;
|
||||
zeile.KostenJeLauf.ShouldBe(Math.Round(erwartet, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ohne_Preis_und_Laeufe_bleibt_sie_ehrlich_unbekannt()
|
||||
{
|
||||
var p = Rechne([Stuendlich("t-1")], schaetzen: (m, _, _) => new CostEstimate(0m, false, m));
|
||||
|
||||
var zeile = p.Aufgaben.Single();
|
||||
zeile.Grundlage.ShouldBe(PrognoseGrundlage.Unbekannt);
|
||||
zeile.KostenJeLauf.ShouldBeNull();
|
||||
zeile.KostenProTag.ShouldBe(0m);
|
||||
p.OhneKostenbasis.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pausierte_und_menschliche_Aufgaben_kosten_nichts()
|
||||
=> Rechne([Stuendlich("t-p", status: TaskItemStatus.Backlog), Stuendlich("t-h", "@human")])
|
||||
.Aufgaben.ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void Sonstige_Laeufe_sind_das_Tagesmittel_ausserhalb_der_Aufgaben()
|
||||
{
|
||||
var p = Rechne(
|
||||
[Stuendlich("t-1")],
|
||||
Belege(("t-1", [Beleg("t-1", 0.14m)])),
|
||||
[new RunUsage { AgentId = "nachrichten", CostUsd = 0.70m, CostIsKnown = true, OccurredAt = Jetzt.AddDays(-1) }]);
|
||||
|
||||
p.SonstigeProTag.ShouldBe(0.08m); // (0,70 - 0,14) / 7
|
||||
p.AufgabenProTag.ShouldBe(3.36m); // 0,14 * 24
|
||||
p.SummeProTag.ShouldBe(3.44m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Budgetablehnungen_druecken_den_Schnitt_nicht()
|
||||
{
|
||||
// #608: A rejection stands as a receipt of 0 USD in the history - it is not a run.
|
||||
var abgelehnt = Beleg("t-1", 0m) with { Status = "BudgetExceeded" };
|
||||
|
||||
var zeile = Rechne([Stuendlich("t-1")], Belege(("t-1", [Beleg("t-1", 0.02m, 2), abgelehnt])))
|
||||
.Aufgaben.Single();
|
||||
|
||||
zeile.KostenJeLauf.ShouldBe(0.02m);
|
||||
zeile.Stichprobe.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Warnt_wenn_die_Aufgaben_das_Tagesbudget_des_Agenten_uebersteigen()
|
||||
{
|
||||
var knapp = new AgentConfig { AgentId = "nachrichten", Model = Nachrichten.Model, SystemPrompt = "x" };
|
||||
knapp.Budget.DailyCostUsd = 0.40m;
|
||||
|
||||
var p = KostenPrognose.Berechne(
|
||||
[Stuendlich("t-1")], Belege(("t-1", [Beleg("t-1", 0.02m)])), [], [knapp], null, Jetzt);
|
||||
|
||||
var warnung = p.Budgetwarnungen.ShouldHaveSingleItem();
|
||||
warnung.AgentId.ShouldBe("nachrichten");
|
||||
warnung.KostenProTag.ShouldBe(0.48m);
|
||||
warnung.BudgetProTag.ShouldBe(0.40m);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)] // no limit set
|
||||
[InlineData(1.0)] // budget suffices
|
||||
public void Ohne_Grenze_oder_mit_Luft_keine_Budgetwarnung(double budget)
|
||||
{
|
||||
var agent = new AgentConfig { AgentId = "nachrichten", Model = Nachrichten.Model, SystemPrompt = "x" };
|
||||
agent.Budget.DailyCostUsd = (decimal)budget;
|
||||
|
||||
KostenPrognose.Berechne([Stuendlich("t-1")], Belege(("t-1", [Beleg("t-1", 0.02m)])), [], [agent], null, Jetzt)
|
||||
.Budgetwarnungen.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Werkzeugjob_zaehlt_nur_tatsaechliche_Weckungen()
|
||||
{
|
||||
var job = new TaskItem
|
||||
{
|
||||
Id = "tj", Title = "Telegram", Assignee = "@nachrichten", Type = TaskItemType.ToolJob,
|
||||
Status = TaskItemStatus.Todo,
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "* * * * *", TimeZone = "UTC" }
|
||||
};
|
||||
|
||||
var p = Rechne([job], Belege(("tj", [Beleg("tj", 0.07m), Beleg("tj", 0.07m, 30)])));
|
||||
|
||||
var zeile = p.Aufgaben.Single();
|
||||
zeile.KostenProTag.ShouldBe(0.02m, "zwei Weckungen in sieben Tagen, nicht 1.440 Ticks je Tag");
|
||||
zeile.LaeufeProTag.ShouldBe(0.29);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user