using Microsoft.Extensions.AI;
using Morph.AiKit.Agents;
using Morph.AiKit.Middleware;
using Morph.AiKit.Providers;
using Morph.AiKit.Tests.Fakes;
using Xunit;
namespace Morph.AiKit.Tests;
///
/// Canarios do (T11). A afirmacao central da task e uma
/// so: cost_usd nao sai nulo, e o zero nunca e ambiguo — ou e medicao, ou e
/// dispensa escrita, ou e lacuna declarada.
///
public sealed class UsageCostMiddlewareTests
{
private static readonly DateTimeOffset FixedNow = new(2026, 9, 8, 12, 0, 0, TimeSpan.Zero);
/// Tarifa ilustrativa com desconto de cache — a que exercita a aritmetica inteira.
private static ModelPricing Tariff()
=> new(InputPerMTok: 1.25m, OutputPerMTok: 10.0m, CachedInputPerMTok: 0.125m);
private static ModelAlias PricedAlias(ModelPricing? pricing = null)
=> new("openai", "gpt-5.4", AgentApi.Responses, pricing ?? Tariff(), Endpoint: null)
{
Name = "text-heavy",
};
private static ModelAlias WaivedAlias()
=> new("ollama", "qwen3:8b", AgentApi.Responses, Pricing: null, Endpoint: "http://localhost:11434")
{
Name = "local-dev",
PricingWaiver = "modelo local: a conta e a maquina, nao o token.",
};
private static (IChatClient Client, FakeChatClient Inner, FakeUsageCostSink Sink) Pipeline(
ModelAlias alias,
Action script)
{
var inner = new FakeChatClient();
script(inner);
var sink = new FakeUsageCostSink();
// A extensao publica e o caminho de registro: se ela quebrar, o teste quebra junto.
var client = inner.AsBuilder().UseUsageCost(alias, sink, new FakeClock(FixedNow)).Build();
return (client, inner, sink);
}
// ---------------------------------------------------------------------------------
// doneCriteria 1: Usage conhecido + ModelPricing conhecido => CostUsd EXATO
// ---------------------------------------------------------------------------------
///
/// O valor esperado esta escrito a mao, com a semantica correta de token cacheado:
/// cacheado e SUBCONJUNTO da entrada. 1000 de entrada com 400 servidos do cache
/// custam 600 na tarifa cheia mais 400 na de cache — nunca 1000 mais 400. E por isso que
/// este caso, e nao um sem cache, e o que denuncia uma segunda aritmetica.
///
[Fact]
public async Task AKnownUsageAgainstAKnownTariffProducesTheExactCostInUsd()
{
var (client, _, sink) = Pipeline(
PricedAlias(),
fake => fake.EnqueueTextWithUsage("ok", inputTokens: 1000, outputTokens: 200, cachedInputTokens: 400));
await client.GetResponseAsync(
[new ChatMessage(ChatRole.User, "oi")],
cancellationToken: TestContext.Current.CancellationToken);
var record = Assert.Single(sink.Records);
// (600 * 1.25) + (400 * 0.125) + (200 * 10.0) = 750 + 50 + 2000 = 2800 por 1M tokens.
Assert.Equal(0.0028m, record.CostUsd);
Assert.Equal(UsageCostStatus.Computed, record.Status);
Assert.True(record.IsMeasuredCost);
Assert.Equal("text-heavy", record.Alias);
Assert.Equal(1000, record.InputTokens);
Assert.Equal(200, record.OutputTokens);
Assert.Equal(400, record.CachedInputTokens);
Assert.Null(record.PricingWaiver);
Assert.Equal(FixedNow, record.At);
}
///
/// O canario que torna "nao existe uma segunda conta do custo" verificavel em vez de
/// prometido: o custo publicado tem de ser identico ao de
/// — inclusive nos casos que separam as duas
/// formulas plausiveis (cacheado sem tarifa de cache, cacheado igual a entrada, saida
/// zerada).
///
[Theory]
[InlineData(1000, 200, 400L)]
[InlineData(1000, 200, null)]
[InlineData(1000, 200, 1000L)]
[InlineData(1000, 200, 0L)]
[InlineData(7, 3, 2L)]
[InlineData(1_000_000, 0, 999_999L)]
public async Task ThePublishedCostIsTheOneComputeCostUsdWouldGive(long input, long output, long? cached)
{
var pricing = Tariff();
var (client, _, sink) = Pipeline(
PricedAlias(pricing),
fake => fake.EnqueueTextWithUsage("ok", input, output, cached));
await client.GetResponseAsync(
[new ChatMessage(ChatRole.User, "oi")],
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(pricing.ComputeCostUsd(input, output, cached), sink.Records[0].CostUsd);
}
///
/// Tarifa sem desconto de cache declarado: nulo em CachedInputPerMTok significa
/// "sem desconto", nunca "de graca" — a entrada inteira e cobrada cheia mesmo com
/// tokens vindos do cache.
///
[Fact]
public async Task WithoutADeclaredCacheTariffTheWholeInputIsChargedAsInput()
{
var (client, _, sink) = Pipeline(
PricedAlias(new ModelPricing(1.25m, 10.0m, CachedInputPerMTok: null)),
fake => fake.EnqueueTextWithUsage("ok", inputTokens: 1000, outputTokens: 200, cachedInputTokens: 400));
await client.GetResponseAsync(
[new ChatMessage(ChatRole.User, "oi")],
cancellationToken: TestContext.Current.CancellationToken);
// (1000 * 1.25) + (200 * 10.0) = 3250 por 1M tokens. O cacheado nao rende desconto.
Assert.Equal(0.00325m, sink.Records[0].CostUsd);
}
// ---------------------------------------------------------------------------------
// doneCriteria 2: alias sem Pricing => CostUsd == 0, ausencia sinalizada, SEM excecao
// ---------------------------------------------------------------------------------
///
/// Alias sem tarifa e caminho previsto, nao erro. E o zero nao e anonimo: o motivo
/// escrito no pricingWaiver do registry viaja no record. Nao ha excecao — e nao
/// ha nulo em CostUsd.
///
[Fact]
public async Task AnAliasWithoutATariffRecordsTokensWithZeroCostAndSaysWhy()
{
var alias = WaivedAlias();
var (client, _, sink) = Pipeline(
alias,
fake => fake.EnqueueTextWithUsage("ok", inputTokens: 900, outputTokens: 100));
var response = await client.GetResponseAsync(
[new ChatMessage(ChatRole.User, "oi")],
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("ok", response.Text);
var record = Assert.Single(sink.Records);
Assert.Equal(0m, record.CostUsd); // zero, jamais nulo
Assert.Equal(UsageCostStatus.Waived, record.Status);
Assert.False(record.IsMeasuredCost); // logo, nao entra na soma
Assert.Equal(alias.PricingWaiver, record.PricingWaiver);
Assert.Equal(900, record.InputTokens); // os tokens continuam medidos
Assert.Equal(100, record.OutputTokens);
}
///
/// A regra que sustenta a anterior mora no registry (T8): um alias sem tarifa e
/// sem dispensa nao carrega. Por isso um record Waived sempre tem motivo — a
/// ausencia por esquecimento morre antes de chegar ao middleware.
///
[Fact]
public void AWaivedRecordAlwaysHasAReasonBecauseTheRegistryRefusesAnAliasWithNeither()
{
var orphan = new ModelAlias("openai", "gpt-5.4", AgentApi.Responses, Pricing: null, Endpoint: null)
{
Name = "sem-decisao",
};
var refusal = Assert.Throws(() => ModelAlias.ValidatePricingDecision(orphan));
Assert.Contains("sem-decisao", refusal.Message, StringComparison.Ordinal);
ModelAlias.ValidatePricingDecision(WaivedAlias()); // com dispensa escrita, passa
}
///
/// A soma so junta o que foi medido. Uma volta tarifada mais uma dispensada somam o
/// custo da primeira — o zero da dispensa nao "abaixa a media" nem some do relatorio.
///
[Fact]
public async Task OnlyMeasuredCostsEnterTheSum()
{
var sink = new FakeUsageCostSink();
var clock = new FakeClock(FixedNow);
using var paid = new FakeChatClient().EnqueueTextWithUsage("a", 1000, 200, 400);
using var free = new FakeChatClient().EnqueueTextWithUsage("b", 900, 100);
var paidClient = paid.AsBuilder().UseUsageCost(PricedAlias(), sink, clock).Build();
var freeClient = free.AsBuilder().UseUsageCost(WaivedAlias(), sink, clock).Build();
await paidClient.GetResponseAsync(
[new ChatMessage(ChatRole.User, "1")],
cancellationToken: TestContext.Current.CancellationToken);
await freeClient.GetResponseAsync(
[new ChatMessage(ChatRole.User, "2")],
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(2, sink.Count);
Assert.Equal(0.0028m, sink.MeasuredTotalUsd);
}
// ---------------------------------------------------------------------------------
// O terceiro zero: o provider nao reportou uso
// ---------------------------------------------------------------------------------
///
/// Resposta sem Usage: a linha sai marcada como lacuna. Nao publicar nada faria o
/// custo desaparecer do relatorio, que e a mesma dor por outro angulo; publicar como
/// Computed mentiria que a volta custou zero.
///
[Fact]
public async Task AResponseWithoutUsageIsRecordedAsAGapNotAsFree()
{
var (client, _, sink) = Pipeline(PricedAlias(), fake => fake.EnqueueText("sem contador"));
await client.GetResponseAsync(
[new ChatMessage(ChatRole.User, "oi")],
cancellationToken: TestContext.Current.CancellationToken);
var record = Assert.Single(sink.Records);
Assert.Equal(UsageCostStatus.UsageMissing, record.Status);
Assert.Equal(0m, record.CostUsd);
Assert.False(record.IsMeasuredCost);
Assert.Equal(0m, sink.MeasuredTotalUsd);
}
///
/// cached > input e o provider se contradizendo. A conta do T8 recusa esse par
/// com excecao — e recusar aqui derrubaria a resposta do usuario por causa de um
/// contador. O middleware degrada para lacuna declarada, sem lancar.
///
[Fact]
public async Task InconsistentTokenCountsBecomeADeclaredGapInsteadOfAThrownRequest()
{
var pricing = Tariff();
Assert.Throws(() => pricing.ComputeCostUsd(10, 5, cachedInputTokens: 11));
var (client, _, sink) = Pipeline(
PricedAlias(pricing),
fake => fake.EnqueueTextWithUsage("ok", inputTokens: 10, outputTokens: 5, cachedInputTokens: 11));
var response = await client.GetResponseAsync(
[new ChatMessage(ChatRole.User, "oi")],
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("ok", response.Text);
Assert.Equal(UsageCostStatus.UsageMissing, Assert.Single(sink.Records).Status);
}
// ---------------------------------------------------------------------------------
// Uma linha por volta ao modelo — inclusive no streaming
// ---------------------------------------------------------------------------------
///
/// Duas chamadas a GetResponseAsync publicam duas linhas: uma por
/// ida ao modelo, cada uma com o seu proprio numero.
///
///
/// O que este teste prova, e so isto: uma linha por chamada, sem acumulo de
/// estado entre chamadas. Ele NAO prova nada sobre a posicao do middleware no pipeline
/// — sao duas chamadas separadas, nao uma chamada com volta de tool, e aqui o
/// middleware e montado sozinho, sem FunctionInvokingChatClient nenhum. A
/// posicao real e o oposto do que uma leitura apressada deste teste sugeriria: o
/// middleware fica ACIMA da invocacao de funcoes, e quem prova isso e
/// AgentFactoryTests.ThePipelineOrderIsUsageCostAboveFunctionInvocationAndTelemetryBelowIt
/// (com ...SeesTheUsageOfEveryToolRoundSummedOnce mostrando o uso ja somado das
/// duas voltas). Acima do FICC, uma conversa com tool call e uma chamada com o
/// uso ja totalizado — nao duas.
///
[Fact]
public async Task EveryTripToTheModelPublishesItsOwnLine()
{
var (client, inner, sink) = Pipeline(
PricedAlias(),
fake => fake
.EnqueueTextWithUsage("primeira", 1000, 200, 400)
.EnqueueTextWithUsage("segunda", 2000, 100, null));
await client.GetResponseAsync(
[new ChatMessage(ChatRole.User, "a")],
cancellationToken: TestContext.Current.CancellationToken);
await client.GetResponseAsync(
[new ChatMessage(ChatRole.User, "b")],
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(2, inner.CallCount);
Assert.Equal(2, sink.Count);
Assert.Equal(0.0028m, sink.Records[0].CostUsd);
Assert.Equal(0.0035m, sink.Records[1].CostUsd); // (2000*1.25 + 100*10) / 1M
}
///
/// No streaming o uso chega como UsageContent avulso. A linha sai uma vez,
/// ao fim da enumeracao, com o mesmo numero do caminho nao-streaming.
///
[Fact]
public async Task StreamingPublishesOneLineAtTheEndWithTheSameNumber()
{
var (client, _, sink) = Pipeline(
PricedAlias(),
fake => fake.EnqueueTextWithUsage("streamado", 1000, 200, 400));
var updates = 0;
await foreach (var _ in client.GetStreamingResponseAsync(
[new ChatMessage(ChatRole.User, "oi")],
cancellationToken: TestContext.Current.CancellationToken))
{
Assert.Equal(0, sink.Count); // nada publicado no meio da enumeracao
updates++;
}
Assert.True(updates > 0);
Assert.Equal(0.0028m, Assert.Single(sink.Records).CostUsd);
}
// ---------------------------------------------------------------------------------
// Higiene de construcao
// ---------------------------------------------------------------------------------
/// Alias ou sink nulos sao defeito de composicao e falham na construcao.
[Fact]
public void TheMiddlewareRefusesToBeBuiltWithoutAnAliasOrASink()
{
using var inner = new FakeChatClient();
Assert.Throws(
() => new UsageCostMiddleware(inner, null!, new FakeUsageCostSink()));
Assert.Throws(
() => new UsageCostMiddleware(inner, PricedAlias(), null!));
}
///
/// O instante do record vem do injetado, nao do relogio do
/// processo — sem isso o campo At seria intestavel (standard
/// backend-dotnet-testing).
///
[Fact]
public async Task TheTimestampComesFromTheInjectedClock()
{
var clock = new FakeClock(FixedNow);
var sink = new FakeUsageCostSink();
using var inner = new FakeChatClient()
.EnqueueTextWithUsage("a", 10, 10)
.EnqueueTextWithUsage("b", 10, 10);
var client = inner.AsBuilder().UseUsageCost(PricedAlias(), sink, clock).Build();
await client.GetResponseAsync(
[new ChatMessage(ChatRole.User, "1")],
cancellationToken: TestContext.Current.CancellationToken);
clock.Advance(TimeSpan.FromMinutes(5));
await client.GetResponseAsync(
[new ChatMessage(ChatRole.User, "2")],
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(FixedNow, sink.Records[0].At);
Assert.Equal(FixedNow.AddMinutes(5), sink.Records[1].At);
}
}