using System.Text.Json; using ClawdDotNet.Core.Api; using ClawdDotNet.Core.Api.Models; using Shouldly; namespace ClawdDotNet.Core.Tests.Api; /// /// 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. /// 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); } }