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