From caf2aba4b966069b699915a19e5032a6ecd36d72 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 13 Sep 2026 10:59:41 +0200 Subject: [PATCH] Read the context window from four more model lists --- .../Models/Live/ModelListing.cs | 16 ++ .../Provider/BaseProvider.cs | 12 ++ .../Provider/Groq/GroqModel.cs | 15 ++ .../Provider/Groq/GroqModelsResponse.cs | 7 + .../Provider/Groq/ProviderGroq.cs | 9 +- .../Provider/HuggingFace/HFModel.cs | 28 +++- .../Provider/HuggingFace/HFModelProvider.cs | 13 +- .../HuggingFace/ProviderHuggingFace.cs | 23 ++- .../Provider/Mistral/Model.cs | 12 +- .../Provider/Mistral/ProviderMistral.cs | 5 +- .../Provider/OpenRouter/OpenRouterModel.cs | 10 +- .../Provider/OpenRouter/ProviderOpenRouter.cs | 13 ++ .../Provider/SelfHosted/ProviderSelfHosted.cs | 19 +-- app/Tests/Models/Live/ListedModelsTests.cs | 25 ++++ app/Tests/Provider/HFModelTests.cs | 95 ++++++++++++ app/Tests/Provider/ModelListMetadataTests.cs | 141 ++++++++++++++++++ 16 files changed, 415 insertions(+), 28 deletions(-) create mode 100644 app/MindWork AI Studio/Provider/Groq/GroqModel.cs create mode 100644 app/MindWork AI Studio/Provider/Groq/GroqModelsResponse.cs create mode 100644 app/Tests/Provider/HFModelTests.cs create mode 100644 app/Tests/Provider/ModelListMetadataTests.cs diff --git a/app/MindWork AI Studio/Models/Live/ModelListing.cs b/app/MindWork AI Studio/Models/Live/ModelListing.cs index 6083fb51..1d444c9f 100644 --- a/app/MindWork AI Studio/Models/Live/ModelListing.cs +++ b/app/MindWork AI Studio/Models/Live/ModelListing.cs @@ -28,6 +28,22 @@ public readonly record struct ModelListing(string ModelId, ContextWindow Context /// public bool IsKnown => this.Context.IsKnown; + /// + /// What a provider stated about one model, as every model list states it: a name and a number. + /// + /// + /// A window of zero or less is dropped rather than repaired, and so is a nameless entry. A + /// provider answering that way is saying something we cannot interpret, and falling back to + /// what the rules say about the model is the one answer nobody has to invent. Every dialect + /// comes through here, so that none of them has to decide that on its own. + /// + /// The model, named the way the provider names it. + /// The window the provider stated, where it stated one. + /// The listing, or nothing when there is nothing usable to keep. + public static ModelListing For(string modelId, int? contextWindowTokens) => string.IsNullOrWhiteSpace(modelId) || contextWindowTokens is not > 0 + ? NOTHING + : new(modelId, ContextWindow.Of(contextWindowTokens.Value)); + /// /// Puts what the provider stated over what the rules worked out. /// diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index 97260122..58166675 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -6,6 +6,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using AIStudio.Chat; +using AIStudio.Models.Live; using AIStudio.Provider.Anthropic; using AIStudio.Provider.OpenAI; using AIStudio.Provider.SelfHosted; @@ -191,6 +192,7 @@ public abstract class BaseProvider : IProvider, ISecretId Action? requestConfigurator = null, JsonSerializerOptions? jsonSerializerOptions = null, bool isTryingSecret = false, + Func>? listingFactory = null, CancellationToken token = default) { var secretKey = await this.GetModelLoadingSecretKey(storeType, apiKeyProvisional, isTryingSecret); @@ -220,6 +222,16 @@ public abstract class BaseProvider : IProvider, ISecretId if (parsedResponse is null) return FailedModelLoadResult(ModelLoadFailureReason.INVALID_RESPONSE, "Model list response could not be deserialized."); + // + // What the list stated about the models, read before anything is filtered out of + // it: a model left out below as an embedding model is still a model somebody may + // have configured this instance with, and a list like this one is the only place + // its window is ever stated. Only pass a whole list in here -- reporting a part of + // one would tell the app that everything left out has stopped existing. + // + if (listingFactory is not null) + ListedModels.Shared.Report(this.ConfiguredProviderId, listingFactory(parsedResponse)); + return SuccessfulModelLoadResult(modelFactory(parsedResponse)); } catch (Exception e) diff --git a/app/MindWork AI Studio/Provider/Groq/GroqModel.cs b/app/MindWork AI Studio/Provider/Groq/GroqModel.cs new file mode 100644 index 00000000..1767e3cd --- /dev/null +++ b/app/MindWork AI Studio/Provider/Groq/GroqModel.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace AIStudio.Provider.Groq; + +/// +/// One model as Groq lists it. +/// +/// +/// Groq says more about a model than the shared OpenAI-compatible list does, which is why this +/// provider brings a data model of its own instead of using that one: the shared record is read by +/// a dozen providers, and a field only one of them sends has no business in it. +/// +/// The model's ID. +/// How much the model reads and writes in one conversation, in tokens. +public readonly record struct GroqModel(string Id, [property: JsonPropertyName("context_window")] int? ContextWindowTokens); \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Groq/GroqModelsResponse.cs b/app/MindWork AI Studio/Provider/Groq/GroqModelsResponse.cs new file mode 100644 index 00000000..60bd69dc --- /dev/null +++ b/app/MindWork AI Studio/Provider/Groq/GroqModelsResponse.cs @@ -0,0 +1,7 @@ +namespace AIStudio.Provider.Groq; + +/// +/// A data model for the response from the Groq models endpoint. +/// +/// The models Groq serves. +public readonly record struct GroqModelsResponse(IList Data); \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs b/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs index 0544a5d6..134bc4ed 100644 --- a/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs +++ b/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; using AIStudio.Chat; +using AIStudio.Models.Live; using AIStudio.Provider.OpenAI; using AIStudio.Settings; @@ -113,10 +114,12 @@ public class ProviderGroq() : BaseProvider(LLMProviders.GROQ, new Uri("https://a private Task LoadModels(SecretStoreType storeType, string? apiKeyProvisional, CancellationToken token) { - return this.LoadModelsResponse( + return this.LoadModelsResponse( storeType, "models", - modelResponse => modelResponse.Data, - apiKeyProvisional, token: token); + modelResponse => modelResponse.Data.Select(n => new Model(n.Id, null)), + apiKeyProvisional, + listingFactory: modelResponse => modelResponse.Data.Select(n => ModelListing.For(n.Id, n.ContextWindowTokens)), + token: token); } } diff --git a/app/MindWork AI Studio/Provider/HuggingFace/HFModel.cs b/app/MindWork AI Studio/Provider/HuggingFace/HFModel.cs index b4e5f5dd..8464d83b 100644 --- a/app/MindWork AI Studio/Provider/HuggingFace/HFModel.cs +++ b/app/MindWork AI Studio/Provider/HuggingFace/HFModel.cs @@ -5,4 +5,30 @@ namespace AIStudio.Provider.HuggingFace; /// /// The ID of the model, written as "org/model". /// The inference providers serving this model. -public readonly record struct HFModel(string Id, IList? Providers); \ No newline at end of file +public readonly record struct HFModel(string Id, IList? Providers) +{ + /// + /// The window this model has when it is reached the way this user set things up. + /// + /// + /// A window belongs to an inference provider here, not to the model: the same weights run + /// behind several of them, each configured by somebody else. Where the user named one, its + /// number is the answer. Where they let the router choose, the smallest window among the + /// providers currently serving the model is -- nobody knows which one the router will take, and + /// a number promising more than the chosen provider delivers would walk a conversation into an + /// error the user could not see coming. + /// + /// The inference provider the user chose, or empty when the router chooses. + /// The window in tokens, or null where nobody stated one. + public int? ContextWindowTokens(string providerSlug) + { + if (this.Providers is null) + return null; + + var serving = this.Providers.Where(provider => provider.IsLive); + if (!string.IsNullOrEmpty(providerSlug)) + serving = serving.Where(provider => string.Equals(provider.Provider, providerSlug, StringComparison.OrdinalIgnoreCase)); + + return serving.Where(provider => provider.ContextWindowTokens is > 0).Min(provider => provider.ContextWindowTokens); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/HuggingFace/HFModelProvider.cs b/app/MindWork AI Studio/Provider/HuggingFace/HFModelProvider.cs index b3ccff4a..29a6baf0 100644 --- a/app/MindWork AI Studio/Provider/HuggingFace/HFModelProvider.cs +++ b/app/MindWork AI Studio/Provider/HuggingFace/HFModelProvider.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace AIStudio.Provider.HuggingFace; /// @@ -5,4 +7,13 @@ namespace AIStudio.Provider.HuggingFace; /// /// The slug of the inference provider, e.g. "novita". /// Whether the provider currently serves the model. Known value: "live". -public readonly record struct HFModelProvider(string Provider, string Status); \ No newline at end of file +/// How much this provider reads and writes in one conversation, in tokens. +public readonly record struct HFModelProvider(string Provider, string Status, [property: JsonPropertyName("context_length")] int? ContextWindowTokens) +{ + private const string LIVE = "live"; + + /// + /// Whether this provider serves the model right now. + /// + public bool IsLive => string.Equals(this.Status, LIVE, StringComparison.OrdinalIgnoreCase); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs b/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs index 35e64e52..debd89e9 100644 --- a/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs +++ b/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs @@ -2,6 +2,7 @@ using System.Runtime.CompilerServices; using AIStudio.Chat; +using AIStudio.Models.Live; using AIStudio.Provider.OpenAI; using AIStudio.Settings; using AIStudio.Tools.PluginSystem; @@ -221,7 +222,23 @@ public sealed class ProviderHuggingFace : BaseProvider /// public override Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return this.LoadModelsResponse(SecretStoreType.LLM_PROVIDER, "models", this.SelectChatModels, apiKeyProvisional, token: token); + return this.LoadModelsResponse(SecretStoreType.LLM_PROVIDER, "models", this.SelectChatModels, apiKeyProvisional, listingFactory: this.ListingsOf, token: token); + } + + /// + /// What the router stated about the models it knows. + /// + /// + /// Every model the router reports, not only the ones offered for chatting below: which models + /// are offered depends on the chosen inference provider, while a window belongs to whoever is + /// configured here, and both questions are asked of the same list. + /// + /// The response of the model endpoint. + /// One listing per model, which says nothing for the models nobody stated a window for. + private IEnumerable ListingsOf(ModelsResponse response) + { + var providerSlug = this.hfProvider.EndpointsId(); + return response.Data.Select(hfModel => ModelListing.For(hfModel.Id, hfModel.ContextWindowTokens(providerSlug))); } /// @@ -253,8 +270,8 @@ public sealed class ProviderHuggingFace : BaseProvider return false; return hfModel.Providers.Any(provider => - string.Equals(provider.Provider, providerSlug, StringComparison.OrdinalIgnoreCase) && - string.Equals(provider.Status, "live", StringComparison.OrdinalIgnoreCase)); + provider.IsLive && + string.Equals(provider.Provider, providerSlug, StringComparison.OrdinalIgnoreCase)); } /// diff --git a/app/MindWork AI Studio/Provider/Mistral/Model.cs b/app/MindWork AI Studio/Provider/Mistral/Model.cs index ae0a0878..d0994464 100644 --- a/app/MindWork AI Studio/Provider/Mistral/Model.cs +++ b/app/MindWork AI Studio/Provider/Mistral/Model.cs @@ -1,3 +1,13 @@ +using System.Text.Json.Serialization; + namespace AIStudio.Provider.Mistral; -public readonly record struct Model(string Id, string Object, int Created, string OwnedBy); \ No newline at end of file +/// +/// One model as Mistral lists it. +/// +/// The model's ID. +/// What kind of thing the entry is. Known value: "model". +/// When the model was published, as seconds since the epoch. +/// Who Mistral names as the owner of the model. +/// How much the model reads and writes in one conversation, in tokens. +public readonly record struct Model(string Id, string Object, int Created, string OwnedBy, [property: JsonPropertyName("max_context_length")] int? ContextWindowTokens); \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs b/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs index 11f436e4..15299569 100644 --- a/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs +++ b/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; using AIStudio.Chat; +using AIStudio.Models.Live; using AIStudio.Provider.OpenAI; using AIStudio.Settings; @@ -139,6 +140,8 @@ public sealed class ProviderMistral() : BaseProvider(LLMProviders.MISTRAL, new U storeType, "models", modelResponse => modelResponse.Data.Select(n => new Provider.Model(n.Id, null)), - apiKeyProvisional, token: token); + apiKeyProvisional, + listingFactory: modelResponse => modelResponse.Data.Select(n => ModelListing.For(n.Id, n.ContextWindowTokens)), + token: token); } } diff --git a/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterModel.cs b/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterModel.cs index 7cd47a59..92ca0c0b 100644 --- a/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterModel.cs +++ b/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterModel.cs @@ -1,8 +1,16 @@ +using System.Text.Json.Serialization; + namespace AIStudio.Provider.OpenRouter; /// /// A data model for an OpenRouter model from the API. /// +/// +/// The window is the model's, not that of any one provider behind it. OpenRouter also states a +/// window per provider it currently prefers, but it picks one per request, so a number taken from +/// there would describe a choice nobody has made yet. +/// /// The model's ID. /// The model's human-readable display name. -public readonly record struct OpenRouterModel(string Id, string? Name); +/// How much the model reads and writes in one conversation, in tokens. +public readonly record struct OpenRouterModel(string Id, string? Name, [property: JsonPropertyName("context_length")] int? ContextWindowTokens); \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs b/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs index a5844ee7..0ff35fb0 100644 --- a/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs +++ b/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs @@ -2,6 +2,7 @@ using System.Net.Http.Headers; using System.Runtime.CompilerServices; using AIStudio.Chat; +using AIStudio.Models.Live; using AIStudio.Provider.OpenAI; using AIStudio.Settings; @@ -125,9 +126,21 @@ public sealed class ProviderOpenRouter() : BaseProvider(LLMProviders.OPEN_ROUTER request.Headers.Add("HTTP-Referer", PROJECT_WEBSITE); request.Headers.Add("X-Title", PROJECT_NAME); }, + listingFactory: modelResponse => modelResponse.Data.Select(n => ModelListing.For(n.Id, n.ContextWindowTokens)), token: token); } + /// + /// Loads the models OpenRouter offers for embedding, which live on a route of their own. + /// + /// + /// Nothing is reported from here: this route answers with the embedding models alone, and what + /// is reported replaces everything an instance said before. The windows of the chat models + /// would go missing the moment somebody opens the embedding settings. + /// + /// An API key which is not stored yet. + /// The cancellation token to use. + /// The embedding models. private Task LoadEmbeddingModels(string? apiKeyProvisional, CancellationToken token) { return this.LoadModelsResponse( diff --git a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs index dee9e41c..ffe8ead7 100644 --- a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs +++ b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs @@ -3,7 +3,6 @@ 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; @@ -322,23 +321,9 @@ 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)); - } - } + /// One listing per model, which says nothing for the models the engine was silent about. + private static IEnumerable ListingsOf(IEnumerable models) => models.Select(model => ModelListing.For(model.Id, model.ContextWindowTokens)); private static bool IsMatchingLlamaCppTextModel(Model model, string[] ignorePhrases, string[] filterPhrases) { diff --git a/app/Tests/Models/Live/ListedModelsTests.cs b/app/Tests/Models/Live/ListedModelsTests.cs index bc385985..bd0672e1 100644 --- a/app/Tests/Models/Live/ListedModelsTests.cs +++ b/app/Tests/Models/Live/ListedModelsTests.cs @@ -151,4 +151,29 @@ public sealed class ListedModelsTests { Assert.That(ModelListing.NOTHING.ApplyTo(WHAT_THE_RULES_SAY), Is.EqualTo(WHAT_THE_RULES_SAY)); } + + [Test] + public void AWindowAProviderStatedIsTakenAsItIs() + { + Assert.That(ModelListing.For(MODEL, 32_768).Context.DefaultTokens, Is.EqualTo(32_768)); + } + + [TestCase(0, TestName = "A window of no tokens")] + [TestCase(-1, TestName = "A window of negative tokens")] + [TestCase(null, TestName = "No window at all")] + public void AWindowWhichIsNoWidthIsDroppedRatherThanRepaired(int? tokens) + { + // + // Every dialect comes through this one factory, so a provider answering with something + // nobody can interpret falls back to what the rules say -- and does so the same way for + // all of them, rather than once per provider and slightly differently each time. + // + Assert.That(ModelListing.For(MODEL, tokens), Is.EqualTo(ModelListing.NOTHING)); + } + + [Test] + public void AnEntryWithoutANameIsNoListing() + { + Assert.That(ModelListing.For(string.Empty, 32_768), Is.EqualTo(ModelListing.NOTHING)); + } } \ No newline at end of file diff --git a/app/Tests/Provider/HFModelTests.cs b/app/Tests/Provider/HFModelTests.cs new file mode 100644 index 00000000..3cc07a08 --- /dev/null +++ b/app/Tests/Provider/HFModelTests.cs @@ -0,0 +1,95 @@ +using AIStudio.Provider.HuggingFace; + +namespace AIStudio.Tests.Provider; + +/// +/// Checks which window a model has when it is reached through the Hugging Face router. +/// +/// +/// The router is the one provider where the window does not belong to the model: the same weights +/// run behind several inference providers, each configured by somebody else, and which of them +/// answers depends on what the user chose. +/// +[TestFixture] +public sealed class HFModelTests +{ + private const string AUTOMATIC = ""; + + private static readonly HFModel SERVED_BY_THREE = new("deepseek-ai/DeepSeek-R1", + [ + new("novita", "live", 64_000), + new("together", "live", 128_000), + new("fireworks-ai", "live", 160_000), + ]); + + [Test] + public void AChosenProviderAnswersForItself() + { + Assert.Multiple(() => + { + Assert.That(SERVED_BY_THREE.ContextWindowTokens("novita"), Is.EqualTo(64_000)); + Assert.That(SERVED_BY_THREE.ContextWindowTokens("together"), Is.EqualTo(128_000)); + }); + } + + [Test] + public void AChosenProviderIsFoundHoweverItIsSpelled() + { + Assert.That(SERVED_BY_THREE.ContextWindowTokens("Novita"), Is.EqualTo(64_000)); + } + + [Test] + public void LettingTheRouterChooseMeansTheSmallestWindowOnOffer() + { + // + // Nobody knows which provider the router will take. Promising the largest window would walk + // a conversation into an error the user could not see coming; the smallest one only warns + // them earlier than strictly necessary. + // + Assert.That(SERVED_BY_THREE.ContextWindowTokens(AUTOMATIC), Is.EqualTo(64_000)); + } + + [Test] + public void AProviderWhichIsNotServingDoesNotDecideAnything() + { + var oneIsDown = new HFModel("deepseek-ai/DeepSeek-R1", + [ + new("novita", "staging", 8_000), + new("together", "live", 128_000), + ]); + + Assert.Multiple(() => + { + Assert.That(oneIsDown.ContextWindowTokens(AUTOMATIC), Is.EqualTo(128_000), "The small window belongs to a provider nobody can reach."); + Assert.That(oneIsDown.ContextWindowTokens("novita"), Is.Null, "And asking for that provider by name does not bring it back either."); + }); + } + + [Test] + public void AProviderWhichDoesNotServeTheModelSaysNothingAboutIt() + { + Assert.That(SERVED_BY_THREE.ContextWindowTokens("cerebras"), Is.Null); + } + + [Test] + public void AWindowNobodyStatedIsSkippedRatherThanCountedAsNothing() + { + var halfStated = new HFModel("deepseek-ai/DeepSeek-R1", + [ + new("novita", "live", null), + new("together", "live", 128_000), + ]); + + Assert.That(halfStated.ContextWindowTokens(AUTOMATIC), Is.EqualTo(128_000), "A missing number is not the smallest number."); + } + + [Test] + public void AModelNobodyServesHasNoWindow() + { + Assert.Multiple(() => + { + Assert.That(new HFModel("org/model", null).ContextWindowTokens(AUTOMATIC), Is.Null); + Assert.That(new HFModel("org/model", []).ContextWindowTokens(AUTOMATIC), Is.Null); + }); + } +} \ No newline at end of file diff --git a/app/Tests/Provider/ModelListMetadataTests.cs b/app/Tests/Provider/ModelListMetadataTests.cs new file mode 100644 index 00000000..bc42bc76 --- /dev/null +++ b/app/Tests/Provider/ModelListMetadataTests.cs @@ -0,0 +1,141 @@ +using System.Text.Json; + +using AIStudio.Provider.Groq; +using AIStudio.Provider.OpenRouter; + +using MistralModelsResponse = AIStudio.Provider.Mistral.ModelsResponse; +using SelfHostedModelsResponse = AIStudio.Provider.SelfHosted.ModelsResponse; + +namespace AIStudio.Tests.Provider; + +/// +/// Checks that the numbers a provider already sends actually arrive in the records reading them. +/// +/// +/// Every one of these providers spells the context window differently, and each record renames it +/// to the one word the app uses. Getting such a name wrong fails silently -- the field stays null, +/// the model list still loads, and the only symptom is a window nobody ever sees. The snippets +/// below are shortened answers of the real routes, so that a rename is caught here rather than by +/// somebody wondering why their window never shows up. +/// +/// The options mirror what the providers deserialize with: names in snake case, which is what makes +/// the renaming attributes necessary in the first place. +/// +[TestFixture] +public sealed class ModelListMetadataTests +{ + private static readonly JsonSerializerOptions AS_THE_PROVIDERS_READ_IT = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }; + + [Test] + // ReSharper disable once InconsistentNaming + public void VLLMStatesTheWindowItWasStartedWith() + { + var response = JsonSerializer.Deserialize(""" + { + "object": "list", + "data": [ + { "id": "Qwen/Qwen3-32B", "object": "model", "owned_by": "vllm", "max_model_len": 32768 } + ] + } + """, AS_THE_PROVIDERS_READ_IT); + + Assert.That(response.Data, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(response.Data![0].ContextWindowTokens, Is.EqualTo(32_768)); + Assert.That(response.Data[0].OwnedBy, Is.EqualTo("vllm"), "Read with the shared options, this one arrives too -- it did not before."); + }); + } + + [Test] + public void AnEngineWhichStatesNoWindowLeavesItUnknown() + { + // + // Ollama and LM Studio answer the very same route without that field. Nothing may fail + // over it, and nothing may be invented for it either. + // + var response = JsonSerializer.Deserialize(""" + { + "object": "list", + "data": [ { "id": "gemma3:1b", "object": "model" } ] + } + """, AS_THE_PROVIDERS_READ_IT); + + Assert.That(response.Data, Is.Not.Null); + Assert.That(response.Data![0].ContextWindowTokens, Is.Null); + } + + [Test] + public void OpenRouterStatesTheWindowOfTheModel() + { + var response = JsonSerializer.Deserialize(""" + { + "data": [ + { + "id": "anthropic/claude-sonnet-4.5", + "name": "Anthropic: Claude Sonnet 4.5", + "context_length": 1000000, + "architecture": { "tokenizer": "Claude" }, + "top_provider": { "max_completion_tokens": 64000 } + } + ] + } + """, AS_THE_PROVIDERS_READ_IT); + + Assert.Multiple(() => + { + Assert.That(response.Data[0].ContextWindowTokens, Is.EqualTo(1_000_000)); + Assert.That(response.Data[0].Name, Is.EqualTo("Anthropic: Claude Sonnet 4.5"), "The fields we do read keep working next to the fields we deliberately do not."); + }); + } + + [Test] + public void GroqStatesTheWindowAsTheContextWindow() + { + var response = JsonSerializer.Deserialize(""" + { + "object": "list", + "data": [ { "id": "llama-3.3-70b-versatile", "object": "model", "context_window": 131072 } ] + } + """, AS_THE_PROVIDERS_READ_IT); + + Assert.That(response.Data[0].ContextWindowTokens, Is.EqualTo(131_072)); + } + + [Test] + public void MistralStatesTheWindowAsAMaximumLength() + { + var response = JsonSerializer.Deserialize(""" + { + "object": "list", + "data": [ { "id": "mistral-large-latest", "object": "model", "created": 1700000000, "owned_by": "mistralai", "max_context_length": 131072 } ] + } + """, AS_THE_PROVIDERS_READ_IT); + + Assert.That(response.Data[0].ContextWindowTokens, Is.EqualTo(131_072)); + } + + [Test] + public void TheRouterStatesAWindowPerInferenceProvider() + { + var response = JsonSerializer.Deserialize(""" + { + "data": [ + { + "id": "deepseek-ai/DeepSeek-R1", + "providers": [ + { "provider": "novita", "status": "live", "context_length": 64000 }, + { "provider": "together", "status": "live", "context_length": 128000 } + ] + } + ] + } + """, AS_THE_PROVIDERS_READ_IT); + + Assert.That(response.Data[0].Providers, Is.Not.Null); + Assert.That(response.Data[0].Providers![0].ContextWindowTokens, Is.EqualTo(64_000)); + } +} \ No newline at end of file