Rund 2.600 Kommentarblöcke in 310 C#-Dateien übersetzt, Begründungen vollständig erhalten: blockweise gleiche Anzahl wie vorher, kein Block unter 60 % seiner Länge, der Code außerhalb der Kommentare per Tokenizer byte-identisch geprüft. Zitate deutscher Oberflächentexte und Testdaten bleiben deutsch. WebUI: Kommentare in JS/CSS/HTML übersetzt, JSDoc-@param an die in 4b umbenannten Parameter angeglichen. Dabei behoben: - 4b hatte "tag" (HTML-Tag) als deutsches "Tag" gelesen und zu "dayValue" gemacht (makeElement, svg, Tagesübersicht) - zurück auf tag/day. - Aufgaben "InProgress" bekamen die Plakettenart "laufend", für die es nie eine CSS-Klasse gab - jetzt "success" wie laufende Instanzen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
6.0 KiB
C#
163 lines
6.0 KiB
C#
using System.Text.Json;
|
|
using ClawdDotNet.Core.Api;
|
|
using ClawdDotNet.Core.Api.Models;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Core.Tests.Api;
|
|
|
|
/// <summary>
|
|
/// B4, second part: the prices were hard-coded, outdated, and of all things
|
|
/// the agents' default model was missing. CalculateCost silently returned 0 for it
|
|
/// — the display thus reported 0 € while real costs were incurred.
|
|
/// </summary>
|
|
public sealed class ModelPricingTests
|
|
{
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Conversion of the OpenRouter figures
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
[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()
|
|
{
|
|
// With German culture "0.000003" would otherwise be read as 3 instead of 0.000003.
|
|
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;
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Reading from the API response
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
[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)
|
|
{
|
|
// Half the information is worse than none — it would result in a plausible-looking
|
|
// but wrong sum.
|
|
OpenRouterClient.ReadPricing(JsonDocument.Parse(json).RootElement).ShouldBeNull();
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Catalog and cost estimate
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
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 input + 5,000 output = 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()
|
|
{
|
|
// The core of the old error: 0 looked like free.
|
|
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);
|
|
}
|
|
}
|