Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Api/ModelPricingTests.cs
T
RichardandClaude Opus 5.5 77062facc9 Stufe 4c (#764): Bezeichner auf Englisch - Typen, Methoden, Felder, Parameter, Locals, Testnamen
Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt).
Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e):
Properties, Record-Parameter, Enum-Member sowie die Parameter der
Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder
anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:18:43 +02:00

163 lines
5.9 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 Prices_per_token_are_scaled_to_one_million(string raw, double expected)
{
ModelPricing.ParsePerToken(raw).ShouldBe((decimal)expected);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("keine-zahl")]
[InlineData("-0.001")]
public void Unusable_values_give_null(string? raw)
{
ModelPricing.ParsePerToken(raw).ShouldBeNull();
}
[Fact]
public void The_conversion_is_independent_of_the_system_language()
{
// 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 Prices_are_read_from_the_model_response()
{
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 Incomplete_prices_give_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 The_costs_are_calculated_separately_from_prompt_and_completion()
{
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 An_unknown_model_is_reported_as_unknown_instead_of_zero_cost()
{
// 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 Case_of_the_model_ID_does_not_matter()
{
var catalog = CatalogWith(("openai/gpt-4o", 2.5m, 10m));
catalog.IsKnown("OpenAI/GPT-4o").ShouldBeTrue();
}
[Fact]
public void Models_without_price_do_not_end_up_in_the_catalogue()
{
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 An_empty_catalogue_knows_no_model()
{
new ModelPricingCatalog().Estimate("beliebig", 1000, 100).IsKnown.ShouldBeFalse();
}
[Fact]
public void Reloading_updates_the_prices()
{
var catalog = CatalogWith(("m", 1m, 2m));
catalog.Load([new ModelInfo { Id = "m", Pricing = new ModelPricing(5m, 10m) }]);
catalog.Get("m")!.InputPer1M.ShouldBe(5m);
}
}