Modellpreise live vom Anbieter beziehen

Zweiter Teil von B4. Die Preise standen fest im Code — eine Tabelle mit einer
Handvoll Modelle, die bereits veraltet war. Ausgerechnet das Standardmodell der
Agenten fehlte darin, und CalculateCost gab fuer unbekannte Modelle
stillschweigend 0 zurueck. Die Kostenanzeige war damit nicht bloss ungenau,
sondern blind: Sie meldete 0 Euro, waehrend echte Kosten anfielen.

ModelInfo traegt jetzt die Preisangaben aus dem /models-Endpunkt. OpenRouter
liefert sie als Text und pro einzelnem Token; die Umrechnung auf eine Million
Token laeuft ueber InvariantCulture, sonst wuerde eine deutsche Systemsprache
0.000003 als drei lesen.

Halbe Angaben werden verworfen: Ein Modell, bei dem nur der Eingabepreis
vorliegt, ergaebe eine plausibel aussehende, aber falsche Summe.

ModelPricingCatalog haelt die Preise und liefert eine Kostenschaetzung, die
ausweist, ob sie belastbar ist. Der Statusdienst laedt den Katalog beim Start und
markiert Laeufe ohne Preisangabe sichtbar — in der Zeile mit einem Warnzeichen,
im Tooltip mit den betroffenen Modellnamen und dem Hinweis, dass die Summe
unvollstaendig ist.

284 Tests gruen (136 Core, 148 Tools).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-28 10:09:39 +02:00
co-authored by Claude Opus 4.8
parent cbb8ac22bc
commit 93f47aca3a
6 changed files with 352 additions and 29 deletions
@@ -0,0 +1,162 @@
using System.Text.Json;
using ClawdDotNet.Core.Api;
using ClawdDotNet.Core.Api.Models;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Api;
/// <summary>
/// B4, zweiter Teil: Die Preise standen fest im Code, waren veraltet, und ausgerechnet
/// das Standardmodell der Agenten fehlte. CalculateCost gab dafür stillschweigend 0
/// zurück — die Anzeige meldete also 0 €, während echte Kosten anfielen.
/// </summary>
public sealed class ModelPricingTests
{
// ═══════════════════════════════════════════════════════════
// Umrechnung der OpenRouter-Angaben
// ═══════════════════════════════════════════════════════════
[Theory]
[InlineData("0.000003", 3.0)] // 3 $ pro 1M Token
[InlineData("0.000015", 15.0)]
[InlineData("0.0000001", 0.1)]
[InlineData("0", 0.0)]
public void Preise_pro_Token_werden_auf_eine_Million_hochgerechnet(string raw, double expected)
{
ModelPricing.ParsePerToken(raw).ShouldBe((decimal)expected);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("keine-zahl")]
[InlineData("-0.001")]
public void Unbrauchbare_Angaben_ergeben_null(string? raw)
{
ModelPricing.ParsePerToken(raw).ShouldBeNull();
}
[Fact]
public void Die_Umrechnung_ist_unabhaengig_von_der_Systemsprache()
{
// Mit deutscher Kultur würde "0.000003" sonst als 3 statt als 0,000003 gelesen.
var original = Thread.CurrentThread.CurrentCulture;
try
{
Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("de-DE");
ModelPricing.ParsePerToken("0.000003").ShouldBe(3.0m);
}
finally
{
Thread.CurrentThread.CurrentCulture = original;
}
}
// ═══════════════════════════════════════════════════════════
// Auslesen aus der API-Antwort
// ═══════════════════════════════════════════════════════════
[Fact]
public void Preise_werden_aus_der_Modellantwort_gelesen()
{
var item = JsonDocument.Parse("""
{ "id": "anthropic/claude-sonnet-4-5",
"pricing": { "prompt": "0.000003", "completion": "0.000015" } }
""").RootElement;
var pricing = OpenRouterClient.ReadPricing(item);
pricing.ShouldNotBeNull();
pricing.InputPer1M.ShouldBe(3.0m);
pricing.OutputPer1M.ShouldBe(15.0m);
}
[Theory]
[InlineData("""{ "id": "x" }""")]
[InlineData("""{ "id": "x", "pricing": {} }""")]
[InlineData("""{ "id": "x", "pricing": { "prompt": "0.000003" } }""")]
[InlineData("""{ "id": "x", "pricing": "kostenlos" }""")]
public void Unvollstaendige_Preisangaben_ergeben_null(string json)
{
// Halbe Angaben sind schlimmer als keine — sie ergäben eine plausibel
// aussehende, aber falsche Summe.
OpenRouterClient.ReadPricing(JsonDocument.Parse(json).RootElement).ShouldBeNull();
}
// ═══════════════════════════════════════════════════════════
// Katalog und Kostenschätzung
// ═══════════════════════════════════════════════════════════
private static ModelPricingCatalog CatalogWith(params (string Id, decimal In, decimal Out)[] models)
{
var catalog = new ModelPricingCatalog();
catalog.Load(models.Select(m => new ModelInfo
{
Id = m.Id,
Name = m.Id,
Pricing = new ModelPricing(m.In, m.Out)
}));
return catalog;
}
[Fact]
public void Die_Kosten_werden_aus_Prompt_und_Completion_getrennt_berechnet()
{
var catalog = CatalogWith(("anthropic/claude-sonnet-4-5", 3m, 15m));
// 95.000 Eingabe + 5.000 Ausgabe = 0,285 + 0,075 = 0,36 $
var estimate = catalog.Estimate("anthropic/claude-sonnet-4-5", 95_000, 5_000);
estimate.IsKnown.ShouldBeTrue();
estimate.Usd.ShouldBe(0.36m);
}
[Fact]
public void Ein_unbekanntes_Modell_wird_als_unbekannt_gemeldet_statt_als_null_Kosten()
{
// Der Kern des alten Fehlers: 0 sah aus wie kostenlos.
var catalog = CatalogWith(("openai/gpt-4o", 2.5m, 10m));
var estimate = catalog.Estimate("anthropic/claude-sonnet-4-5", 100_000, 5_000);
estimate.IsKnown.ShouldBeFalse();
estimate.Model.ShouldBe("anthropic/claude-sonnet-4-5");
}
[Fact]
public void Grossschreibung_der_Modell_ID_spielt_keine_Rolle()
{
var catalog = CatalogWith(("openai/gpt-4o", 2.5m, 10m));
catalog.IsKnown("OpenAI/GPT-4o").ShouldBeTrue();
}
[Fact]
public void Modelle_ohne_Preisangabe_landen_nicht_im_Katalog()
{
var catalog = new ModelPricingCatalog();
catalog.Load([
new ModelInfo { Id = "mit-preis", Pricing = new ModelPricing(1m, 2m) },
new ModelInfo { Id = "ohne-preis", Pricing = null }
]);
catalog.IsKnown("mit-preis").ShouldBeTrue();
catalog.IsKnown("ohne-preis").ShouldBeFalse();
catalog.Count.ShouldBe(1);
}
[Fact]
public void Ein_leerer_Katalog_kennt_kein_Modell()
{
new ModelPricingCatalog().Estimate("beliebig", 1000, 100).IsKnown.ShouldBeFalse();
}
[Fact]
public void Erneutes_Laden_aktualisiert_die_Preise()
{
var catalog = CatalogWith(("m", 1m, 2m));
catalog.Load([new ModelInfo { Id = "m", Pricing = new ModelPricing(5m, 10m) }]);
catalog.Get("m")!.InputPer1M.ShouldBe(5m);
}
}