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); } }