Resolve model profiles through the registry

This commit is contained in:
Thorsten Sommer committed 2026-09-12 10:50:12 +02:00
1 parent 9307f2c363
commit 429ca8c739
8 files changed
+551 -6

No files matched your search

@@ -59,7 +59,6 @@ public static class LeftToTheDefault
new(SELF_HOSTED, "ling-1t", THE_DEFAULT_SAYS_THE_SAME),
new(SELF_HOSTED, "inclusionai/ling-mini-2.0", THE_DEFAULT_SAYS_THE_SAME),
new(SELF_HOSTED, "starling-lm:7b", THE_DEFAULT_SAYS_THE_SAME),
new(SELF_HOSTED, "ernie-4.5-21b", THE_DEFAULT_SAYS_THE_SAME),
new(SELF_HOSTED, "phi3:14b", "The Phi rules were written for the fourth generation and the ones before it already reached the default, which answers them the same as it does today."),
//
@@ -70,7 +69,6 @@ public static class LeftToTheDefault
new(SELF_HOSTED, "olmo-3-32b-think", THE_DEFAULT_KEEPS_WHAT_MATTERS),
new(SELF_HOSTED, "seed-oss:36b", THE_DEFAULT_KEEPS_WHAT_MATTERS),
new(SELF_HOSTED, "ring-1t", THE_DEFAULT_KEEPS_WHAT_MATTERS),
new(SELF_HOSTED, "ernie-x1.1-thinking", THE_DEFAULT_KEEPS_WHAT_MATTERS),
new(SELF_HOSTED, "smollm3:3b", THE_DEFAULT_KEEPS_WHAT_MATTERS),
new(HUGGINGFACE, "HuggingFaceTB/SmolLM3-3B", THE_DEFAULT_KEEPS_WHAT_MATTERS),
new(SELF_HOSTED, "internlm3:8b", THE_DEFAULT_KEEPS_WHAT_MATTERS),
@@ -81,9 +79,6 @@ public static class LeftToTheDefault
//
new(SELF_HOSTED, "internvl3-8b", THE_DEFAULT_DROPS_THE_MODALITIES),
new(GWDG, "internvl2.5-8b", THE_DEFAULT_DROPS_THE_MODALITIES),
new(SELF_HOSTED, "ernie-4.5-vl-28b", THE_DEFAULT_DROPS_THE_MODALITIES),
new(SELF_HOSTED, "apriel-1.5-15b-thinker", THE_DEFAULT_DROPS_THE_MODALITIES),
new(SELF_HOSTED, "apriel-1.6-15b-thinker", THE_DEFAULT_DROPS_THE_MODALITIES),
new(SELF_HOSTED, "apertus-1.5-8b", "A family we decided not to write down, and the one which loses the most by it: it reads images and listens to audio, and the default knows about neither."),
//
@@ -0,0 +1,126 @@
using AIStudio.Models;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Tests.Models.Corpus;
namespace AIStudio.Tests.Settings;
/// <summary>
/// Checks the door the app asks its question through.
/// </summary>
/// <remarks>
/// Behind it stand the links of the chain in the order they win: what a person said about their own
/// installation, then what the rules worked out, then what the app assumes. The rules themselves are
/// measured elsewhere, against the whole corpus. What is measured here is the last link -- the one
/// nothing held to account until now, because a model falling through looked exactly like a model
/// nobody had asked about.
/// </remarks>
[TestFixture]
public sealed class ModelProfileChainTests
{
[Test]
public void EveryModelLeftToTheDefaultIsAnsweredByTheAssumption()
{
Assert.Multiple(() =>
{
foreach (var left in LeftToTheDefault.ENTRIES)
{
var profile = left.Provider.GetModelProfile(new Model(left.ModelId, null));
var wanted = left.Provider is LLMProviders.NONE || string.IsNullOrWhiteSpace(left.ModelId)
? Capability.NONE
: ModelProfile.ASSUMED.Capabilities;
Assert.That(profile.Capabilities, Is.EqualTo(wanted), $"{left.Provider} \"{left.ModelId}\": {left.Reason}");
}
});
}
[Test]
public void NothingLeftToTheDefaultGainsACapabilityItDoesNotHaveToday()
{
//
// The direction which matters. These models lose things on the way over -- the thinking of
// a family nobody wrote down, the image input of another -- and every loss was decided and
// written next to the entry. What may never happen is the other direction: the switch-over
// handing a model an ability the old rules denied it, which nobody decided and nobody would
// see until a request comes back as an error.
//
Assert.Multiple(() =>
{
foreach (var left in LeftToTheDefault.ENTRIES)
{
var entry = new CorpusEntry(left.Provider, left.ModelId, CorpusOrigin.NAMED_BY_NO_RULE);
var today = CapabilitySnapshot.AskTheCurrentRules(entry);
var gained = RebuiltRules.AsCapabilities(left.Provider.GetModelProfile(new Model(left.ModelId, null))).Except(today).ToList();
Assert.That(gained, Is.Empty, $"{left.Provider} \"{left.ModelId}\" would gain {string.Join(", ", gained)}, which nobody decided.");
}
});
}
[Test]
public void AModelNoRuleKnowsReadsAndWritesTextAndCallsFunctions()
{
var profile = LLMProviders.SELF_HOSTED.GetModelProfile(new Model("a-model-nobody-has-heard-of", null));
Assert.Multiple(() =>
{
Assert.That(profile.Has(Capability.TEXT_INPUT), Is.True);
Assert.That(profile.Has(Capability.TEXT_OUTPUT), Is.True);
Assert.That(profile.Has(Capability.CHAT_COMPLETION_API), Is.True);
Assert.That(profile.Has(Capability.FUNCTION_CALLING), Is.True);
Assert.That(profile.Has(Capability.MULTIPLE_IMAGE_INPUT), Is.False, "The assumption says nothing about what a model reads besides text.");
Assert.That(profile.Reasoning, Is.EqualTo(ReasoningSupport.NONE));
Assert.That(profile.Context.IsKnown, Is.False, "A context window nobody stated is unknown, not a number somebody picked.");
});
}
[Test]
public void AModelWhoseKindIsKnownKeepsItWhenTheAssumptionFillsInTheRest()
{
//
// The assumption fills in the capabilities and nothing else. An embedding model nobody wrote
// a rule for is still an embedding model, and must not turn into a chat model on the way
// through -- it would appear in the user's chat model list and answer every request with an
// error.
//
var profile = LLMProviders.SELF_HOSTED.GetModelProfile(new Model("bge-m3:567m", null));
Assert.That(profile.Kind, Is.EqualTo(ModelKind.EMBEDDING));
}
[TestCase("")]
[TestCase(" ")]
public void AProviderWhichNamedNoModelIsAnsweredWithNothing(string modelId)
{
var profile = LLMProviders.OPEN_AI.GetModelProfile(new Model(modelId, null));
Assert.That(profile.Capabilities, Is.EqualTo(Capability.NONE), "There is nothing to assume about a model nobody picked.");
}
[Test]
public void WithoutAProviderThereIsNothingToAssumeEither()
{
var profile = LLMProviders.NONE.GetModelProfile(new Model("gpt-5.6", null));
Assert.That(profile.Capabilities, Is.EqualTo(Capability.NONE), "There is no way to reach the model, so there is nothing to say about how it could be used.");
}
[Test]
public void WhatAPersonSaidAboutTheirOwnInstallationWinsOverTheRules()
{
var configured = new AIStudio.Settings.Provider(0, "test", "Test", LLMProviders.SELF_HOSTED, new Model("llama3.3:70b", null))
{
CapabilityOverrides = new() { MultipleImageInput = true, FunctionCalling = false },
};
var profile = configured.GetModelProfile();
Assert.Multiple(() =>
{
Assert.That(profile.Has(Capability.MULTIPLE_IMAGE_INPUT), Is.True, "The rules say this model reads text only; the person says otherwise and can see their installation.");
Assert.That(profile.Has(Capability.FUNCTION_CALLING), Is.False, "And the other way round.");
Assert.That(profile.Has(Capability.TEXT_INPUT), Is.True, "Everything nobody said anything about stays as the rules had it.");
});
}
}
@@ -0,0 +1,176 @@
using AIStudio.Models;
using AIStudio.Provider;
using AIStudio.Settings;
namespace AIStudio.Tests.Settings;
/// <summary>
/// Checks what a person's own settings do to what the rules worked out.
/// </summary>
/// <remarks>
/// The expert dialog writes the three reasoning words together, in five combinations. Those five
/// are the whole surface the app produces, so they are the ones held against the code being
/// replaced: every one of them has to come out of the new resolution exactly as it comes out of the
/// old repair today.
///
/// A configuration plugin can write the three words one at a time, and there the two differ on
/// purpose. The old repair took a word away unless another one stood next to it, so an override
/// about something else destroyed an answer nobody had touched. Those cases are stated below, one
/// by one, with what they answer now.
/// </remarks>
[TestFixture]
public sealed class ProviderCapabilityOverridesTests
{
private static readonly ReasoningSupport[] EVERY_STATE = [ReasoningSupport.NONE, ReasoningSupport.OPTIONAL, ReasoningSupport.ON_BY_DEFAULT, ReasoningSupport.ALWAYS];
/// <summary>
/// The five combinations the expert dialog writes, in the order its list shows them.
/// </summary>
/// <remarks>
/// "Automatic" is not among them. It means the person said nothing, and a provider carrying
/// nothing but nothing is saved without an override record at all, so it never reaches here --
/// which is exactly why the defect below went unnoticed for so long: it needed a second,
/// unrelated switch to become visible.
/// </remarks>
private static readonly ProviderCapabilityOverrides[] WHAT_THE_DIALOG_WRITES =
[
new() { AlwaysReasoning = false, OptionalReasoning = false, ReasoningByDefault = false },
new() { AlwaysReasoning = false, OptionalReasoning = true, ReasoningByDefault = false },
new() { AlwaysReasoning = false, OptionalReasoning = true, ReasoningByDefault = true },
new() { AlwaysReasoning = true, OptionalReasoning = false, ReasoningByDefault = false },
];
[Test]
public void EveryChoiceTheExpertDialogOffersMeansTheSameAsItDoesToday()
{
Assert.Multiple(() =>
{
foreach (var overrides in WHAT_THE_DIALOG_WRITES)
foreach (var stated in EVERY_STATE)
{
var rebuilt = overrides.ApplyTo(ProfileWhichReasons(stated)).Reasoning;
var today = ReasoningOf(overrides.ApplyTo(CapabilitiesWhichReason(stated)));
Assert.That(rebuilt, Is.EqualTo(today), $"A model which reasons {stated}, with {Describe(overrides)}.");
}
});
}
[Test]
public void AnOverrideAboutSomethingElseNoLongerTakesTheThinkingAway()
{
//
// The defect this replaces. Turning tool calling off said nothing about reasoning, and yet
// a model which thinks unless asked not to came out of it as a model which never thinks --
// because the repair kept "on by default" only where "on request" stood next to it, which
// no rule has ever stated.
//
var overrides = new ProviderCapabilityOverrides { FunctionCalling = false };
var thinker = ProfileWhichReasons(ReasoningSupport.ON_BY_DEFAULT);
Assert.Multiple(() =>
{
Assert.That(overrides.ApplyTo(thinker).Reasoning, Is.EqualTo(ReasoningSupport.ON_BY_DEFAULT));
Assert.That(ReasoningOf(overrides.ApplyTo(CapabilitiesWhichReason(ReasoningSupport.ON_BY_DEFAULT))), Is.EqualTo(ReasoningSupport.NONE), "Which is what it used to answer, and the reason this test exists.");
});
}
[TestCase(ReasoningSupport.ALWAYS, ReasoningSupport.ALWAYS, Description = "Saying it is not on by default says nothing about a model which cannot turn it off.")]
[TestCase(ReasoningSupport.ON_BY_DEFAULT, ReasoningSupport.NONE, Description = "Here it names the state the model is in.")]
[TestCase(ReasoningSupport.OPTIONAL, ReasoningSupport.OPTIONAL, Description = "A model which reasons on request was never on by default.")]
public void ANoOnlyTakesAwayTheStateItNames(ReasoningSupport stated, ReasoningSupport wanted)
{
var overrides = new ProviderCapabilityOverrides { ReasoningByDefault = false };
Assert.That(overrides.ApplyTo(ProfileWhichReasons(stated)).Reasoning, Is.EqualTo(wanted));
}
[Test]
public void AYesIsTheWholeAnswer()
{
var alwaysOn = new ProviderCapabilityOverrides { AlwaysReasoning = true, OptionalReasoning = false, ReasoningByDefault = false };
Assert.That(alwaysOn.ApplyTo(ProfileWhichReasons(ReasoningSupport.NONE)).Reasoning, Is.EqualTo(ReasoningSupport.ALWAYS));
}
[Test]
public void SwitchingACapabilityOnAndOffTouchesNothingElse()
{
var profile = new ModelProfile
{
Capabilities = Capability.TEXT_INPUT | Capability.TEXT_OUTPUT | Capability.SINGLE_IMAGE_INPUT | Capability.FUNCTION_CALLING,
Kind = ModelKind.CHAT,
Context = ContextWindow.Of(128_000),
};
var overrides = new ProviderCapabilityOverrides { FunctionCalling = false, AudioInput = true };
var after = overrides.ApplyTo(profile);
Assert.Multiple(() =>
{
Assert.That(after.Has(Capability.FUNCTION_CALLING), Is.False);
Assert.That(after.Has(Capability.AUDIO_INPUT), Is.True);
Assert.That(after.Has(Capability.TEXT_INPUT), Is.True);
Assert.That(after.Has(Capability.SINGLE_IMAGE_INPUT), Is.True, "Turning several images off is what removes several images; one image is a statement of its own.");
Assert.That(after.Context, Is.EqualTo(profile.Context));
});
}
/// <summary>
/// A profile which reasons the given way and says nothing else.
/// </summary>
/// <param name="reasoning">How the model reasons.</param>
/// <returns>The profile.</returns>
private static ModelProfile ProfileWhichReasons(ReasoningSupport reasoning) => new()
{
Capabilities = Capability.TEXT_INPUT | Capability.TEXT_OUTPUT,
Reasoning = reasoning,
};
/// <summary>
/// The same model, written the way the rules being replaced answer.
/// </summary>
/// <param name="reasoning">How the model reasons.</param>
/// <returns>The capabilities, with the one word which stands for that state.</returns>
private static List<Capability> CapabilitiesWhichReason(ReasoningSupport reasoning)
{
List<Capability> capabilities = [Capability.TEXT_INPUT, Capability.TEXT_OUTPUT];
switch (reasoning)
{
case ReasoningSupport.OPTIONAL:
capabilities.Add(Capability.OPTIONAL_REASONING);
break;
case ReasoningSupport.ON_BY_DEFAULT:
capabilities.Add(Capability.REASONING_BY_DEFAULT);
break;
case ReasoningSupport.ALWAYS:
capabilities.Add(Capability.ALWAYS_REASONING);
break;
}
return capabilities;
}
/// <summary>
/// Which state a list of capabilities stands for, read the way the app reads it today.
/// </summary>
/// <param name="capabilities">The capabilities.</param>
/// <returns>The state they stand for.</returns>
private static ReasoningSupport ReasoningOf(List<Capability> capabilities)
{
if (capabilities.Contains(Capability.ALWAYS_REASONING))
return ReasoningSupport.ALWAYS;
if (capabilities.Contains(Capability.REASONING_BY_DEFAULT))
return ReasoningSupport.ON_BY_DEFAULT;
if (capabilities.Contains(Capability.OPTIONAL_REASONING))
return ReasoningSupport.OPTIONAL;
return ReasoningSupport.NONE;
}
private static string Describe(ProviderCapabilityOverrides overrides) => $"always={overrides.AlwaysReasoning?.ToString() ?? "auto"}, optional={overrides.OptionalReasoning?.ToString() ?? "auto"}, byDefault={overrides.ReasoningByDefault?.ToString() ?? "auto"}";
}