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); + } +}