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
@@ -0,0 +1,64 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ClawdDotNet.Core.Api.Models;
|
||||
|
||||
namespace ClawdDotNet.Core.Api;
|
||||
|
||||
/// <summary>Kostenschätzung eines Laufs — oder die Auskunft, dass keine möglich ist.</summary>
|
||||
public sealed record CostEstimate(decimal Usd, bool IsKnown, string Model)
|
||||
{
|
||||
public static CostEstimate Unknown(string model) => new(0m, false, model);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>CalculateCost</c> 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.
|
||||
/// </summary>
|
||||
public sealed class ModelPricingCatalog
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ModelPricing> _prices =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public DateTime? LastUpdated { get; private set; }
|
||||
|
||||
public int Count => _prices.Count;
|
||||
|
||||
/// <summary>Übernimmt die Preise aus einer Modellliste. Modelle ohne Angaben werden übersprungen.</summary>
|
||||
public void Load(IEnumerable<ModelInfo> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Berechnet die Kosten eines Laufs. Bei unbekanntem Modell wird das im Ergebnis
|
||||
/// ausgewiesen — der Aufrufer soll das sichtbar machen, nicht als 0 verbuchen.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,36 @@ public sealed class ModelInfo
|
||||
/// <summary>Anzeigename des Modells</summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Preise laut OpenRouter. Null, wenn der Endpunkt für dieses Modell keine
|
||||
/// Angaben liefert — dann darf keine Kostenschätzung vorgetäuscht werden.
|
||||
/// </summary>
|
||||
public ModelPricing? Pricing { get; set; }
|
||||
|
||||
public override string ToString() => Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Preise pro einer Million Token. OpenRouter liefert sie im /models-Endpunkt als
|
||||
/// Preis pro einzelnem Token in Textform.
|
||||
/// </summary>
|
||||
public sealed record ModelPricing(decimal InputPer1M, decimal OutputPer1M)
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest die Preisangaben eines Modell-Eintrags. OpenRouter liefert sie als Text
|
||||
/// und als Preis pro einzelnem Token.
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user