From 93f47aca3a59beafa4dfecfd09de47604694274c Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 28 Jul 2026 10:09:39 +0200 Subject: [PATCH] Modellpreise live vom Anbieter beziehen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Services/OpenRouterStatusService.cs | 99 ++++++++--- frm_main.cs | 5 + .../Api/ModelPricingCatalog.cs | 64 +++++++ src/ClawdDotNet.Core/Api/Models/ModelInfo.cs | 31 ++++ src/ClawdDotNet.Core/Api/OpenRouterClient.cs | 20 ++- .../Api/ModelPricingTests.cs | 162 ++++++++++++++++++ 6 files changed, 352 insertions(+), 29 deletions(-) create mode 100644 src/ClawdDotNet.Core/Api/ModelPricingCatalog.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Api/ModelPricingTests.cs diff --git a/Services/OpenRouterStatusService.cs b/Services/OpenRouterStatusService.cs index 117bb7d..cd1b059 100644 --- a/Services/OpenRouterStatusService.cs +++ b/Services/OpenRouterStatusService.cs @@ -1,6 +1,8 @@ using System.Collections.Concurrent; using System.Net.Http.Headers; using System.Text.Json; +using ClawdDotNet.Core.Api; +using ClawdDotNet.Core.Api.Models; namespace ClawdDotNet.Services; @@ -11,22 +13,15 @@ public sealed class OpenRouterStatusService : IDisposable private readonly ConcurrentBag _usageRecords = new(); - // Preise pro 1M Token (Input / Output) in USD — gängige OpenRouter-Modelle - private static readonly Dictionary ModelPricing = new(StringComparer.OrdinalIgnoreCase) - { - ["anthropic/claude-sonnet-4"] = (3.00, 15.00), - ["anthropic/claude-haiku-4.5"] = (0.80, 4.00), - ["anthropic/claude-opus-4"] = (15.00, 75.00), - ["openai/gpt-4o"] = (2.50, 10.00), - ["openai/gpt-4o-mini"] = (0.15, 0.60), - ["openai/gpt-4.1"] = (2.00, 8.00), - ["openai/gpt-4.1-mini"] = (0.40, 1.60), - ["openai/gpt-4.1-nano"] = (0.10, 0.40), - ["google/gemini-2.5-flash"] = (0.15, 0.60), - ["google/gemini-2.5-pro"] = (1.25, 10.00), - ["google/gemini-3.1-flash-lite"] = (0.00, 0.00), - ["deepseek/deepseek-chat-v3-0324"] = (0.14, 0.28), - }; + /// + /// Preise kommen vom /models-Endpunkt statt aus einer fest verdrahteten Tabelle. + /// Die alte Tabelle war veraltet und enthielt ausgerechnet das Standardmodell der + /// Agenten nicht — die Anzeige meldete dafür stillschweigend 0 €. + /// + private readonly ModelPricingCatalog _pricing = new(); + + /// Modelle, für die keine Preise vorliegen — werden in der Anzeige benannt. + private readonly ConcurrentDictionary _modelsWithoutPricing = new(); private const double UsdToEur = 0.92; @@ -59,19 +54,43 @@ public sealed class OpenRouterStatusService : IDisposable public void RecordUsage(string model, int promptTokens, int completionTokens) { - var cost = CalculateCost(model, promptTokens, completionTokens); - _usageRecords.Add(new UsageRecord(DateTime.Now, model, promptTokens, completionTokens, cost)); + var estimate = _pricing.Estimate(model, promptTokens, completionTokens); + + if (!estimate.IsKnown) + _modelsWithoutPricing.TryAdd(model, 0); + + _usageRecords.Add(new UsageRecord( + DateTime.Now, model, promptTokens, completionTokens, (double)estimate.Usd, estimate.IsKnown)); + UpdateCreditsText(); OnStatusUpdated?.Invoke(); } - private static double CalculateCost(string model, int promptTokens, int completionTokens) + /// + /// Lädt die aktuellen Modellpreise. Ohne diesen Aufruf bleibt der Katalog leer und + /// alle Kosten werden als unbekannt ausgewiesen. + /// + public async Task LoadPricingAsync(OpenRouterClient client, CancellationToken ct = default) { - if (!ModelPricing.TryGetValue(model, out var pricing)) - return 0; + try + { + var models = await client.GetAvailableModelsAsync(ct); + _pricing.Load(models); - return (promptTokens / 1_000_000.0 * pricing.InputPer1M) + - (completionTokens / 1_000_000.0 * pricing.OutputPer1M); + // Modelle, die bisher als unbekannt galten, sind jetzt vielleicht bekannt. + foreach (var model in _modelsWithoutPricing.Keys) + { + if (_pricing.IsKnown(model)) + _modelsWithoutPricing.TryRemove(model, out _); + } + + UpdateCreditsText(); + OnStatusUpdated?.Invoke(); + } + catch (Exception) + { + // Ohne Preise bleibt die Anzeige ehrlich unbekannt statt falsch null. + } } private async Task CheckStatusAsync() @@ -151,8 +170,13 @@ public sealed class OpenRouterStatusService : IDisposable var costLastHour = lastHour.Sum(r => r.CostUsd); var costLast24h = last24h.Sum(r => r.CostUsd); + // Ein Hinweis, sobald Läufe dabei sind, deren Kosten nicht bezifferbar sind — + // sonst liest sich eine zu niedrige Summe wie eine vollständige. + var unpriced = last24h.Count(r => !r.CostIsKnown); + var warning = unpriced > 0 ? " ⚠" : ""; + CreditsText = $"1h: {tokensLastHour:N0} Tok (~{costLastHour * UsdToEur:F4}€) | " + - $"24h: {tokensLast24h:N0} Tok (~{costLast24h * UsdToEur:F4}€)"; + $"24h: {tokensLast24h:N0} Tok (~{costLast24h * UsdToEur:F4}€){warning}"; // Detaillierter Tooltip: Pro-Model-Aufschlüsselung (letzte 24h) var modelGroups = last24h @@ -183,16 +207,34 @@ public sealed class OpenRouterStatusService : IDisposable sb.AppendLine($"▸ {shortName}"); sb.AppendLine($" {runs}x Runs | {total:N0} Tokens ({prompt:N0} in / {completion:N0} out)"); - if (ModelPricing.TryGetValue(modelName, out var pricing)) - sb.AppendLine($" Preis: ${pricing.InputPer1M}/1M in, ${pricing.OutputPer1M}/1M out"); + if (_pricing.Get(modelName) is { } pricing) + { + sb.AppendLine($" Preis: ${pricing.InputPer1M:0.####}/1M in, ${pricing.OutputPer1M:0.####}/1M out"); + sb.AppendLine($" Kosten: ${cost:F4} (~{cost * UsdToEur:F4}€)"); + } + else + { + sb.AppendLine(" Kosten: unbekannt — für dieses Modell liegen keine Preise vor"); + } - sb.AppendLine($" Kosten: ${cost:F4} (~{cost * UsdToEur:F4}€)"); sb.AppendLine(); } var totalCost = last24h.Sum(r => r.CostUsd); sb.AppendLine($"═══ Gesamt: ${totalCost:F4} (~{totalCost * UsdToEur:F4}€) ═══"); + if (unpriced > 0) + { + sb.AppendLine(); + sb.AppendLine($"⚠ {unpriced} Lauf/Läufe ohne Preisangabe — die Summe ist unvollständig."); + sb.AppendLine($" Betroffene Modelle: {string.Join(", ", _modelsWithoutPricing.Keys.Order())}"); + } + + if (_pricing.LastUpdated is { } updated) + sb.AppendLine($"\nPreise abgerufen: {updated:g} ({_pricing.Count} Modelle)"); + else + sb.AppendLine("\n⚠ Preise noch nicht geladen."); + CreditsTooltip = sb.ToString().TrimEnd(); } @@ -208,5 +250,6 @@ public sealed class OpenRouterStatusService : IDisposable string Model, int PromptTokens, int CompletionTokens, - double CostUsd); + double CostUsd, + bool CostIsKnown); } diff --git a/frm_main.cs b/frm_main.cs index 8821e26..274a023 100644 --- a/frm_main.cs +++ b/frm_main.cs @@ -1479,6 +1479,11 @@ public partial class frm_main : Form }; _statusService.Start(); + + // Modellpreise einmalig laden. Ohne sie weist die Anzeige alle Kosten als + // unbekannt aus, statt stillschweigend 0 € zu melden. + if (ModelTypeConverter.Client is { } client) + _ = _statusService.LoadPricingAsync(client); } // ═══════════════════════════════════════════════════ diff --git a/src/ClawdDotNet.Core/Api/ModelPricingCatalog.cs b/src/ClawdDotNet.Core/Api/ModelPricingCatalog.cs new file mode 100644 index 0000000..7f85f32 --- /dev/null +++ b/src/ClawdDotNet.Core/Api/ModelPricingCatalog.cs @@ -0,0 +1,64 @@ +using System.Collections.Concurrent; +using ClawdDotNet.Core.Api.Models; + +namespace ClawdDotNet.Core.Api; + +/// Kostenschätzung eines Laufs — oder die Auskunft, dass keine möglich ist. +public sealed record CostEstimate(decimal Usd, bool IsKnown, string Model) +{ + public static CostEstimate Unknown(string model) => new(0m, false, model); +} + +/// +/// Hält die Modellpreise von OpenRouter. +/// +/// Hintergrund (B4): Die Preise standen fest im Code — eine Tabelle mit einer Handvoll +/// Modelle, die bereits veraltet war. Das Standardmodell der Agenten fehlte darin, und +/// CalculateCost gab für unbekannte Modelle stillschweigend 0 zurück. Die +/// Kostenanzeige war damit nicht bloß ungenau, sondern blind: Sie zeigte 0 € an, +/// während echte Kosten anfielen. +/// +/// Deshalb kommen die Preise jetzt vom Anbieter, und ein unbekanntes Modell wird als +/// solches gemeldet, statt eine Null vorzutäuschen. +/// +public sealed class ModelPricingCatalog +{ + private readonly ConcurrentDictionary _prices = + new(StringComparer.OrdinalIgnoreCase); + + public DateTime? LastUpdated { get; private set; } + + public int Count => _prices.Count; + + /// Übernimmt die Preise aus einer Modellliste. Modelle ohne Angaben werden übersprungen. + public void Load(IEnumerable models) + { + foreach (var model in models) + { + if (model.Pricing is { } pricing && !string.IsNullOrWhiteSpace(model.Id)) + _prices[model.Id] = pricing; + } + + LastUpdated = DateTime.Now; + } + + public ModelPricing? Get(string model) + => _prices.TryGetValue(model, out var pricing) ? pricing : null; + + public bool IsKnown(string model) => _prices.ContainsKey(model); + + /// + /// Berechnet die Kosten eines Laufs. Bei unbekanntem Modell wird das im Ergebnis + /// ausgewiesen — der Aufrufer soll das sichtbar machen, nicht als 0 verbuchen. + /// + public CostEstimate Estimate(string model, int promptTokens, int completionTokens) + { + if (Get(model) is not { } pricing) + return CostEstimate.Unknown(model); + + var cost = promptTokens / 1_000_000m * pricing.InputPer1M + + completionTokens / 1_000_000m * pricing.OutputPer1M; + + return new CostEstimate(cost, true, model); + } +} diff --git a/src/ClawdDotNet.Core/Api/Models/ModelInfo.cs b/src/ClawdDotNet.Core/Api/Models/ModelInfo.cs index dfb2ab2..077183f 100644 --- a/src/ClawdDotNet.Core/Api/Models/ModelInfo.cs +++ b/src/ClawdDotNet.Core/Api/Models/ModelInfo.cs @@ -11,5 +11,36 @@ public sealed class ModelInfo /// Anzeigename des Modells public string Name { get; set; } = ""; + /// + /// Preise laut OpenRouter. Null, wenn der Endpunkt für dieses Modell keine + /// Angaben liefert — dann darf keine Kostenschätzung vorgetäuscht werden. + /// + public ModelPricing? Pricing { get; set; } + public override string ToString() => Id; } + +/// +/// Preise pro einer Million Token. OpenRouter liefert sie im /models-Endpunkt als +/// Preis pro einzelnem Token in Textform. +/// +public sealed record ModelPricing(decimal InputPer1M, decimal OutputPer1M) +{ + /// + /// Wandelt eine OpenRouter-Preisangabe ("0.000003" pro Token) in den Preis pro + /// einer Million Token um. Gibt null zurück, wenn der Wert unbrauchbar ist. + /// + public static decimal? ParsePerToken(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + if (!decimal.TryParse(value, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var perToken)) + { + return null; + } + + return perToken < 0 ? null : perToken * 1_000_000m; + } +} diff --git a/src/ClawdDotNet.Core/Api/OpenRouterClient.cs b/src/ClawdDotNet.Core/Api/OpenRouterClient.cs index 1553144..e6a15a0 100644 --- a/src/ClawdDotNet.Core/Api/OpenRouterClient.cs +++ b/src/ClawdDotNet.Core/Api/OpenRouterClient.cs @@ -168,7 +168,7 @@ public sealed class OpenRouterClient : IChatCompletionClient, IDisposable var id = item.GetProperty("id").GetString() ?? ""; var name = item.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? id : id; - models.Add(new ModelInfo { Id = id, Name = name }); + models.Add(new ModelInfo { Id = id, Name = name, Pricing = ReadPricing(item) }); } models.Sort((a, b) => string.Compare(a.Id, b.Id, StringComparison.OrdinalIgnoreCase)); @@ -177,6 +177,24 @@ public sealed class OpenRouterClient : IChatCompletionClient, IDisposable return models; } + /// + /// Liest die Preisangaben eines Modell-Eintrags. OpenRouter liefert sie als Text + /// und als Preis pro einzelnem Token. + /// + internal static ModelPricing? ReadPricing(JsonElement item) + { + if (!item.TryGetProperty("pricing", out var pricing) || pricing.ValueKind != JsonValueKind.Object) + return null; + + var input = ModelPricing.ParsePerToken( + pricing.TryGetProperty("prompt", out var p) ? p.GetString() : null); + var output = ModelPricing.ParsePerToken( + pricing.TryGetProperty("completion", out var c) ? c.GetString() : null); + + // Nur wenn beide Werte vorliegen, ist eine Kostenrechnung belastbar. + return input is { } i && output is { } o ? new ModelPricing(i, o) : null; + } + public void Dispose() { _http.Dispose(); diff --git a/tests/ClawdDotNet.Core.Tests/Api/ModelPricingTests.cs b/tests/ClawdDotNet.Core.Tests/Api/ModelPricingTests.cs new file mode 100644 index 0000000..a4d378c --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Api/ModelPricingTests.cs @@ -0,0 +1,162 @@ +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); + } +}