diff --git a/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs index 6d376e85..993a5c7e 100644 --- a/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs @@ -853,10 +853,15 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId private ModelProfile GetCurrentModelProfile() => this.CreateProviderSettings().GetModelProfile(); /// - /// What the rules alone say about the model, which is what each switch shows as its automatic answer. + /// What holds without anybody switching anything, which is what each field shows as its automatic answer. /// + /// + /// The rules, plus whatever the provider itself stated when the model list was loaded a moment + /// ago. A self-hosted engine is the case this matters for: it reports the window it was started + /// with, and that is the number a person gets by leaving the field below empty. + /// /// The profile. - private ModelProfile GetAutomaticModelProfile() => this.DataLLMProvider.GetModelProfile(this.GetSelectedModel()); + private ModelProfile GetAutomaticModelProfile() => this.CreateProviderSettings().GetAutomaticModelProfile(); private string GetCurrentModelApiLabel() { diff --git a/app/MindWork AI Studio/Models/Live/ListedModels.cs b/app/MindWork AI Studio/Models/Live/ListedModels.cs new file mode 100644 index 00000000..5db4f6a9 --- /dev/null +++ b/app/MindWork AI Studio/Models/Live/ListedModels.cs @@ -0,0 +1,86 @@ +using System.Collections.Concurrent; +using System.Collections.Frozen; + +namespace AIStudio.Models.Live; + +/// +/// What the configured providers last said about the models they serve. +/// +/// +/// One snapshot per configured provider instance, and reporting replaces the snapshot rather than +/// adding to it. That is the same reason the registry replaces what the plugins declare: a model an +/// installation no longer serves has to stop answering, and a window somebody halved by restarting +/// their engine must not go on being reported alongside its correction. +/// +/// Nothing here is written to disk. These are statements about a machine as it is running right +/// now, and the app asks that machine again before every chat round anyway. An instance somebody +/// deleted keeps its snapshot until the app is closed -- a few dozen kilobytes at the very worst, +/// which is not worth a second mechanism to watch the settings for. +/// +public sealed class ListedModels +{ + /// + /// The one the app reports into and asks. + /// + public static ListedModels Shared { get; } = new(); + + /// + /// Per configured provider instance, what its model list said about each model. + /// + /// + /// Both keys ignore case. The IDs come back from the same list they were stored under, so + /// ordinal would do -- but a model an organization wrote into a configuration plugin by hand + /// was typed by a person, and the availability check already treats such a name as the same + /// model regardless of case. Being stricter here would leave exactly those people without the + /// numbers. + /// + private readonly ConcurrentDictionary> byProvider = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Takes over what one provider instance said about its models, replacing what it said before. + /// + /// + /// Only ever call this with a whole list in hand. Reporting a filtered part of one would tell + /// this instance that everything left out has stopped existing. + /// + /// The instance that was asked. Nothing happens without one. + /// What its list stated, with the models it stated nothing about left in or out as convenient. + public void Report(string configuredProviderId, IEnumerable listings) + { + // + // A provider instance nobody has configured yet is not a machine we could ask again later, + // so there is nothing to remember it by. The provider dialog is not such a case: it works + // on a fully built instance from the moment it opens, ID included. + // + if (string.IsNullOrWhiteSpace(configuredProviderId)) + return; + + var stated = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var listing in listings) + { + if (string.IsNullOrWhiteSpace(listing.ModelId) || !listing.IsKnown) + continue; + + stated[listing.ModelId] = listing; + } + + this.byProvider[configuredProviderId] = stated.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + } + + /// + /// What one provider instance said about one of its models. + /// + /// The instance serving the model. + /// The model, named the way that instance names it. + /// What it stated, which is nothing when it was never asked or said nothing. + public ModelListing Of(string configuredProviderId, string modelId) + { + if (string.IsNullOrWhiteSpace(configuredProviderId) || string.IsNullOrWhiteSpace(modelId)) + return ModelListing.NOTHING; + + if (!this.byProvider.TryGetValue(configuredProviderId, out var stated)) + return ModelListing.NOTHING; + + return stated.TryGetValue(modelId, out var listing) ? listing : ModelListing.NOTHING; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Models/Live/ModelListing.cs b/app/MindWork AI Studio/Models/Live/ModelListing.cs new file mode 100644 index 00000000..6083fb51 --- /dev/null +++ b/app/MindWork AI Studio/Models/Live/ModelListing.cs @@ -0,0 +1,44 @@ +namespace AIStudio.Models.Live; + +/// +/// What a provider's own model list says about one of the models it serves. +/// +/// +/// That list is fetched anyway: before every chat round, before every assistant run, and whenever +/// somebody opens the provider dialog. Reading what it already carries therefore costs no request +/// of its own, which is the whole reason these numbers are taken from here and not asked for. +/// +/// This describes one installation, never the model as such. Two machines may serve the same +/// weights behind different settings, and a statement about one of them says nothing about the +/// other -- which is why a listing is kept per configured provider instance and is gone with the +/// process. It is also the only source for a self-hosted model: a rule can say what the weights +/// were trained for, but only the engine knows what its operator started it with. +/// +/// The model, named the way the provider names it in its list. +/// The window the provider states for it, or unknown where it states none. +public readonly record struct ModelListing(string ModelId, ContextWindow Context) +{ + /// + /// What we have about a model nobody has reported anything about. + /// + public static readonly ModelListing NOTHING = new(string.Empty, ContextWindow.UNKNOWN); + + /// + /// Whether this listing states anything at all. + /// + public bool IsKnown => this.Context.IsKnown; + + /// + /// Puts what the provider stated over what the rules worked out. + /// + /// + /// A stated window replaces the whole window, the ceiling included, for the same reason the + /// expert settings do: what a model card says it could be raised to is a statement about the + /// model, while this is a statement about the installation serving it. Whoever started that + /// engine has already decided, and a ceiling nobody can reach without restarting it is not a + /// number to keep showing. + /// + /// What is known about the model without this listing. + /// The profile, with what the provider stated in it. + public ModelProfile ApplyTo(in ModelProfile profile) => this.IsKnown ? profile with { Context = this.Context } : profile; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/SelfHosted/Model.cs b/app/MindWork AI Studio/Provider/SelfHosted/Model.cs index 06172018..ce1db8e7 100644 --- a/app/MindWork AI Studio/Provider/SelfHosted/Model.cs +++ b/app/MindWork AI Studio/Provider/SelfHosted/Model.cs @@ -1,3 +1,23 @@ +using System.Text.Json.Serialization; + namespace AIStudio.Provider.SelfHosted; -public readonly record struct Model(string Id, string? Object, string? OwnedBy, ModelArchitecture? Architecture); \ No newline at end of file +/// +/// One model as an OpenAI-compatible engine lists it. +/// +/// +/// The context window is vLLM's addition to that route: it reports the window the operator started +/// the engine with, which is the one number no rule about the weights could ever know. Ollama, +/// LM Studio, and llama.cpp answer the same route without it, so it stays unknown there instead of +/// being guessed. +/// +/// vLLM calls that field max_model_len, which reads like a limit on the model rather than on a +/// conversation. The wire keeps their spelling, and this record says what the number means, so that +/// nobody has to remember the translation while reading the code that uses it. +/// +/// The model's ID. +/// What kind of thing the entry is. Known value: "model". +/// Who the engine names as the owner of the model. +/// Which kinds of input and output the model takes, where the engine says. +/// The context window the engine was started with, in tokens, where it says. +public readonly record struct Model(string Id, string? Object, string? OwnedBy, ModelArchitecture? Architecture, [property: JsonPropertyName("max_model_len")] int? ContextWindowTokens); \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs index 691e15bb..dee9e41c 100644 --- a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs +++ b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs @@ -3,6 +3,8 @@ using System.Runtime.CompilerServices; using System.Text.Json; using AIStudio.Chat; +using AIStudio.Models; +using AIStudio.Models.Live; using AIStudio.Provider.OpenAI; using AIStudio.Settings; using AIStudio.Tools.PluginSystem; @@ -188,8 +190,23 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide return FailedModelLoadResult(this.GetModelLoadFailureReason(lmStudioResponse, responseBody), $"Status={(int)lmStudioResponse.StatusCode} {lmStudioResponse.ReasonPhrase}; Body='{responseBody}'"); } - var lmStudioModelResponse = await lmStudioResponse.Content.ReadFromJsonAsync(token); + // + // Read with the shared options, the way every other model list of this app is read. + // This one route did without them, which quietly cost it every field an engine spells + // in snake case: owned_by has been arriving as nothing all along, and the next field + // somebody adds here would have gone the same way without anything failing. + // + var lmStudioModelResponse = await lmStudioResponse.Content.ReadFromJsonAsync(JSON_SERIALIZER_OPTIONS, token); var models = lmStudioModelResponse.Data ?? []; + + // + // What the engine said about its own models, taken from the whole list rather than + // from what is offered below: a model filtered out here as an embedding model is still + // a model somebody may have configured this instance with, and this list is the only + // place its window is ever stated. + // + ListedModels.Shared.Report(this.ConfiguredProviderId, ListingsOf(models)); + return SuccessfulModelLoadResult(models. Where(model => !string.IsNullOrWhiteSpace(model.Id) && !ignorePhrases.Any(ignorePhrase => model.Id.Contains(ignorePhrase, StringComparison.InvariantCulture)) && @@ -302,6 +319,27 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide } } + /// + /// What an engine stated about the models it serves. + /// + /// + /// A window of zero or less is dropped rather than repaired. An engine answering that way is + /// telling us something we cannot interpret, and falling back to what the rules say about the + /// weights is the one answer nobody has to invent. + /// + /// The models exactly as the engine listed them. + /// One listing per model the engine said something usable about. + private static IEnumerable ListingsOf(IEnumerable models) + { + foreach (var model in models) + { + if (string.IsNullOrWhiteSpace(model.Id) || model.ContextWindowTokens is not > 0) + continue; + + yield return new(model.Id, ContextWindow.Of(model.ContextWindowTokens.Value)); + } + } + private static bool IsMatchingLlamaCppTextModel(Model model, string[] ignorePhrases, string[] filterPhrases) { if (string.IsNullOrWhiteSpace(model.Id)) diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.cs index 9c033a9e..27193979 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.cs @@ -1,4 +1,5 @@ using AIStudio.Models; +using AIStudio.Models.Live; using AIStudio.Models.Registry; using AIStudio.Provider; @@ -11,23 +12,42 @@ public static partial class ProviderExtensions /// /// /// The one door to that question. Behind it stand the links of the chain, in the order they - /// win: what the person said about their own installation, then what the rules worked out from - /// the name, then what the app assumes when nothing else said anything. + /// win: what the person said about their own installation, then what the installation itself + /// reported, then what the rules worked out from the name, and last what the app assumes when + /// nothing else said anything. /// /// The configured provider. /// The profile of the configured model. public static ModelProfile GetModelProfile(this Provider provider) { - var stated = provider.UsedLLMProvider.GetModelProfile(provider.Model); - return provider.CapabilityOverrides?.ApplyTo(stated) ?? stated; + var automatic = provider.GetAutomaticModelProfile(); + return provider.CapabilityOverrides?.ApplyTo(automatic) ?? automatic; } /// - /// Everything the rules know about a model at a provider, without anybody's own settings. + /// Everything known about the configured model except what the person themselves switched. /// /// - /// What the expert dialog shows next to each switch as the automatic answer, so that a person - /// can see what they are overriding. + /// This is what happens when somebody fills in nothing, which is why the expert dialog shows it + /// as the automatic answer. It has to include what the provider reported: a person who leaves + /// the window empty gets the number their own engine stated, and a placeholder showing them a + /// different one would be a promise the app does not keep. + /// + /// The configured provider. + /// The profile of the configured model, without that provider's overrides. + public static ModelProfile GetAutomaticModelProfile(this Provider provider) + { + var stated = provider.UsedLLMProvider.GetModelProfile(provider.Model); + return ListedModels.Shared.Of(provider.Id, provider.Model.Id).ApplyTo(stated); + } + + /// + /// Everything the rules know about a model at a provider, without anybody's own installation. + /// + /// + /// The answer to the model as such, which is the same for everybody who uses that name at that + /// provider -- and therefore the answer the registry caches. What one particular installation + /// says about it is asked one link further up, where the instance is known. /// /// The assumed profile fills in where no rule stated a single capability. It fills in the /// capabilities only: a modifier may well have said what the model is made for without any rule diff --git a/app/Tests/Models/Live/ListedModelsTests.cs b/app/Tests/Models/Live/ListedModelsTests.cs new file mode 100644 index 00000000..bc385985 --- /dev/null +++ b/app/Tests/Models/Live/ListedModelsTests.cs @@ -0,0 +1,154 @@ +using AIStudio.Models; +using AIStudio.Models.Live; +using AIStudio.Provider; + +namespace AIStudio.Tests.Models.Live; + +/// +/// Checks what a running installation is allowed to say about the models it serves. +/// +/// +/// Every test here builds its own store rather than using the shared one. What a provider reported +/// is state which outlives a single question, and a test leaving some of it behind would decide +/// what the next test sees. +/// +[TestFixture] +public sealed class ListedModelsTests +{ + private const string ONE_MACHINE = "11111111-1111-1111-1111-111111111111"; + private const string ANOTHER_MACHINE = "22222222-2222-2222-2222-222222222222"; + private const string MODEL = "qwen3-32b"; + + /// + /// A model the rules have something to say about, so that a report has something to contradict. + /// + private static readonly ModelProfile WHAT_THE_RULES_SAY = new() + { + Capabilities = Capability.TEXT_INPUT | Capability.TEXT_OUTPUT | Capability.MULTIPLE_IMAGE_INPUT, + Kind = ModelKind.CHAT, + Context = ContextWindow.Of(131_072, 262_144), + Images = new(null, 20), + }; + + [Test] + public void AMachineWhichWasNeverAskedSaysNothing() + { + var listed = new ListedModels(); + + Assert.Multiple(() => + { + Assert.That(listed.Of(ONE_MACHINE, MODEL), Is.EqualTo(ModelListing.NOTHING)); + Assert.That(listed.Of(ONE_MACHINE, MODEL).IsKnown, Is.False); + }); + } + + [Test] + public void WhatOneMachineSaysIsNotWhatAnotherSays() + { + // + // The same weights behind two engines, each started by somebody who decided for themselves. + // This is the whole reason these numbers are kept per configured instance. + // + var listed = new ListedModels(); + listed.Report(ONE_MACHINE, [new(MODEL, ContextWindow.Of(32_768))]); + listed.Report(ANOTHER_MACHINE, [new(MODEL, ContextWindow.Of(8_192))]); + + Assert.Multiple(() => + { + Assert.That(listed.Of(ONE_MACHINE, MODEL).Context.DefaultTokens, Is.EqualTo(32_768)); + Assert.That(listed.Of(ANOTHER_MACHINE, MODEL).Context.DefaultTokens, Is.EqualTo(8_192)); + }); + } + + [Test] + public void WhatAMachineNoLongerServesStopsAnswering() + { + var listed = new ListedModels(); + listed.Report(ONE_MACHINE, [new(MODEL, ContextWindow.Of(32_768)), new("gemma3-27b", ContextWindow.Of(16_384))]); + listed.Report(ONE_MACHINE, [new("gemma3-27b", ContextWindow.Of(16_384))]); + + Assert.Multiple(() => + { + Assert.That(listed.Of(ONE_MACHINE, MODEL), Is.EqualTo(ModelListing.NOTHING), "The engine was restarted without it, so nothing is known about it any more."); + Assert.That(listed.Of(ONE_MACHINE, "gemma3-27b").Context.DefaultTokens, Is.EqualTo(16_384)); + }); + } + + [Test] + public void AMachineWhichHalvedItsWindowIsBelievedTheSecondTimeToo() + { + var listed = new ListedModels(); + listed.Report(ONE_MACHINE, [new(MODEL, ContextWindow.Of(32_768))]); + listed.Report(ONE_MACHINE, [new(MODEL, ContextWindow.Of(16_384))]); + + Assert.That(listed.Of(ONE_MACHINE, MODEL).Context.DefaultTokens, Is.EqualTo(16_384)); + } + + [TestCase("Qwen3-32B")] + [TestCase("qwen3-32b")] + public void AModelSomebodyTypedIsStillTheSameModel(string asConfigured) + { + // + // An organization writes the model of a provider into its configuration plugin by hand, + // and the availability check already treats such a name as the same model whatever case it + // was typed in. Being stricter here would leave exactly those people without the numbers. + // + var listed = new ListedModels(); + listed.Report(ONE_MACHINE, [new("qwen3-32b", ContextWindow.Of(32_768))]); + + Assert.That(listed.Of(ONE_MACHINE, asConfigured).Context.DefaultTokens, Is.EqualTo(32_768)); + } + + [Test] + public void AnInstanceWithoutAnIdIsNothingToRemember() + { + var listed = new ListedModels(); + listed.Report(string.Empty, [new(MODEL, ContextWindow.Of(32_768))]); + + Assert.Multiple(() => + { + Assert.That(listed.Of(string.Empty, MODEL), Is.EqualTo(ModelListing.NOTHING)); + Assert.That(listed.Of(ONE_MACHINE, MODEL), Is.EqualTo(ModelListing.NOTHING), "One nameless report does not become every machine's answer."); + }); + } + + [Test] + public void AModelTheMachineSaidNothingAboutIsNotStored() + { + var listed = new ListedModels(); + listed.Report(ONE_MACHINE, [new(MODEL, ContextWindow.UNKNOWN), new(string.Empty, ContextWindow.Of(32_768))]); + + Assert.That(listed.Of(ONE_MACHINE, MODEL), Is.EqualTo(ModelListing.NOTHING)); + } + + [Test] + public void AReportedWindowReplacesTheWholeWindow() + { + var after = new ModelListing(MODEL, ContextWindow.Of(32_768)).ApplyTo(WHAT_THE_RULES_SAY); + + Assert.Multiple(() => + { + Assert.That(after.Context.DefaultTokens, Is.EqualTo(32_768)); + Assert.That(after.Context.RaisableToTokens, Is.Null, "What the weights could be raised to is not a number anybody reaches without restarting this engine."); + }); + } + + [Test] + public void AWindowSaysNothingAboutAnythingElse() + { + var after = new ModelListing(MODEL, ContextWindow.Of(32_768)).ApplyTo(WHAT_THE_RULES_SAY); + + Assert.Multiple(() => + { + Assert.That(after.Capabilities, Is.EqualTo(WHAT_THE_RULES_SAY.Capabilities)); + Assert.That(after.Images, Is.EqualTo(WHAT_THE_RULES_SAY.Images)); + Assert.That(after.Kind, Is.EqualTo(WHAT_THE_RULES_SAY.Kind)); + }); + } + + [Test] + public void SayingNothingKeepsEverythingTheRulesWorkedOut() + { + Assert.That(ModelListing.NOTHING.ApplyTo(WHAT_THE_RULES_SAY), Is.EqualTo(WHAT_THE_RULES_SAY)); + } +} \ No newline at end of file diff --git a/app/Tests/Settings/ModelProfileChainTests.cs b/app/Tests/Settings/ModelProfileChainTests.cs index 908fadc8..ffe680fa 100644 --- a/app/Tests/Settings/ModelProfileChainTests.cs +++ b/app/Tests/Settings/ModelProfileChainTests.cs @@ -1,4 +1,5 @@ using AIStudio.Models; +using AIStudio.Models.Live; using AIStudio.Provider; using AIStudio.Settings; using AIStudio.Tests.Models.Corpus; @@ -10,14 +11,31 @@ namespace AIStudio.Tests.Settings; /// /// /// 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. +/// installation, then what that installation reported about itself, 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 everything around them -- the last link, which nothing held to account +/// until now because a model falling through looked exactly like a model nobody had asked about, +/// and the order of the three links above it, each of which can speak about the same number. +/// +/// What a provider reported lands in the store the app shares, so these tests must not run next to +/// anything else touching it. /// [TestFixture] +[NonParallelizable] public sealed class ModelProfileChainTests { + private const string MACHINE = "33333333-3333-3333-3333-333333333333"; + + /// + /// A window no rule would ever state, so that finding it proves where the answer came from. + /// + private const int WHAT_THE_MACHINE_REPORTS = 33_333; + + private static readonly Model MODEL = new("qwen3-32b", null); + + [SetUp] + public void ForgetWhatTheMachineSaidBefore() => ListedModels.Shared.Report(MACHINE, []); + [Test] public void EveryModelLeftToTheDefaultIsAnsweredByTheAssumption() { @@ -100,4 +118,71 @@ public sealed class ModelProfileChainTests Assert.That(profile.Has(Capability.TEXT_INPUT), Is.True, "Everything nobody said anything about stays as the rules had it."); }); } + + [Test] + public void WhatThePersonTypedBeatsWhatTheMachineReported() + { + // + // Somebody who types a window has a reason for it, and the app is not in a position to know + // it better -- they may be working around an engine reporting nonsense. + // + ListedModels.Shared.Report(MACHINE, [new(MODEL.Id, ContextWindow.Of(WHAT_THE_MACHINE_REPORTS))]); + var configured = ProviderWith(new() { ContextWindowTokens = 8_192 }); + + Assert.That(configured.GetModelProfile().Context.DefaultTokens, Is.EqualTo(8_192)); + } + + [Test] + public void WhatTheMachineReportedBeatsWhatTheRulesWorkedOut() + { + ListedModels.Shared.Report(MACHINE, [new(MODEL.Id, ContextWindow.Of(WHAT_THE_MACHINE_REPORTS))]); + var configured = ProviderWith(null); + + Assert.That(configured.GetModelProfile().Context.DefaultTokens, Is.EqualTo(WHAT_THE_MACHINE_REPORTS)); + } + + [Test] + public void ASilentMachineLeavesTheRulesStanding() + { + var configured = ProviderWith(null); + + Assert.That(configured.GetModelProfile().Context, Is.EqualTo(LLMProviders.SELF_HOSTED.GetModelProfile(MODEL).Context)); + } + + [Test] + public void TheAutomaticAnswerIsWhatHappensWithoutTheSwitches() + { + // + // This is the number the expert dialog offers as its placeholder. Showing the rules there + // while the chat goes by the reported window would tell a person that emptying the field + // gets them something it does not. + // + ListedModels.Shared.Report(MACHINE, [new(MODEL.Id, ContextWindow.Of(WHAT_THE_MACHINE_REPORTS))]); + var configured = ProviderWith(new() { ContextWindowTokens = 8_192 }); + + Assert.Multiple(() => + { + Assert.That(configured.GetAutomaticModelProfile().Context.DefaultTokens, Is.EqualTo(WHAT_THE_MACHINE_REPORTS)); + Assert.That(configured.GetModelProfile().Context.DefaultTokens, Is.EqualTo(8_192), "What the person typed is still what counts everywhere else."); + }); + } + + [Test] + public void WhatOneMachineReportsIsNoAnswerForAnother() + { + ListedModels.Shared.Report(MACHINE, [new(MODEL.Id, ContextWindow.Of(WHAT_THE_MACHINE_REPORTS))]); + var somebodyElse = ProviderWith(null) with { Id = "44444444-4444-4444-4444-444444444444" }; + + Assert.That(somebodyElse.GetModelProfile().Context, Is.EqualTo(LLMProviders.SELF_HOSTED.GetModelProfile(MODEL).Context)); + } + + /// + /// A configured self-hosted provider, the way the settings hold one. + /// + /// What the person switched, or nothing when they switched nothing. + /// The configured provider. + private static AIStudio.Settings.Provider ProviderWith(ProviderCapabilityOverrides? overrides) => new(1, MACHINE, "A machine of my own", LLMProviders.SELF_HOSTED, MODEL, IsSelfHosted: true) + { + CapabilityOverrides = overrides, + }; } \ No newline at end of file