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:
co-authored by
Claude Opus 4.8
parent
cbb8ac22bc
commit
93f47aca3a
@@ -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<UsageRecord> _usageRecords = new();
|
||||
|
||||
// Preise pro 1M Token (Input / Output) in USD — gängige OpenRouter-Modelle
|
||||
private static readonly Dictionary<string, (double InputPer1M, double OutputPer1M)> 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),
|
||||
};
|
||||
/// <summary>
|
||||
/// 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 €.
|
||||
/// </summary>
|
||||
private readonly ModelPricingCatalog _pricing = new();
|
||||
|
||||
/// <summary>Modelle, für die keine Preise vorliegen — werden in der Anzeige benannt.</summary>
|
||||
private readonly ConcurrentDictionary<string, byte> _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)
|
||||
/// <summary>
|
||||
/// Lädt die aktuellen Modellpreise. Ohne diesen Aufruf bleibt der Katalog leer und
|
||||
/// alle Kosten werden als unbekannt ausgewiesen.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user