using Microsoft.Extensions.AI; using Morph.AiKit.Agents; using Morph.AiKit.Providers; using Morph.AiKit.Tests.Fakes; using Xunit; namespace Morph.AiKit.Tests; /// /// Canarios do (T8). Tres coisas sao cobradas aqui, e as tres /// sao as que o registry v1 nao tinha: protocolo por alias, tarifa por alias /// e falha explicita onde antes havia nulo silencioso. /// public sealed class ModelRegistryTests { /// O exemplar de verdade, embutido do proprio arquivo que o kit publica. private static string ExampleDocument() { using var stream = typeof(ModelRegistryTests).Assembly .GetManifestResourceStream("model-registry.v2.json") ?? throw new InvalidOperationException( "model-registry.v2.json nao foi embutido no assembly de teste."); using var reader = new StreamReader(stream); return reader.ReadToEnd(); } private static string Document(string aliasesBody, string extraRoot = "") => $$""" { "version": "2.0"{{extraRoot}}, "aliases": { {{aliasesBody}} } } """; // --------------------------------------------------------------------------------- // O exemplar publicado carrega — e expoe api e pricing por alias // --------------------------------------------------------------------------------- [Fact] public void ThePublishedExampleLoadsAndExposesApiAndPricingPerAlias() { using var registry = ModelRegistry.FromJson(ExampleDocument(), new FakeProviderClientFactory()); Assert.Equal( ["local-dev", "text-default", "text-heavy", "text-heavy-chat", "vision"], registry.Aliases.Order(StringComparer.Ordinal)); Assert.Equal("text-default", registry.DefaultAlias); // 'api' omitido cai em Responses — a decisao da casa, verificada e nao suposta. Assert.Equal(AgentApi.Responses, registry.Resolve("text-default").Api); Assert.Equal(AgentApi.Responses, registry.Resolve("text-heavy").Api); // ... e o MESMO modelo, sob outro alias, pode falar Chat Completions. var chat = registry.Resolve("text-heavy-chat"); Assert.Equal(AgentApi.Chat, chat.Api); Assert.Equal(registry.Resolve("text-heavy").Model, chat.Model); var pricing = registry.Resolve("text-heavy").RequirePricing(); Assert.Equal(1.25m, pricing.InputPerMTok); Assert.Equal(10.0m, pricing.OutputPerMTok); Assert.Equal(0.125m, pricing.CachedInputPerMTok); // cachedInputPerMTok explicitamente nulo = sem desconto declarado (nao "de graca"). Assert.Null(registry.Resolve("vision").RequirePricing().CachedInputPerMTok); Assert.Equal("http://localhost:11434/v1", registry.Resolve("local-dev").Endpoint); } [Fact] public void TheExampleCarriesTheChatCompletionsLimitWithItsProvenance() { using var registry = ModelRegistry.FromJson(ExampleDocument(), new FakeProviderClientFactory()); var constraints = registry.Resolve("text-heavy-chat").Constraints; Assert.True(constraints.ToolCallingRequiresReasoningEffortNone); Assert.False(constraints.TemperatureUnsupported); Assert.Contains("reasoning_effort", constraints.Provenance!, StringComparison.Ordinal); } // --------------------------------------------------------------------------------- // Tarifa ausente e ALTO, nunca nulo silencioso // --------------------------------------------------------------------------------- [Fact] public void AnAliasWithNeitherPricingNorWaiverIsRefusedAtLoadNamingTheAlias() { var document = Document(""" "orfao": { "provider": "openai", "model": "gpt-5.4" } """); var error = Assert.Throws( () => ModelRegistry.FromJson(document, new FakeProviderClientFactory())); Assert.Equal(ModelRegistryError.InvalidDocument, error.Reason); Assert.Contains("alias 'orfao'", error.Message, StringComparison.Ordinal); Assert.Contains("pricingWaiver", error.Message, StringComparison.Ordinal); } [Fact] public void AnAliasWithBothPricingAndWaiverIsRefusedBecauseNeitherWins() { var document = Document(""" "ambiguo": { "provider": "openai", "model": "gpt-5.4", "pricing": { "inputPerMTok": 1, "outputPerMTok": 2 }, "pricingWaiver": "nao tem preco" } """); var error = Assert.Throws( () => ModelRegistry.FromJson(document, new FakeProviderClientFactory())); Assert.Contains("alias 'ambiguo'", error.Message, StringComparison.Ordinal); } [Fact] public void AWaivedAliasLoadsAndSaysWhyItHasNoTariff() { using var registry = ModelRegistry.FromJson(ExampleDocument(), new FakeProviderClientFactory()); var local = registry.Resolve("local-dev"); Assert.False(local.HasPricing); Assert.Contains("modelo local", local.PricingWaiver!, StringComparison.Ordinal); var error = Assert.Throws(() => { _ = local.RequirePricing(); }); Assert.Equal(ModelRegistryError.MissingPricing, error.Reason); Assert.Contains("local-dev", error.Message, StringComparison.Ordinal); Assert.Contains("modelo local", error.Message, StringComparison.Ordinal); } [Fact] public void ANegativeTariffIsRefused() { var document = Document(""" "barato-demais": { "provider": "openai", "model": "gpt-5.4", "pricing": { "inputPerMTok": -1, "outputPerMTok": 2 } } """); var error = Assert.Throws( () => ModelRegistry.FromJson(document, new FakeProviderClientFactory())); Assert.Contains("inputPerMTok", error.Message, StringComparison.Ordinal); } [Fact] public void ATypoInAFieldNameIsRefusedInsteadOfSilentlyIgnored() { // "priceing" seria lido como "sem pricing" — e a ausencia silenciosa e o defeito. var document = Document(""" "digitado-errado": { "provider": "openai", "model": "gpt-5.4", "priceing": { "inputPerMTok": 1, "outputPerMTok": 2 } } """); var error = Assert.Throws( () => ModelRegistry.FromJson(document, new FakeProviderClientFactory())); Assert.Contains("priceing", error.Message, StringComparison.Ordinal); } // --------------------------------------------------------------------------------- // api: por alias // --------------------------------------------------------------------------------- [Fact] public void AnUnknownApiValueIsRefusedListingTheValidOnes() { var document = Document(""" "inventado": { "provider": "openai", "model": "gpt-5.4", "api": "assistants", "pricing": { "inputPerMTok": 1, "outputPerMTok": 2 } } """); var error = Assert.Throws( () => ModelRegistry.FromJson(document, new FakeProviderClientFactory())); Assert.Contains("'assistants'", error.Message, StringComparison.Ordinal); Assert.Contains("responses", error.Message, StringComparison.Ordinal); Assert.Contains("chat", error.Message, StringComparison.Ordinal); } [Fact] public void ApiIsCaseInsensitiveOnTheWayIn() { var document = Document(""" "gritado": { "provider": "openai", "model": "gpt-5.4", "api": "CHAT", "pricing": { "inputPerMTok": 1, "outputPerMTok": 2 } } """); using var registry = ModelRegistry.FromJson(document, new FakeProviderClientFactory()); Assert.Equal(AgentApi.Chat, registry.Resolve("gritado").Api); } [Fact] public void AV1DocumentIsRefusedWithAVersionMessageInsteadOfAConfusingFieldError() { var document = """ { "version": "1.0", "defaultAlias": "text-default", "aliases": { "text-default": { "provider": "openai", "model": "gpt-4o-mini" } } } """; var error = Assert.Throws( () => ModelRegistry.FromJson(document, new FakeProviderClientFactory())); Assert.Contains("'1.0'", error.Message, StringComparison.Ordinal); } // --------------------------------------------------------------------------------- // Resolucao e memoizacao // --------------------------------------------------------------------------------- [Fact] public void ResolvingAnUnknownAliasFailsExplicitlyAndListsWhatExists() { using var registry = ModelRegistry.FromJson(ExampleDocument(), new FakeProviderClientFactory()); var error = Assert.Throws(() => registry.Resolve("nao-existe")); Assert.Equal(ModelRegistryError.UnknownAlias, error.Reason); Assert.Contains("nao-existe", error.Message, StringComparison.Ordinal); Assert.Contains("text-default", error.Message, StringComparison.Ordinal); Assert.False(registry.TryResolve("nao-existe", out var silent)); Assert.Null(silent); } [Fact] public void TheChatClientIsMemoizedPerAliasAndTheFactoryRunsOnce() { var factory = new FakeProviderClientFactory(); using var registry = ModelRegistry.FromJson(ExampleDocument(), factory); var first = registry.GetChatClient("text-default"); var second = registry.GetChatClient("text-default"); Assert.Same(first, second); Assert.Equal(1, factory.CreateCount); var other = registry.GetChatClient("text-heavy"); Assert.NotSame(first, other); Assert.Equal(2, factory.CreateCount); } [Fact] public void AFactoryThatReturnsNullIsRefusedInsteadOfCachingNothing() { var factory = new FakeProviderClientFactory(_ => null); using var registry = ModelRegistry.FromJson(ExampleDocument(), factory); var error = Assert.Throws(() => registry.GetChatClient("text-default")); Assert.Contains("devolveu nulo", error.Message, StringComparison.Ordinal); } [Fact] public void TheRegistryOwnsAndDisposesTheClientsItMemoized() { var factory = new FakeProviderClientFactory(); var registry = ModelRegistry.FromJson(ExampleDocument(), factory); var client = (FakeChatClient)registry.GetChatClient("text-default"); Assert.False(client.IsDisposed); registry.Dispose(); Assert.True(client.IsDisposed); Assert.Throws(() => registry.GetChatClient("text-default")); registry.Dispose(); // idempotente } [Fact] public void ADefaultAliasPointingNowhereIsRefused() { var document = Document( """ "existe": { "provider": "openai", "model": "gpt-5.4", "pricing": { "inputPerMTok": 1, "outputPerMTok": 2 } } """, extraRoot: ""","defaultAlias": "fantasma" """); var error = Assert.Throws( () => ModelRegistry.FromJson(document, new FakeProviderClientFactory())); Assert.Contains("fantasma", error.Message, StringComparison.Ordinal); } [Fact] public void BrokenJsonFailsNamingTheDocumentInsteadOfLeakingAJsonException() { var error = Assert.Throws( () => ModelRegistry.FromJson("{ isto nao e json", new FakeProviderClientFactory())); Assert.Contains("JSON invalido", error.Message, StringComparison.Ordinal); Assert.IsAssignableFrom(error.InnerException); } // --------------------------------------------------------------------------------- // Tarifa: a conta // --------------------------------------------------------------------------------- [Fact] public void CostIsTheTariffTimesTheTokensDividedByAMillion() { var pricing = new ModelPricing(InputPerMTok: 1.25m, OutputPerMTok: 10m, CachedInputPerMTok: null); // 1.000.000 de entrada = 1.25 USD; 100.000 de saida = 1.00 USD Assert.Equal(2.25m, pricing.ComputeCostUsd(inputTokens: 1_000_000, outputTokens: 100_000)); Assert.Equal(0m, pricing.ComputeCostUsd(0, 0)); } [Fact] public void CachedInputIsChargedApartOnlyWhenItsTariffExists() { var comCache = new ModelPricing(1.25m, 10m, CachedInputPerMTok: 0.125m); var semCache = comCache with { CachedInputPerMTok = null }; // 1M de entrada, dos quais 800k vieram do cache: // com tarifa de cache: 200k * 1.25 + 800k * 0.125 = 0.25 + 0.10 = 0.35 Assert.Equal( 0.35m, comCache.ComputeCostUsd(inputTokens: 1_000_000, outputTokens: 0, cachedInputTokens: 800_000)); // sem tarifa de cache, a entrada inteira e cobrada como entrada — nunca de graca. Assert.Equal( 1.25m, semCache.ComputeCostUsd(inputTokens: 1_000_000, outputTokens: 0, cachedInputTokens: 800_000)); } [Fact] public void CachedTokensCannotExceedInputTokens() { var pricing = new ModelPricing(1m, 1m, 0.1m); Assert.Throws( () => pricing.ComputeCostUsd(inputTokens: 10, outputTokens: 0, cachedInputTokens: 11)); Assert.Throws( () => pricing.ComputeCostUsd(inputTokens: -1, outputTokens: 0)); } // --------------------------------------------------------------------------------- // Limites por alias — o limite REAL, nao o folclore // --------------------------------------------------------------------------------- [Fact] public void TheChatToolLimitFiresOnlyWithAllFourFactsTogether() { using var registry = ModelRegistry.FromJson(ExampleDocument(), new FakeProviderClientFactory()); var alias = registry.Resolve("text-heavy-chat"); // os quatro fatos juntos: Chat + tools + effort enviado + effort != none var violado = alias.CheckRequest( AgentApi.Chat, hasTools: true, reasoningEffort: ReasoningEffort.High, temperatureIsSet: false); Assert.False(violado.IsSatisfied); Assert.Contains("reasoning effort", Assert.Single(violado.Violations), StringComparison.Ordinal); // mesmo pedido, na Responses API: sem violacao Assert.True(alias.CheckRequest( AgentApi.Responses, hasTools: true, ReasoningEffort.High, temperatureIsSet: false).IsSatisfied); // sem tools: sem violacao Assert.True(alias.CheckRequest( AgentApi.Chat, hasTools: false, ReasoningEffort.High, temperatureIsSet: false).IsSatisfied); // effort 'none': e exatamente o que a documentacao permite Assert.True(alias.CheckRequest( AgentApi.Chat, hasTools: true, ReasoningEffort.None, temperatureIsSet: false).IsSatisfied); // effort nao enviado: o default do provider vale, e o kit NAO afirma saber qual e Assert.True(alias.CheckRequest( AgentApi.Chat, hasTools: true, reasoningEffort: null, temperatureIsSet: false).IsSatisfied); } [Fact] public void AnAliasWithoutConstraintsNeverViolatesAnything() { using var registry = ModelRegistry.FromJson(ExampleDocument(), new FakeProviderClientFactory()); var check = registry.Resolve("text-default").CheckRequest( AgentApi.Chat, hasTools: true, ReasoningEffort.High, temperatureIsSet: true); Assert.True(check.IsSatisfied); Assert.Empty(check.Violations); } [Fact] public void TheTemperatureLimitIsPerAliasAndOnlyBitesWhenTemperatureIsSent() { // A afirmacao "temperatura e obsoleta para modelo de raciocinio" NAO foi confirmada // em fonte primaria (issue #93). Por isso ela e uma flag por alias com procedencia // escrita — nunca uma regra universal no codigo. Aqui a procedencia diz, por extenso, // que o exemplo e sintetico. var document = Document(""" "sem-temperatura": { "provider": "openai", "model": "modelo-hipotetico", "pricing": { "inputPerMTok": 1, "outputPerMTok": 2 }, "constraints": { "temperatureUnsupported": true, "provenance": "EXEMPLO SINTETICO deste teste. Nenhuma fonte primaria foi encontrada para a afirmacao generica sobre temperatura em modelos de raciocinio." } } """); using var registry = ModelRegistry.FromJson(document, new FakeProviderClientFactory()); var alias = registry.Resolve("sem-temperatura"); var violado = alias.CheckRequest( AgentApi.Responses, hasTools: false, reasoningEffort: null, temperatureIsSet: true); Assert.False(violado.IsSatisfied); Assert.Contains("EXEMPLO SINTETICO", Assert.Single(violado.Violations), StringComparison.Ordinal); Assert.True(alias.CheckRequest( AgentApi.Responses, hasTools: false, reasoningEffort: null, temperatureIsSet: false).IsSatisfied); } [Fact] public void AConstraintWithoutProvenanceIsRefusedAtLoad() { var document = Document(""" "folclore": { "provider": "openai", "model": "gpt-5.4", "pricing": { "inputPerMTok": 1, "outputPerMTok": 2 }, "constraints": { "temperatureUnsupported": true } } """); var error = Assert.Throws( () => ModelRegistry.FromJson(document, new FakeProviderClientFactory())); Assert.Contains("provenance", error.Message, StringComparison.Ordinal); } }