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