Rebuilt how AI Studio knows what a model can do (#960)
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-09-13 14:17:25 +02:00
1 parent d21e09dd1e
commit d85b4e71b6
287 files changed
+18341 -3677

No files matched your search

@@ -0,0 +1,29 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Alibaba;
/// <summary>
/// Alibaba's embedding models, which turn text into a vector and answer nothing.
/// </summary>
/// <remarks>
/// The previous rules answered for these with the Model Studio default and told them they call
/// functions. The prefix is Alibaba's own: the app filters the catalog by "text-embedding-" to find
/// them, which is also why the rule may be written that broadly -- bound to this provider, it can
/// only ever meet the models Alibaba names that way.
/// </remarks>
public sealed class ModelStudioEmbeddingFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.ALIBABA;
/// <inheritdoc />
public override ModelSource Source => new("https://www.alibabacloud.com/help/en/model-studio/embedding", new DateOnly(2026, 9, 11), "Provider/AlibabaCloud/ProviderAlibabaCloud.cs adds these in GetEmbeddingModels and filters the catalog by the prefix \"text-embedding-\".");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("text-embedding").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD)
.Capabilities(TEXT_INPUT | EMBEDDING)
.Kind(ModelKind.EMBEDDING);
}
@@ -0,0 +1,24 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Alibaba;
/// <summary>
/// QVQ, the thinking-only model which also looks at pictures.
/// </summary>
public sealed class ModelStudioQvqFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.ALIBABA;
/// <inheritdoc />
public override ModelSource Source => new("https://www.alibabacloud.com/help/en/model-studio/models", new DateOnly(2026, 9, 11), "Ported unchanged from the rules in ProviderExtensions.Alibaba.cs: images in, thinking which cannot be switched off, and no tools.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("qvq").AsSegment().OnlyOn(LLMProviders.ALIBABA_CLOUD)
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.ALWAYS);
}
@@ -0,0 +1,83 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Alibaba;
/// <summary>
/// The Qwen models Alibaba Cloud Model Studio serves.
/// </summary>
/// <remarks>
/// Everything called Model Studio here is bound to Alibaba Cloud, and that is the point of it.
/// Model Studio sells commercial models -- qwen-max, qwen3.7-max, qwq-plus -- which carry the
/// family names of the open weights without being them, and it answers differently for several
/// names the open weights share with it. The old rules kept the two apart by having two functions;
/// here they are kept apart by saying which provider a rule speaks for. The unbound families next
/// to these are the open weights, which answer everywhere else.
///
/// The first rule is the catalog's own fallback, and it is written as a plain substring on purpose:
/// a substring is the weakest thing a rule can be, so every other rule here beats it without anyone
/// arranging that. It also has to be one, because "qwen2.5-72b-instruct" does not contain "qwen" as
/// a whole name part -- the version grows straight out of the family name.
/// </remarks>
public sealed class ModelStudioQwenFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.ALIBABA;
/// <inheritdoc />
public override ModelSource Source => new("https://www.alibabacloud.com/help/en/model-studio/models", new DateOnly(2026, 9, 12), "Capabilities ported unchanged from ProviderExtensions.Alibaba.cs, which follow Alibaba's own list of models that call functions. Alibaba's announcement of Qwen3.8-Max states a window of up to one million tokens; the other Qwen models are served at sizes their list does not state per model.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("qwen").AsSubstring().OnlyOn(LLMProviders.ALIBABA_CLOUD)
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API);
// Qwen 3 thinks when asked to:
builder.Rule("qwen3").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD).Inherits()
.Reasoning(ReasoningSupport.OPTIONAL);
builder.Rule("qwen3.5").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD).InheritsFrom("qwen3")
.Capabilities(MULTIPLE_IMAGE_INPUT);
builder.Rule("qwen3.6").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD).InheritsFrom("qwen3")
.Capabilities(MULTIPLE_IMAGE_INPUT | VIDEO_INPUT)
.Reasoning(ReasoningSupport.ALWAYS);
//
// Qwen 3.7 thinks unless it is told not to, and it started out reading nothing but text.
// Vision arrived in the middle of the series, so only the June snapshot may be told that
// it sees: the rolling max alias still answers as the May one.
//
builder.Rule("qwen3.7").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD).InheritsFrom("qwen3")
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
builder.Rule("qwen3.7").AsPrefix().AlsoContains("preview").OnlyOn(LLMProviders.ALIBABA_CLOUD).Inherits()
.Reasoning(ReasoningSupport.ALWAYS);
builder.Rule("qwen3.7").AsPrefix().AlsoContains("2026-05-17").OnlyOn(LLMProviders.ALIBABA_CLOUD).Inherits();
builder.Rule("qwen3.7").AsPrefix().AlsoContains("2026-06-08").OnlyOn(LLMProviders.ALIBABA_CLOUD)
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | VIDEO_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
// Qwen 3.8, whose 27B checkpoint is what the tier without a size resolves to:
builder.Rule("qwen3.8").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD).InheritsFrom("qwen3")
.Capabilities(MULTIPLE_IMAGE_INPUT)
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
builder.Rule("qwen3.8-flash").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD).Inherits()
.Capabilities(VIDEO_INPUT);
//
// Unlike the open-weight checkpoint of the same name, the Max model keeps its vision when
// it is reached through Model Studio:
//
builder.Rule("qwen3.8-max").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD).InheritsFrom("qwen3.8-flash")
.Reasoning(ReasoningSupport.ALWAYS)
.ContextWindow(1_000_000);
}
}
@@ -0,0 +1,32 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Alibaba;
/// <summary>
/// The Qwen Omni models, which take everything in and answer in text or in speech.
/// </summary>
/// <remarks>
/// Alibaba lists the Qwen3 Omni series among the models which call functions and leaves the older
/// ones off that list, which is the whole difference between the two rules below.
/// </remarks>
public sealed class ModelStudioQwenOmniFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.ALIBABA;
/// <inheritdoc />
public override ModelSource Source => new("https://www.alibabacloud.com/help/en/model-studio/models", new DateOnly(2026, 9, 11), "Ported unchanged from the rules in ProviderExtensions.Alibaba.cs: every modality in, text and speech out, tool calling from Qwen3 on.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("qwen").AsSubstring().AlsoContains("omni").OnlyOn(LLMProviders.ALIBABA_CLOUD)
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | AUDIO_INPUT | SPEECH_INPUT | VIDEO_INPUT | TEXT_OUTPUT | SPEECH_OUTPUT)
.Apis(CHAT_COMPLETION_API);
builder.Rule("qwen3").AsPrefix().AlsoContains("omni").OnlyOn(LLMProviders.ALIBABA_CLOUD).Inherits()
.Capabilities(FUNCTION_CALLING);
}
}
@@ -0,0 +1,32 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Alibaba;
/// <summary>
/// The Qwen VL models, the ones built to look at pictures.
/// </summary>
/// <remarks>
/// As with the Omni series, Alibaba names only the Qwen3 VL models as function callers and the
/// older ones not at all.
/// </remarks>
public sealed class ModelStudioQwenVisionFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.ALIBABA;
/// <inheritdoc />
public override ModelSource Source => new("https://www.alibabacloud.com/help/en/model-studio/models", new DateOnly(2026, 9, 11), "Ported unchanged from the rules in ProviderExtensions.Alibaba.cs: images in, and tool calling from Qwen3 on.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("qwen").AsSubstring().AlsoContains("vl").OnlyOn(LLMProviders.ALIBABA_CLOUD)
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
builder.Rule("qwen3").AsPrefix().AlsoContains("vl").OnlyOn(LLMProviders.ALIBABA_CLOUD).Inherits()
.Capabilities(FUNCTION_CALLING);
}
}
@@ -0,0 +1,34 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Alibaba;
/// <summary>
/// QwQ as Model Studio serves it, which is qwq-plus.
/// </summary>
/// <remarks>
/// This is the contradiction the provider-bound rules were built for. What Model Studio sells under
/// this name is a commercial thinking-only model built on Qwen 2.5; QwQ-32B, which everybody else
/// serves, is the open-weight model. They share a family name and nothing else, and the old rules
/// could only keep them apart by living in two different functions.
///
/// Neither of them appears in Alibaba's list of models which call functions, and the model card of
/// the open weights does not mention tools at all, which is why no such ability is stated here.
/// Anybody who knows better turns it on in the expert settings.
/// </remarks>
public sealed class ModelStudioQwqFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.ALIBABA;
/// <inheritdoc />
public override ModelSource Source => new("https://www.alibabacloud.com/help/en/model-studio/models", new DateOnly(2026, 9, 11), "Ported unchanged from the rules in ProviderExtensions.Alibaba.cs: text in, text out, thinking which cannot be switched off, and no tools.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("qwq").AsSegment().OnlyOn(LLMProviders.ALIBABA_CLOUD)
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.ALWAYS);
}
@@ -0,0 +1,77 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Alibaba;
/// <summary>
/// Qwen as everybody except Alibaba Cloud serves it: the open weights.
/// </summary>
/// <remarks>
/// The counterpart to the Model Studio families next door, and the reason those are bound to their
/// provider. Alibaba sells commercial models under the same family names, and for several of them
/// it promises something else than the published checkpoint does. Nothing here is bound: these
/// rules answer wherever the weights are run, which is every gateway and every engine somebody
/// starts on their own machine.
///
/// The whole line calls functions, from Qwen 2.5 on, and the Coder checkpoints are built for
/// exactly that. Thinking is not promised by the fallback: the older generations cannot do it, and
/// which of the newer ones think by default differs per checkpoint, so those say it one by one.
/// </remarks>
public sealed class QwenFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.ALIBABA;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/Qwen", new DateOnly(2026, 9, 11), "Ported unchanged from the Qwen block of ProviderExtensions.OpenSource.cs, which is the one answering everywhere but Alibaba Cloud.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
//
// A substring, because the version grows straight out of the family name: there is no name
// part "qwen" in "qwen2.5-72b-instruct". It is also the weakest thing a rule can be, which
// is what lets every rule below beat it without anybody arranging an order.
//
builder.Rule("qwen").AsSubstring()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API);
// The VL checkpoints are the ones built to look at pictures:
builder.Rule("qwen").AsSubstring().AlsoContains("vl").Inherits()
.Capabilities(MULTIPLE_IMAGE_INPUT);
// Qwen 3.5 sees, and thinks when the request asks it to:
builder.Rule("qwen3.5").AsPrefix()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.OPTIONAL);
builder.Rule("qwen3.6").AsPrefix().Inherits()
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
//
// The 3.8 tier without a size is the 27B checkpoint: that is what a rolling tag such as
// "qwen3.8:latest" resolves to, so it is what the tier may promise.
//
builder.Rule("qwen3.8").AsPrefix().InheritsFrom("qwen3.6");
// Flash-Next is the published checkpoint and Flash the production model; both watch videos:
builder.Rule("qwen3.8-flash").AsPrefix().InheritsFrom("qwen3.8")
.Capabilities(VIDEO_INPUT);
//
// Blablador writes the 27B checkpoint in two further ways, and no normalization turns
// either into the canonical name: it separates the family from the version ("Qwen 3.8-27B
// with DFlash on haicluster"), and its short alias drops the dot ("alias-qwen38-27b").
//
builder.Rule("qwen-3.8-27b").AsSegment().InheritsFrom("qwen3.8");
builder.Rule("qwen38-27b").AsSegment().InheritsFrom("qwen3.8");
// The big 3.8 checkpoint reads nothing but text, and it thinks whatever it is asked:
builder.Rule("qwen3.8-2.4t-a95b").AsPrefix()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.ALWAYS);
}
}
@@ -0,0 +1,31 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Alibaba;
/// <summary>
/// QwQ as everybody except Alibaba Cloud serves it: the open weights built on Qwen 2.5.
/// </summary>
/// <remarks>
/// The other half of the contradiction the provider-bound rules exist for. What Model Studio sells
/// as "qwq-plus" is a commercial model; QwQ-32B, which the gateways and the local engines serve, is
/// the published checkpoint. The two share a family name and nothing else.
///
/// Both answer the same here, and for the same reason: neither the model card nor Alibaba's list of
/// models which call functions mentions tools at all. Anybody who knows better says so in the
/// expert settings.
/// </remarks>
public sealed class QwqFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.ALIBABA;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/Qwen/QwQ-32B", new DateOnly(2026, 9, 11), "Ported unchanged from the QwQ check of ProviderExtensions.OpenSource.cs: text in, text out, thinking which cannot be switched off, and no tools.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("qwq").AsSegment()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.ALWAYS);
}
@@ -0,0 +1,130 @@
using AIStudio.Models.Matching;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Anthropic;
/// <summary>
/// Claude, all of it: the 3.x models, the 4.x models, and the 5 line.
/// </summary>
/// <remarks>
/// One family, because every Claude is the same shape and always has been -- text and images in,
/// text out, tool calling, one API. What each generation adds to that is a single sentence about
/// thinking, and the rules below are almost nothing but those sentences.
///
/// The first rule is the family's own fallback, and it is a statement rather than an accident: a
/// Claude nobody has written a rule for yet is still a Claude, and every one of them so far reads
/// images and calls tools. It answers for whole name parts, so every rule bound to the start of a
/// name beats it, whatever their lengths -- which is what lets it sit first and mean "unless".
///
/// The one thing no rule below states is how many images a Claude takes. Anthropic ties that number
/// to the context window instead of to the model, so it is worked out afterwards rather than written
/// on every line which sets a window.
/// </remarks>
public sealed class ClaudeFamily : ModelFamily
{
/// <summary>
/// The window every Claude has unless its own rule states the larger one.
/// </summary>
private const int STANDARD_WINDOW = 200_000;
/// <summary>
/// The window of the Claude models which read a million tokens.
/// </summary>
private const int LARGE_WINDOW = 1_000_000;
/// <summary>
/// How many images one request may carry when the model has the standard window.
/// </summary>
private const int IMAGES_PER_REQUEST_STANDARD_WINDOW = 100;
/// <summary>
/// How many images one request may carry for every other Claude.
/// </summary>
private const int IMAGES_PER_REQUEST_OTHERWISE = 600;
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.ANTHROPIC;
/// <inheritdoc />
public override ModelSource Source => new("https://platform.claude.com/docs/en/build-with-claude/context-windows", new DateOnly(2026, 9, 12), "Capabilities ported unchanged from ProviderExtensions.Anthropic.cs: one shape for all of Claude, and one sentence per generation about how it thinks. The context window page names the models with a 1M window and says every other Claude has 200k.");
/// <inheritdoc />
public override IReadOnlyList<ModelSource> FurtherSources =>
[
new("https://platform.claude.com/docs/en/build-with-claude/vision", new DateOnly(2026, 9, 12), "The vision page gives the image limit as a rule rather than as a number per model: 100 images per request on the API for models with a 200k-token context window, 600 per request for all other models. The 20 it also names belongs to claude.ai, not to the API."),
new("https://platform.claude.com/docs/en/build-with-claude/token-counting", new DateOnly(2026, 9, 12), "Anthropic publishes no tokenizer file at all; they count through the /v1/messages/count_tokens endpoint instead. The same page warns that Claude 4.7 and later use a newer tokenizer, on which the same text counts roughly 30 percent higher -- so AI Studio's built-in estimate is further off for those models than for the older ones.")
];
/// <inheritdoc />
/// <remarks>
/// Anthropic states no image limit per model. They state a rule which reads off the context
/// window, and this is that rule -- written once rather than repeated as a number on every line
/// which sets a window. Two statements of one fact drift apart, and the way they drift here is
/// silent: the next Claude with the larger window would quietly keep the smaller limit because
/// somebody wrote one number and not the other.
/// </remarks>
public override ModelProfile Refine(in ModelId id, in ModelProfile selected)
{
if (!selected.Context.IsKnown)
return selected;
var perRequest = selected.Context.DefaultTokens is STANDARD_WINDOW ? IMAGES_PER_REQUEST_STANDARD_WINDOW : IMAGES_PER_REQUEST_OTHERWISE;
return selected with { Images = new ImageLimits(null, perRequest) };
}
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
//
// 200k is the window of every Claude which is not named on the list of the 1M ones, which is
// how Anthropic states it themselves: the page names the exceptions and says "other Claude
// models" for the rest. So the fallback carries it, and the generations which got the larger
// window say so one by one below.
//
builder.Rule("claude").AsSegment()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.ContextWindow(STANDARD_WINDOW)
.Tokenizer(TokenizerKind.PROVIDER_API, "/v1/messages/count_tokens");
//
// The 3.x models say nothing beyond the shape above, so nothing is written for them: the
// previous rules had a branch for "claude-3-" which returned exactly what its fallback
// returned. Only 3.7 differs, by being the first Claude which could be asked to think.
//
builder.Rule("claude-3-7").AsPrefix().InheritsFrom("claude")
.Reasoning(ReasoningSupport.OPTIONAL);
// The 4.x models think when a thinking budget is given, and not otherwise:
builder.Rule("claude-opus-4").AsPrefix().InheritsFrom("claude")
.Reasoning(ReasoningSupport.OPTIONAL);
builder.Rule("claude-sonnet-4").AsPrefix().InheritsFrom("claude-opus-4");
builder.Rule("claude-haiku-4-5").AsPrefix().InheritsFrom("claude-opus-4");
//
// Where the window grew inside the 4 line. These rules say nothing but the number: Opus 4.6
// through 4.8 and Sonnet 4.6 have the 1M window, while the 4.0, 4.1 and 4.5 models of the
// same prefixes keep the 200k they were released with.
//
builder.Rule("claude-opus-4-6").AsPrefix().InheritsFrom("claude-opus-4").ContextWindow(LARGE_WINDOW);
builder.Rule("claude-opus-4-7").AsPrefix().InheritsFrom("claude-opus-4").ContextWindow(LARGE_WINDOW);
builder.Rule("claude-opus-4-8").AsPrefix().InheritsFrom("claude-opus-4").ContextWindow(LARGE_WINDOW);
builder.Rule("claude-sonnet-4-6").AsPrefix().InheritsFrom("claude-opus-4").ContextWindow(LARGE_WINDOW);
// Opus 5 and Sonnet 5 think adaptively unless thinking is turned off:
builder.Rule("claude-opus-5").AsPrefix().InheritsFrom("claude")
.Reasoning(ReasoningSupport.ON_BY_DEFAULT)
.ContextWindow(LARGE_WINDOW);
builder.Rule("claude-sonnet-5").AsPrefix().InheritsFrom("claude-opus-5");
// Fable 5 and Mythos 5 always think, and there is no switch for it:
builder.Rule("claude-fable-5").AsPrefix().InheritsFrom("claude")
.Reasoning(ReasoningSupport.ALWAYS)
.ContextWindow(LARGE_WINDOW);
builder.Rule("claude-mythos-5").AsPrefix().InheritsFrom("claude-fable-5");
}
}
@@ -0,0 +1,41 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Baidu;
/// <summary>
/// ERNIE, from Baidu.
/// </summary>
/// <remarks>
/// The line calls functions and the thinking checkpoints keep the channel open whatever the request
/// says. The vision checkpoints are the exception, and the reason this family is written down at
/// all: they run in a thinking and a non-thinking mode, and tool calling is not documented for them.
/// Left to the assumption they would be offered tools nobody has said they can use.
///
/// The vision rule wins over the thinking rule by the latter stepping aside rather than by being
/// less specific: ERNIE ships a checkpoint which is both, and two rules claiming it with the same
/// right would be a coin toss.
/// </remarks>
public sealed class ErnieFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.BAIDU;
/// <inheritdoc />
public override ModelSource Source => new("https://ernie.baidu.com/blog/", new DateOnly(2026, 9, 12), "Ported unchanged from the ERNIE block of ProviderExtensions.OpenSource.cs.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("ernie").AsSubstring()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API);
builder.Rule("ernie").AsSubstring().AlsoContains("thinking").NotContains("vl").Inherits()
.Reasoning(ReasoningSupport.ALWAYS);
builder.Rule("ernie").AsSubstring().AlsoContains("vl")
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.OPTIONAL);
}
}
@@ -0,0 +1,30 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Cohere;
/// <summary>
/// Aya, which comes from Cohere as well and was not built for tools.
/// </summary>
/// <remarks>
/// Their documentation says it in as many words, which is why these are a family of their own
/// rather than a variant of Command: everything the Command rules state would be wrong here.
/// </remarks>
public sealed class AyaFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.COHERE;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.cohere.com/docs/aya", new DateOnly(2026, 9, 11), "Ported unchanged from the Aya block of ProviderExtensions.OpenSource.cs.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("aya-expanse").AsSubstring()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
builder.Rule("aya-vision").AsSubstring().Inherits()
.Capabilities(MULTIPLE_IMAGE_INPUT);
}
}
@@ -0,0 +1,41 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Cohere;
/// <summary>
/// Command, the Cohere line built for tool use.
/// </summary>
/// <remarks>
/// Most of the line calls functions, in one step and in several, so the family states it. Command A
/// Vision is the exception Cohere names outright: tool use is not supported with it.
/// </remarks>
public sealed class CommandFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.COHERE;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.cohere.com/docs/models", new DateOnly(2026, 9, 11), "Ported unchanged from the Command block of ProviderExtensions.OpenSource.cs.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("command-a").AsSubstring()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API);
builder.Rule("command-r").AsSubstring().Inherits();
// Command A+ sees, and thinks unless the request turns the thinking off:
builder.Rule("command-a-plus").AsSubstring().Inherits()
.Capabilities(MULTIPLE_IMAGE_INPUT)
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
builder.Rule("command-a-reasoning").AsSubstring().InheritsFrom("command-r")
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
builder.Rule("command-a-vision").AsSubstring()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
}
}
@@ -0,0 +1,57 @@
namespace AIStudio.Models;
/// <summary>
/// How much a model can read and write in one conversation, in tokens.
/// </summary>
/// <remarks>
/// Two numbers, because the model cards name two. There is what the model does as it ships, and
/// there is what an operator can raise it to by configuring the engine, usually through one of the
/// rope-scaling settings. A self-hosted model runs at whatever its operator chose, so the second
/// number is a ceiling, not a promise.
///
/// Nothing here says "unknown" with a zero. The default value of this type is unknown, which is the
/// right answer for a model nobody has written anything about yet, and a known window can never be
/// zero tokens wide because the factory below refuses to build one.
/// </remarks>
public readonly record struct ContextWindow
{
/// <summary>
/// The window of a model we have no statement about.
/// </summary>
public static readonly ContextWindow UNKNOWN = new();
/// <summary>
/// Whether anything is known about this window at all. When false, both numbers are meaningless.
/// </summary>
public bool IsKnown { get; private init; }
/// <summary>
/// What the model reads and writes without anyone configuring it.
/// </summary>
public int DefaultTokens { get; private init; }
/// <summary>
/// What an operator can raise the window to, or null when it cannot be raised or nobody knows.
/// </summary>
public int? RaisableToTokens { get; private init; }
/// <summary>
/// States a known context window.
/// </summary>
/// <param name="defaultTokens">What the model does as it ships. Has to be greater than zero.</param>
/// <param name="raisableTo">What an operator can raise it to. Has to be at least the default.</param>
/// <returns>The window.</returns>
public static ContextWindow Of(int defaultTokens, int? raisableTo = null)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(defaultTokens);
if (raisableTo is not null)
ArgumentOutOfRangeException.ThrowIfLessThan(raisableTo.Value, defaultTokens);
return new()
{
IsKnown = true,
DefaultTokens = defaultTokens,
RaisableToTokens = raisableTo,
};
}
}
@@ -0,0 +1,78 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.DeepSeek;
/// <summary>
/// DeepSeek, from V3 to V4, including R1 and the checkpoints distilled from it.
/// </summary>
/// <remarks>
/// One family for all of it, and bound to no provider: DeepSeek publishes its models as open
/// weights and offers them on its own platform under the very same names, so a rule written once
/// answers wherever the model turns up. The old code arrived at that by having its DeepSeek
/// function call the open weights function, which is one of the loops this rebuild is undoing.
///
/// The distills are the case the whole priority question came from. They are Llama and Qwen
/// checkpoints fine-tuned on R1 answers, so they carry "r1" in their name and would be read as R1
/// itself -- which would promise the tool calling they lost together with R1's chat template. Here
/// the rule for them is the R1 rule with one condition more, and that alone decides it.
///
/// Point releases behind a dot need a line of their own, as everywhere: "deepseek-v4" does not
/// answer for "deepseek-v4.1", because a dot separates versions rather than name parts.
/// </remarks>
public sealed class DeepSeekFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.DEEP_SEEK;
/// <inheritdoc />
public override ModelSource Source => new("https://api-docs.deepseek.com/quick_start/pricing", new DateOnly(2026, 9, 12), "Capabilities ported unchanged from ProviderExtensions.DeepSeek.cs and the DeepSeek block of ProviderExtensions.OpenSource.cs. The pricing page states a 1M window for the V4 models; the older lines are served at different sizes depending on who serves them, so no window is stated for them.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("deepseek").AsSegment()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
// The V3 line answers directly and calls functions:
builder.Rule("deepseek-v3").AsPrefix().Inherits()
.Capabilities(FUNCTION_CALLING);
//
// From V3.1 on there is a thinking mode which the request turns on, and V3.2 added tool
// calling inside it. The gateways write these either as "deepseek-v3.1" or as
// "deepseek-chat-v3.1", so the version alone is what is looked for.
//
builder.Rule("deepseek").AsSegment().AlsoContains("v3.1").InheritsFrom("deepseek-v3")
.Reasoning(ReasoningSupport.OPTIONAL);
builder.Rule("deepseek").AsSegment().AlsoContains("v3.2").InheritsFrom("deepseek-v3")
.Reasoning(ReasoningSupport.OPTIONAL);
builder.Rule("deepseek-r1").AsPrefix().InheritsFrom("deepseek-v3")
.Reasoning(ReasoningSupport.ALWAYS);
// The distills kept the chat template of the model they were built from, so none of the
// tool calling R1 itself was trained for survived:
builder.Rule("deepseek-r1").AsPrefix().AlsoContains("distill").Inherits()
.Removes(FUNCTION_CALLING);
builder.Rule("deepseek-v4").AsPrefix().InheritsFrom("deepseek-v3")
.Reasoning(ReasoningSupport.ON_BY_DEFAULT)
.ContextWindow(1_000_000);
builder.Rule("deepseek-v4").AsPrefix().AlsoContains("vision").Inherits()
.Capabilities(MULTIPLE_IMAGE_INPUT);
//
// The two aliases of DeepSeek's own platform. They name a mode rather than a model: both
// point at the current flash model, one with thinking and one without. Exactly these
// names and no others -- "deepseek-chat-v3.1" is a gateway's name for a version, not this
// alias.
//
builder.Rule("deepseek-chat").AsExact().InheritsFrom("deepseek-v3");
builder.Rule("deepseek-reasoner").AsExact().InheritsFrom("deepseek-v3")
.Reasoning(ReasoningSupport.ALWAYS);
}
}
@@ -0,0 +1,91 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Google;
/// <summary>
/// The Gemini chat models.
/// </summary>
/// <remarks>
/// A Gemini reads everything -- text, images, audio, speech, video -- writes text, and calls tools.
/// That is the first rule, and it is the family's own fallback for a Gemini nobody has written a
/// rule for yet. What the generations add to it is how they think, and the older exceptions take
/// something away instead.
///
/// Every generation gets a line of its own, including the dotted ones. The dot is a version
/// boundary rather than a name part boundary, deliberately -- it is what keeps llama3 and llama3.1
/// apart -- so a rule for "gemini-3" does not answer for "gemini-3.1", and each has to say so
/// itself. The previous rules searched for "gemini-3" anywhere in the name and covered unreleased
/// versions by accident; the price of not doing that is a line per generation, and the verification
/// run names any model of the corpus which finds no rule.
/// </remarks>
public sealed class GeminiFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.GOOGLE;
/// <inheritdoc />
public override ModelSource Source => new("https://ai.google.dev/gemini-api/docs/gemini-3", new DateOnly(2026, 9, 12), "Capabilities ported unchanged from ProviderExtensions.Google.cs: one shape for all of Gemini, one sentence per generation about thinking. The Gemini 3 guide states a one million token input window; the 2.5 model pages state their input limit as 1,048,576, and both numbers are written here as their page gives them.");
/// <inheritdoc />
public override IReadOnlyList<ModelSource> FurtherSources =>
[
new("https://ai.google.dev/gemini-api/docs/image-understanding", new DateOnly(2026, 9, 12), "States one number for the whole family: \"Gemini models support a maximum of 3,600 image files per request.\" The 20 MB it also names is a limit on the request body rather than on the number of images."),
new("https://ai.google.dev/gemini-api/docs/tokens", new DateOnly(2026, 9, 12), "Google publishes no tokenizer file for Gemini. Counting happens through the countTokens method of the API, which returns the number of tokens of the input alone.")
];
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
//
// Google states the image limit once, for all of Gemini, so it sits on the fallback and
// every generation inherits it. The two rules below which do not inherit from here say
// nothing about it: the live model looks at no still images at all, and for the 1.0 vision
// model Google's current pages state no number any more.
//
builder.Rule("gemini").AsSegment()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | AUDIO_INPUT | SPEECH_INPUT | VIDEO_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.Images(maxPerRequest: 3_600)
.Tokenizer(TokenizerKind.PROVIDER_API, "countTokens");
// The one Gemini which only ever read text and images:
builder.Rule("gemini-1.0-pro-vision").AsPrefix()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
//
// The live model, which belongs to a different API: it speaks back, and it is the one
// Gemini that does not look at still images.
//
builder.Rule("gemini-2.0-flash-live").AsPrefix()
.Capabilities(TEXT_INPUT | AUDIO_INPUT | SPEECH_INPUT | VIDEO_INPUT | TEXT_OUTPUT | SPEECH_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API);
//
// Google states an input limit and an output limit rather than one window. The input limit
// is the one a conversation is measured against, because that is where the conversation
// accumulates, so that is the number written here.
//
builder.Rule("gemini-2.5").AsPrefix().InheritsFrom("gemini")
.Reasoning(ReasoningSupport.ALWAYS)
.ContextWindow(1_048_576);
//
// The one exception of the 2.5 line: it can think, but only when asked. From the 3.x line
// on, even the Flash Lite models think at their lowest level.
//
builder.Rule("gemini-2.5-flash-lite").AsPrefix().InheritsFrom("gemini-2.5")
.Reasoning(ReasoningSupport.OPTIONAL);
builder.Rule("gemini-3").AsPrefix().InheritsFrom("gemini")
.Reasoning(ReasoningSupport.ALWAYS)
.ContextWindow(1_000_000);
builder.Rule("gemini-3.1").AsPrefix().InheritsFrom("gemini-3");
builder.Rule("gemini-3.7").AsPrefix().InheritsFrom("gemini-3");
// The two rolling aliases, which carry no version number and point at the current line:
builder.Rule("gemini-flash-latest").AsExact().InheritsFrom("gemini-3");
builder.Rule("gemini-pro-latest").AsExact().InheritsFrom("gemini-3");
}
}
@@ -0,0 +1,42 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Google;
/// <summary>
/// The Gemini models which draw as well as write.
/// </summary>
/// <remarks>
/// They are named like every other Gemini, with a version and a size, and the only thing setting
/// them apart is the name part "image". So the rules here are the generation rules of the chat
/// family with that one part required on top, and requiring it is exactly what makes them win: two
/// rules reaching equally far into a name are separated by how many conditions they carry.
///
/// What they can do is nearly the opposite of what their generation can. They write images, which
/// no chat Gemini does, and they call no tools, which every chat Gemini does. Reading them as chat
/// models of their line -- which is what happens when nobody asks about the image part first --
/// promises tool calling that is not there.
/// </remarks>
public sealed class GeminiImageFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.GOOGLE;
/// <inheritdoc />
public override ModelSource Source => new("https://ai.google.dev/gemini-api/docs/image-generation", new DateOnly(2026, 9, 11), "Ported unchanged from the rules in ProviderExtensions.Google.cs: images out, no tool calling, and thinking from the 3 line on.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("gemini-2.5").AsPrefix().AlsoContains("image")
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | IMAGE_OUTPUT)
.Apis(CHAT_COMPLETION_API);
// From the 3 line on they think about a complicated prompt, and it cannot be switched off:
builder.Rule("gemini-3").AsPrefix().AlsoContains("image").Inherits()
.Reasoning(ReasoningSupport.ALWAYS);
// Only the 3.1 Flash image models watch video:
builder.Rule("gemini-3.1").AsPrefix().AlsoContains("image").Inherits()
.Capabilities(VIDEO_INPUT);
}
}
@@ -0,0 +1,78 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Google;
/// <summary>
/// Gemma, the open weights Google publishes next to Gemini.
/// </summary>
/// <remarks>
/// Two generations and one spelling problem. Ollama writes "gemma3:27b" and the hub writes
/// "gemma-3-27b-it", and no normalization turns one into the other, so each statement stands twice.
/// What it buys is that the rules never have to ask who served the model.
///
/// Tool calling is the line between the generations. What Google documents for Gemma 3 is writing
/// the tool descriptions into the prompt by hand, which is a different thing from what the tools
/// field of an OpenAI-compatible request does: the chat template has neither a tool role nor tool
/// tokens, and Ollama refuses a request carrying tools for these models. Gemma 4 is the first with
/// tokens of its own, and the first that thinks -- when the request opens the thinking channel.
/// </remarks>
public sealed class GemmaFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.GOOGLE;
/// <inheritdoc />
public override ModelSource Source => new("https://ai.google.dev/gemma/docs/core", new DateOnly(2026, 9, 11), "Ported unchanged from the Gemma block of ProviderExtensions.OpenSource.cs.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
// The early generations take text only and were not built for tools:
builder.Rule("gemma").AsSubstring()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
// Gemma 3 reads pictures from the 4B checkpoint upwards:
builder.Rule("gemma3").AsSubstring()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
builder.Rule("gemma-3").AsSubstring().Inherits();
// The 1B checkpoint is the one that does not:
builder.Rule("gemma3").AsSubstring().AlsoContains("1b")
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
builder.Rule("gemma-3").AsSubstring().AlsoContains("1b").Inherits();
//
// The 3n checkpoints listen as well. Video is not a modality of any Gemma: the model cards
// list text, image, and audio, and mention video only as frames somebody else cut it into.
//
builder.Rule("gemma3n").AsSubstring()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | AUDIO_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
builder.Rule("gemma-3n").AsSubstring().Inherits();
// Every checkpoint of Gemma 4 is multimodal; there is no text-only variant of it:
builder.Rule("gemma4").AsSubstring()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.OPTIONAL);
builder.Rule("gemma-4").AsSubstring().Inherits();
// Three of its checkpoints hear, and they are named one by one because that is all they
// have in common:
builder.Rule("gemma4").AsSubstring().AlsoContains("e2b").Inherits().Capabilities(AUDIO_INPUT);
builder.Rule("gemma-4").AsSubstring().AlsoContains("e2b").Inherits();
builder.Rule("gemma4").AsSubstring().AlsoContains("e4b").Inherits();
builder.Rule("gemma-4").AsSubstring().AlsoContains("e4b").Inherits();
builder.Rule("gemma4").AsSubstring().AlsoContains("12b").Inherits();
builder.Rule("gemma-4").AsSubstring().AlsoContains("12b").Inherits();
}
}
@@ -0,0 +1,35 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Google;
/// <summary>
/// Google's embedding models, which turn text into a vector and answer nothing.
/// </summary>
/// <remarks>
/// The previous rules answered for these with the Google default: images in, text out, tool
/// calling. None of it is true, and the app already knows better -- it asks every provider for its
/// embedding models through a method of its own.
///
/// The Gemini one needs a rule of its own for another reason: its name begins with "gemini", so
/// without one it would be read as a chat model of the family.
/// </remarks>
public sealed class GoogleEmbeddingFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.GOOGLE;
/// <inheritdoc />
public override ModelSource Source => new("https://ai.google.dev/gemini-api/docs/embeddings", new DateOnly(2026, 9, 11), "The app lists these under IProvider.GetEmbeddingModels, which is where the statement that they embed comes from.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("text-embedding-004").AsExact()
.Capabilities(TEXT_INPUT | EMBEDDING)
.Kind(ModelKind.EMBEDDING);
builder.Rule("gemini-embedding").AsPrefix().Inherits();
}
}
@@ -0,0 +1,32 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Google;
/// <summary>
/// Imagen, which draws a picture from a description and does nothing else.
/// </summary>
/// <remarks>
/// The previous rules had no branch for it. Its name does not contain "gemini", so it fell to the
/// last line of the Google function and was answered as a chat model: reads images, writes text,
/// calls functions. Not one of the three is true, and the one thing it does -- writing an image --
/// was not said at all.
///
/// Whole name parts, not a substring: "imagen" also sits inside "imagenet" and "reimagined", and a
/// chat model carrying such a word would be turned into an image generator by a careless match.
/// </remarks>
public sealed class ImagenFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.GOOGLE;
/// <inheritdoc />
public override ModelSource Source => new("https://ai.google.dev/gemini-api/docs/imagen", new DateOnly(2026, 9, 11), "A description goes in and an image comes out; there is no conversation and no tool calling.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("imagen").AsSegment()
.Capabilities(TEXT_INPUT | IMAGE_OUTPUT)
.Kind(ModelKind.IMAGE_GENERATION);
}
@@ -0,0 +1,183 @@
using AIStudio.Models.Matching;
namespace AIStudio.Models.Hosting;
/// <summary>
/// The ways a host wraps a model name, and how to take one wrapping off again.
/// </summary>
/// <remarks>
/// A wrapping is worked on the name as the provider reported it, never on the normalized one. That
/// is not a detail: normalizing writes every separator as a hyphen, so "meta-llama/Llama-3.3-70B"
/// and "meta-llama-llama-3.3-70b" are the same text afterwards and nobody can say where the
/// organization ended. The slash, the colon, and the spaces are the whole evidence, and they only
/// exist in the original.
/// </remarks>
public static class HostNaming
{
/// <summary>
/// What separates the organization from the model on a hub.
/// </summary>
private const char ORGANIZATION_SEPARATOR = '/';
/// <summary>
/// What separates the model from the inference provider it should be routed to.
/// </summary>
private const char ROUTING_SEPARATOR = ':';
/// <summary>
/// Takes the organization off a hub style name.
/// </summary>
/// <remarks>
/// Hubs and gateways write "organization/model", and a few hosts put a whole path in front:
/// Fireworks answers with "accounts/fireworks/models/llama-v3p1-405b-instruct". Taking one
/// segment at a time is what covers both without a second rule -- the caller keeps asking until
/// nothing is left to take.
/// </remarks>
/// <param name="id">The name as it arrived.</param>
/// <param name="inner">The name without its first path segment.</param>
/// <param name="declaredVendor">Who the organization says built the model, when we recognize it.</param>
/// <returns>True, when there was an organization to take off.</returns>
public static bool TrySplitOrganization(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor)
{
inner = id;
declaredVendor = null;
var separatorIndex = id.Original.IndexOf(ORGANIZATION_SEPARATOR);
if (separatorIndex is -1)
return false;
var model = id.Original[(separatorIndex + 1)..];
if (string.IsNullOrWhiteSpace(model))
return false;
inner = new(model);
//
// An organization nobody recognizes says nothing rather than saying "unknown": the rules
// may still work out who built the model from its name, and a stated vendor would stop
// them from trying.
//
var vendor = VendorOfOrganization(id.Original[..separatorIndex]);
declaredVendor = vendor is ModelVendor.UNKNOWN ? null : vendor;
return true;
}
/// <summary>
/// Takes the routing suffix off a name.
/// </summary>
/// <remarks>
/// The suffix says where a request goes, not what the model is: "google/gemma-4-31B-it:novita"
/// is the same model as "google/gemma-4-31B-it". Names on the hub carry no colon of their own,
/// so the last one always starts the suffix. This is not true everywhere -- Ollama writes the
/// variant after a colon, as in "qwen3.8:27b-mlx", and taking that off would throw away which
/// model it is. That is why only the host which has a router asks for this.
/// </remarks>
/// <param name="id">The name as it arrived.</param>
/// <param name="inner">The name without its routing suffix.</param>
/// <returns>True, when there was a suffix to take off.</returns>
public static bool TryStripRoutingSuffix(in ModelId id, out ModelId inner)
{
inner = id;
var separatorIndex = id.Original.LastIndexOf(ROUTING_SEPARATOR);
if (separatorIndex is -1)
return false;
var model = id.Original[..separatorIndex];
if (string.IsNullOrWhiteSpace(model))
return false;
inner = new(model);
return true;
}
/// <summary>
/// Takes the position in a menu off a name.
/// </summary>
/// <remarks>
/// Blablador answers with the line a person would read in a list: "1 - Llama3 405 the best
/// general model". The leading number is where the model sits in that list, and it changes
/// whenever the operator adds one.
///
/// The spaces around the hyphen are what makes this safe to ask. A number followed directly by
/// a hyphen is an ordinary part of a name -- "70b-instruct" would lose the size it is named
/// after -- so only the spaced form counts as a menu position.
/// </remarks>
/// <param name="id">The name as it arrived.</param>
/// <param name="inner">The name without its leading number.</param>
/// <returns>True, when there was a menu position to take off.</returns>
public static bool TryStripMenuPosition(in ModelId id, out ModelId inner)
{
inner = id;
var text = id.Original.AsSpan();
var digits = 0;
while (digits < text.Length && char.IsAsciiDigit(text[digits]))
digits++;
if (digits is 0)
return false;
var afterDigits = text[digits..];
if (afterDigits.IsEmpty || afterDigits[0] is not ' ')
return false;
var afterSpace = afterDigits.TrimStart();
if (afterSpace.IsEmpty || afterSpace[0] is not '-')
return false;
var afterHyphen = afterSpace[1..];
if (afterHyphen.IsEmpty || afterHyphen[0] is not ' ')
return false;
var model = afterHyphen.TrimStart();
if (model.IsEmpty)
return false;
inner = new(model.ToString());
return true;
}
/// <summary>
/// Who an organization on a hub stands for.
/// </summary>
/// <remarks>
/// Hubs name the organization which published the weights, which is who built the model. The
/// spellings are theirs, not ours, which is why several of them appear twice: the same vendor
/// publishes under one name on one hub and another name on the next. Anything not listed is
/// somebody we have no rules for yet, and saying so is the honest answer.
/// </remarks>
/// <param name="organization">The organization as the host wrote it, in any casing.</param>
/// <returns>The vendor, or unknown.</returns>
public static ModelVendor VendorOfOrganization(string organization) => organization.ToLowerInvariant() switch
{
"openai" => ModelVendor.OPEN_AI,
"anthropic" => ModelVendor.ANTHROPIC,
"google" => ModelVendor.GOOGLE,
"mistral" or "mistralai" => ModelVendor.MISTRAL_AI,
"meta" or "meta-llama" => ModelVendor.META,
"alibaba" or "qwen" => ModelVendor.ALIBABA,
"deepseek" or "deepseek-ai" => ModelVendor.DEEP_SEEK,
"perplexity" => ModelVendor.PERPLEXITY,
"x-ai" or "xai" => ModelVendor.XAI,
"microsoft" => ModelVendor.MICROSOFT,
"nvidia" => ModelVendor.NVIDIA,
"ibm-granite" => ModelVendor.IBM,
"cohere" or "coherelabs" or "cohereforai" => ModelVendor.COHERE,
"moonshot" or "moonshotai" => ModelVendor.MOONSHOT_AI,
"tencent" or "tencent-hunyuan" => ModelVendor.TENCENT,
"z-ai" or "zai-org" => ModelVendor.Z_AI,
"minimax" or "minimaxai" => ModelVendor.MINIMAX,
"ai2" or "allenai" => ModelVendor.AI2,
"bytedance" or "bytedance-seed" => ModelVendor.BYTE_DANCE,
"tii" or "tiiuae" => ModelVendor.TII,
"inclusionai" => ModelVendor.INCLUSION_AI,
"baidu" or "baidu-ernie" => ModelVendor.BAIDU,
"huggingfacetb" => ModelVendor.HUGGING_FACE,
"servicenow" or "servicenow-ai" => ModelVendor.SERVICE_NOW,
"internlm" or "opengvlab" or "shanghai-ai-laboratory" => ModelVendor.SHANGHAI_AI_LAB,
"swiss-ai" => ModelVendor.SWISS_AI,
_ => ModelVendor.UNKNOWN,
};
}
@@ -0,0 +1,21 @@
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// Alibaba Cloud Model Studio.
/// </summary>
/// <remarks>
/// Worth knowing about this one: several names mean a different model here than they do anywhere
/// else. "qwq" is the commercial qwq-plus on Model Studio and the open weights everywhere else.
/// That is not settled here but in the rules, which can bind themselves to a provider -- this host
/// exists so that they have a provider to bind to.
/// </remarks>
public sealed class HostAlibabaCloud : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.ALIBABA_CLOUD;
/// <inheritdoc />
public override ModelSource Source => new("https://www.alibabacloud.com/help/en/model-studio/compatibility-of-openai-with-dashscope", new DateOnly(2026, 9, 11), "Models are named plainly, and the app reaches them through the OpenAI-compatible endpoint.");
}
@@ -0,0 +1,15 @@
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// Anthropic's own cloud.
/// </summary>
public sealed class HostAnthropic : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.ANTHROPIC;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.anthropic.com/en/api/messages", new DateOnly(2026, 9, 11), "Models are named plainly, and there is one API to reach them through.");
}
@@ -0,0 +1,20 @@
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// DeepSeek's own platform.
/// </summary>
/// <remarks>
/// It names its models by what they are for rather than by which checkpoint answers: "deepseek-chat"
/// and "deepseek-reasoner" both point at whatever is current. Those are aliases, not wrappings, so
/// there is nothing to take off -- the rules answer for the alias itself.
/// </remarks>
public sealed class HostDeepSeek : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.DEEP_SEEK;
/// <inheritdoc />
public override ModelSource Source => new("https://api-docs.deepseek.com/", new DateOnly(2026, 9, 11), "Models are named plainly, and the app reaches them through the OpenAI-compatible endpoint.");
}
@@ -0,0 +1,25 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// Fireworks AI, which puts a whole account path in front of every model.
/// </summary>
/// <remarks>
/// "accounts/fireworks/models/llama-v3p1-405b-instruct" is three segments of path and then the
/// model. Nothing here counts them: the same taking-off-one-segment the gateways use is asked
/// again until there is no path left. None of the three segments names a vendor we know, so none
/// of them claims to.
/// </remarks>
public sealed class HostFireworks : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.FIREWORKS;
/// <inheritdoc />
public override ModelSource Source => new("https://fireworks.ai/models?show=Serverless", new DateOnly(2026, 9, 11), "Models are named \"accounts/<account>/models/<model>\", served through the OpenAI-compatible chat completion API.");
/// <inheritdoc />
public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor) => HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
}
@@ -0,0 +1,26 @@
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// The GWDG's academic cloud, which resells commercial models next to the open weights it runs.
/// </summary>
/// <remarks>
/// This is the host the transport rule was written for. It offers Claude and GPT under the very
/// names their vendors use -- "claude-sonnet-5", "gpt-5.5" -- so the rules recognize them and
/// answer with everything those models can do at their vendor. Everything except the API: a request
/// goes to Göttingen, not to San Francisco, and the Responses API is not served there.
///
/// The old code arrived at the same answer by having the open weights rules notice a Claude name
/// and call the Anthropic rules, then correct the result. Here the recognizing and the correcting
/// are two different things in two different places, which is why neither has to know about the
/// other.
/// </remarks>
public sealed class HostGWDG : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.GWDG;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.hpc.gwdg.de/services/saia/index.html", new DateOnly(2026, 9, 11), "Open weights and resold commercial models alike are named plainly, and all of them are served through the OpenAI-compatible chat completion API.");
}
@@ -0,0 +1,15 @@
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// Google's own cloud.
/// </summary>
public sealed class HostGoogle : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.GOOGLE;
/// <inheritdoc />
public override ModelSource Source => new("https://ai.google.dev/gemini-api/docs/openai", new DateOnly(2026, 9, 11), "Models are named plainly, and the app reaches them through the OpenAI-compatible endpoint.");
}
@@ -0,0 +1,24 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// Groq, which serves open weights and writes some of their names the way the hub does.
/// </summary>
/// <remarks>
/// Both spellings appear side by side in its catalog: "llama-3.3-70b-versatile" carries no
/// organization, "moonshotai/kimi-k2-instruct" and "openai/gpt-oss-120b" do. Taking one off when
/// there is one settles both without a rule per spelling.
/// </remarks>
public sealed class HostGroq : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.GROQ;
/// <inheritdoc />
public override ModelSource Source => new("https://console.groq.com/docs/api-reference", new DateOnly(2026, 9, 11), "Models are named either plainly or as the hub names them, and served through the OpenAI-compatible chat completion API.");
/// <inheritdoc />
public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor) => HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
}
@@ -0,0 +1,29 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// Helmholtz Blablador, which answers with the line a person would read in a menu.
/// </summary>
/// <remarks>
/// "1 - Llama3 405 the best general model" is a whole sentence, and the number in front is where
/// the entry sits in the list -- it moves whenever the operator adds a model. Taking it off is the
/// one thing this host does; the prose after the model name stays because there is no telling
/// where the name ends and the recommendation begins.
/// </remarks>
public sealed class HostHelmholtz : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.HELMHOLTZ;
/// <inheritdoc />
public override ModelSource Source => new("https://sdlaml.pages.jsc.fz-juelich.de/ai/guides/blablador_api_access/", new DateOnly(2026, 9, 11), "Models are named as menu entries, \"<position> - <description>\", and served through the OpenAI-compatible chat completion API.");
/// <inheritdoc />
public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor)
{
declaredVendor = null;
return HostNaming.TryStripMenuPosition(id, out inner);
}
}
@@ -0,0 +1,15 @@
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// Hetzner's inference offering, which serves open weights under their plain names.
/// </summary>
public sealed class HostHetzner : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.HETZNER;
/// <inheritdoc />
public override ModelSource Source => new("https://experiments.hetzner.com/docs/inference", new DateOnly(2026, 9, 11), "Open weights named plainly, served through the OpenAI-compatible chat completion API.");
}
@@ -0,0 +1,36 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// The Hugging Face router, whose names carry two wrappings rather than one.
/// </summary>
/// <remarks>
/// "google/gemma-4-31B-it:novita" says three things at once: who published the weights, which model
/// it is, and which inference provider should answer. The suffix goes first, because it is the
/// outermost and because it says nothing about the model -- a request routed to Novita and one
/// routed to Together AI reach the same weights.
///
/// This is the case the whole walk was written for. A host which took both off at once would work
/// here and nowhere else; taking one off at a time is what also covers the account path Fireworks
/// puts in front, without either host knowing about the other.
/// </remarks>
public sealed class HostHuggingFace : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.HUGGINGFACE;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/docs/inference-providers/index", new DateOnly(2026, 9, 11), "Models are named as the hub names them, \"organization/model\", optionally followed by a colon and the inference provider to route to.");
/// <inheritdoc />
public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor)
{
declaredVendor = null;
if (HostNaming.TryStripRoutingSuffix(id, out inner))
return true;
return HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
}
}
@@ -0,0 +1,24 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// The IONOS AI Model Hub, which keeps the hub spelling of the models it serves.
/// </summary>
/// <remarks>
/// Its catalog reads like the hub's: "meta-llama/Llama-3.3-70B-Instruct",
/// "mistralai/Mistral-Small-24B-Instruct". So the organization comes off, and with it comes the
/// vendor -- stated rather than guessed from the name.
/// </remarks>
public sealed class HostIONOS : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.IONOS;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.ionos.com/cloud/ai/ai-model-hub", new DateOnly(2026, 9, 11), "Open weights named as the hub names them, served through the OpenAI-compatible chat completion API.");
/// <inheritdoc />
public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor) => HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
}
@@ -0,0 +1,30 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// A LiteLLM proxy, which somebody operates themselves and names as they please.
/// </summary>
/// <remarks>
/// Aliases here are whatever the operator wrote in their configuration. Many of them keep the
/// "vendor/model" shape, some name the cloud instead of the vendor ("azure/gpt-5.6"), and some are
/// a word ("the-fast-one"). Taking off a prefix costs nothing in the last case and helps in the
/// first two, and a prefix nobody recognizes states no vendor -- so a name the operator invented
/// is left for the rules to make what they can of.
///
/// This is also the host where a person is most likely to correct us by hand, which is what the
/// expert settings are for: an alias only its operator can decipher is not something rules will
/// ever get right.
/// </remarks>
public sealed class HostLiteLLM : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.LITE_LLM;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.litellm.ai/docs/proxy/user_keys", new DateOnly(2026, 9, 11), "Models are whatever the operator named them, served through the OpenAI-compatible chat completion API.");
/// <inheritdoc />
public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor) => HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
}
@@ -0,0 +1,20 @@
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// Mistral's own platform, which by now also serves models Mistral did not build.
/// </summary>
/// <remarks>
/// It names those under their plain names rather than prefixing them, so there is nothing to
/// unwrap here. Which model it is remains a question for the rules; what this host settles is that
/// whatever answers, it answers through Mistral's own API.
/// </remarks>
public sealed class HostMistral : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.MISTRAL;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.mistral.ai/api/", new DateOnly(2026, 9, 11), "Models are named plainly, its own and the open weights it hosts alike.");
}
@@ -0,0 +1,28 @@
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// OpenAI's own cloud, the one place where the Responses API is actually spoken.
/// </summary>
/// <remarks>
/// This is the single host that does not put its models on the ordinary chat completion API,
/// because it is the single place the app sends a Responses API request from. Everywhere else a
/// GPT model is reached -- a gateway, a reseller, somebody's own proxy -- it is reached through the
/// ordinary API, and the host there says so.
/// </remarks>
public sealed class HostOpenAI : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.OPEN_AI;
/// <inheritdoc />
public override ModelSource Source => new("https://platform.openai.com/docs/api-reference/responses", new DateOnly(2026, 9, 11), "Models are named plainly, and both the Responses API and the chat completion API are served here.");
/// <inheritdoc />
/// <remarks>
/// Nothing is taken away: whichever of the two APIs a model states, it can be reached through
/// it here.
/// </remarks>
public override ModelProfile ApplyTransport(in ModelProfile profile) => profile;
}
@@ -0,0 +1,25 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// OpenRouter, which serves other people's models and says whose they are.
/// </summary>
/// <remarks>
/// The vendor prefix is the reason the old rules delegated between vendors in circles: a name such
/// as "anthropic/claude-opus-5" had to be handed to whoever knew Claude, and the same for every
/// other vendor. Here the prefix is simply taken off, and the vendor stated, and one set of rules
/// answers the bare name -- no matter which provider it arrived from.
/// </remarks>
public sealed class HostOpenRouter : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.OPEN_ROUTER;
/// <inheritdoc />
public override ModelSource Source => new("https://openrouter.ai/docs/api-reference/overview", new DateOnly(2026, 9, 11), "Models are named \"vendor/model\", and every one of them is served through the OpenAI-compatible chat completion API.");
/// <inheritdoc />
public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor) => HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
}
@@ -0,0 +1,15 @@
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// Perplexity's own API.
/// </summary>
public sealed class HostPerplexity : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.PERPLEXITY;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.perplexity.ai/api-reference/chat-completions-post", new DateOnly(2026, 9, 11), "Models are named plainly, and there is one API to reach them through.");
}
@@ -0,0 +1,31 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// Somebody's own engine: Ollama, LM Studio, vLLM, llama.cpp, or a proxy in front of them.
/// </summary>
/// <remarks>
/// vLLM serves whatever it was pointed at, and what it was pointed at is usually a hub repository:
/// "meta-llama/Llama-3.3-70B-Instruct", "01-ai/yi-large". So the organization comes off here too.
///
/// The colon does not. Ollama writes the variant after it -- "qwen3.8:27b-mlx" -- and taking that
/// off would leave a name which no longer says which build of the model is running. Only the host
/// which actually has a router treats a colon as routing.
///
/// Whatever the engine can do beyond this, only the engine knows: how large a context window the
/// operator configured, how many images it accepts. Those come from the model list of the running
/// installation, not from a rule written here.
/// </remarks>
public sealed class HostSelfHosted : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.SELF_HOSTED;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html", new DateOnly(2026, 9, 11), "Models are named as the operator loaded them, often as a hub repository, and served through the OpenAI-compatible chat completion API.");
/// <inheritdoc />
public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor) => HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
}
@@ -0,0 +1,15 @@
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// xAI's own API, where Grok comes from.
/// </summary>
public sealed class HostX : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.X;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.x.ai/docs/api-reference", new DateOnly(2026, 9, 11), "Models are named plainly, and the app reaches them through the OpenAI-compatible endpoint.");
}
@@ -0,0 +1,56 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting;
/// <summary>
/// One place a model can be reached from, and what reaching it that way does to the answer.
/// </summary>
/// <remarks>
/// This is the routing graph, written down instead of grown into the rules. The old code solved
/// gateways and resellers by having one vendor's rules call another's, which turned into mutual
/// recursion -- Mistral into the open weights, the open weights back into Anthropic, Google, and
/// OpenAI -- and nobody could say from reading it which way a name would travel.
///
/// A host does two things, and only these two. It unwraps a name until the model underneath is
/// visible, and it says what the transport takes away. Unwrapping is iterative on purpose, because
/// the wrappings stack: Hugging Face first drops the routing suffix, then the organization prefix.
/// A host which serves other people's models under their plain names unwraps nothing and only
/// trims the transport, which is the same mechanism rather than a special case.
/// </remarks>
public interface IModelHost
{
/// <summary>
/// The provider this host answers for.
/// </summary>
LLMProviders Provider { get; }
/// <summary>
/// Where the statements about this host were read, and when.
/// </summary>
ModelSource Source { get; }
/// <summary>
/// Takes one wrapping off a name, if there is one.
/// </summary>
/// <remarks>
/// Called again with whatever comes out, until it says no. A host which declares who built the
/// model saves the rules from having to guess it from the name.
/// </remarks>
/// <param name="id">The name as it arrived.</param>
/// <param name="inner">The name with one wrapping removed.</param>
/// <param name="declaredVendor">Who the wrapping says built the model, when it says so.</param>
/// <returns>True, when a wrapping was removed.</returns>
bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor);
/// <summary>
/// Takes away what this host cannot offer, whatever the model itself can do.
/// </summary>
/// <remarks>
/// A provider reselling somebody else's model speaks its own dialect, not the vendor's: the
/// model may well be able to answer through a vendor specific API, but not here.
/// </remarks>
/// <param name="profile">What the model can do.</param>
/// <returns>What it can do through this host.</returns>
ModelProfile ApplyTransport(in ModelProfile profile);
}
@@ -0,0 +1,68 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting;
/// <summary>
/// The ordinary host: it serves models under the names they are known by, through the ordinary API.
/// </summary>
/// <remarks>
/// Most hosts differ from each other in one sentence, and this is what carries the rest. A host
/// which wraps its names says how to unwrap one; a host which speaks an API the others do not says
/// so; everything else is stated here once.
///
/// What a source means for a host: the page names where the behaviour is documented, so that a
/// person can re-check it in a minute. The statements themselves were read off the app's own
/// provider implementations and the model corpus, both of which are in this repository -- the
/// pages are where somebody looks when they doubt them.
/// </remarks>
public abstract class ModelHost : IModelHost
{
/// <summary>
/// The two capabilities which say through which API a model is reached.
/// </summary>
private const Capability THE_APIS = Capability.CHAT_COMPLETION_API | Capability.RESPONSES_API;
/// <inheritdoc />
public abstract LLMProviders Provider { get; }
/// <inheritdoc />
public abstract ModelSource Source { get; }
/// <inheritdoc />
/// <remarks>
/// Nothing is wrapped here: this host serves models under the names they are known by.
/// </remarks>
public virtual bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor)
{
inner = id;
declaredVendor = null;
return false;
}
/// <inheritdoc />
/// <remarks>
/// The Responses API is OpenAI's own, and the app speaks it in exactly one place, its OpenAI
/// provider. Wherever else a model is reached, it is reached through the ordinary chat
/// completion API -- whatever the model itself could do at its vendor.
/// </remarks>
public virtual ModelProfile ApplyTransport(in ModelProfile profile) => ThroughTheOrdinaryApi(profile);
/// <summary>
/// Puts a profile on the ordinary chat completion API.
/// </summary>
/// <remarks>
/// A profile which says nothing about APIs is left alone. An embedding model is reached through
/// neither of the two, and answering that it speaks the chat completion API would be a claim
/// nobody made.
/// </remarks>
/// <param name="profile">What the model can do.</param>
/// <returns>What it can do when reached through the ordinary API.</returns>
public static ModelProfile ThroughTheOrdinaryApi(in ModelProfile profile)
{
if (!profile.HasAny(THE_APIS))
return profile;
return profile with { Capabilities = (profile.Capabilities & ~Capability.RESPONSES_API) | Capability.CHAT_COMPLETION_API };
}
}
@@ -0,0 +1,144 @@
using System.Collections.Frozen;
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting;
/// <summary>
/// Which host answers for which provider, and the unwrapping walk itself.
/// </summary>
/// <remarks>
/// The walk is why this exists rather than a plain dictionary. Wrappings stack, and how deep they
/// go is the host's business, not the caller's: Hugging Face takes off a routing suffix and then an
/// organization, Fireworks takes off three path segments, and most hosts take off nothing at all.
/// Asking a host over and over until it says no covers all three without anybody counting.
/// </remarks>
public sealed class ModelHostIndex
{
/// <summary>
/// How often a name may be unwrapped before we stop believing the host.
/// </summary>
/// <remarks>
/// The deepest wrapping we know of is the account path Fireworks puts in front, at three
/// segments. The limit is not there for that -- it is there so that a host which hands back a
/// name it never shortened cannot hang the app. A host which needs more than this has gone
/// wrong, and stopping is a better answer than never returning.
/// </remarks>
public const int MAX_UNWRAPPING_STEPS = 8;
private readonly FrozenDictionary<LLMProviders, IModelHost> byProvider;
private ModelHostIndex(FrozenDictionary<LLMProviders, IModelHost> byProvider, IReadOnlyList<IModelHost> hosts, IReadOnlyList<LLMProviders> providersWithoutAHost)
{
this.byProvider = byProvider;
this.Hosts = hosts;
this.ProvidersWithoutAHost = providersWithoutAHost;
}
/// <summary>
/// Every host the index was built from, ordered by provider.
/// </summary>
public IReadOnlyList<IModelHost> Hosts { get; }
/// <summary>
/// The providers a person can configure for which nobody wrote a host.
/// </summary>
/// <remarks>
/// Not an error at runtime, and that is on purpose: a provider added to the app without a host
/// still works, its names are simply taken as they are. It is an error the verification run
/// reports, which is where a missing host should surface -- before the release, not during a
/// chat.
/// </remarks>
public IReadOnlyList<LLMProviders> ProvidersWithoutAHost { get; }
/// <summary>
/// Builds an index over a set of hosts.
/// </summary>
/// <param name="hosts">The hosts, in any order.</param>
/// <returns>The index.</returns>
/// <exception cref="InvalidOperationException">When two hosts answer for the same provider, or a host answers for none.</exception>
public static ModelHostIndex Build(IEnumerable<IModelHost> hosts)
{
var byProvider = new Dictionary<LLMProviders, IModelHost>();
foreach (var host in hosts)
{
if (host.Provider is LLMProviders.NONE)
throw new InvalidOperationException($"The host {host.GetType().Name} answers for no provider. A host has to name the provider it serves, because that is how anything finds it.");
if (byProvider.TryGetValue(host.Provider, out var alreadyThere))
throw new InvalidOperationException($"Both {alreadyThere.GetType().Name} and {host.GetType().Name} answer for {host.Provider}. Only one host can, because there is one way a name arrives from a provider.");
byProvider[host.Provider] = host;
}
var withoutAHost = Enum.GetValues<LLMProviders>()
.Where(provider => provider is not LLMProviders.NONE && !byProvider.ContainsKey(provider))
.ToArray();
var ordered = byProvider.OrderBy(entry => entry.Key).Select(entry => entry.Value).ToArray();
return new(byProvider.ToFrozenDictionary(), ordered, withoutAHost);
}
/// <summary>
/// The host answering for a provider.
/// </summary>
/// <param name="provider">The provider.</param>
/// <returns>The host, or nothing when nobody wrote one.</returns>
public IModelHost? Of(LLMProviders provider) => this.byProvider.GetValueOrDefault(provider);
/// <summary>
/// Takes a name apart until the model underneath is visible.
/// </summary>
/// <remarks>
/// The innermost statement about the vendor is the one that counts. A wrapping closer to the
/// model knows more about it than one further out, and a wrapping which says nothing does not
/// erase what an outer one said.
/// </remarks>
/// <param name="id">The name as the provider reported it.</param>
/// <param name="provider">Who reported it.</param>
/// <param name="declaredVendor">Who the wrappings say built the model, when they say so.</param>
/// <returns>The name with every wrapping taken off.</returns>
public ModelId Unwrap(in ModelId id, LLMProviders provider, out ModelVendor? declaredVendor)
{
declaredVendor = null;
var host = this.Of(provider);
if (host is null)
return id;
var current = id;
for (var step = 0; step < MAX_UNWRAPPING_STEPS; step++)
{
if (!host.TryUnwrap(current, out var inner, out var stated))
break;
// A host handing back what it was given would go round forever:
if (inner.Equals(current))
break;
current = inner;
if (stated is not null)
declaredVendor = stated;
}
return current;
}
/// <summary>
/// Takes away what a provider cannot offer, whatever the model itself can do.
/// </summary>
/// <remarks>
/// A provider without a host gets the answer every host but one gives: the ordinary chat
/// completion API. That is the safe direction -- claiming an API which is not there turns into
/// a failed request, while not claiming one merely means the app does not use it.
/// </remarks>
/// <param name="profile">What the model can do.</param>
/// <param name="provider">Who serves it.</param>
/// <returns>What it can do through this provider.</returns>
public ModelProfile ApplyTransport(in ModelProfile profile, LLMProviders provider)
{
var host = this.Of(provider);
return host?.ApplyTransport(profile) ?? ModelHost.ThroughTheOrdinaryApi(profile);
}
}
@@ -0,0 +1,65 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.IBM;
/// <summary>
/// Granite, from IBM.
/// </summary>
/// <remarks>
/// The instruct line calls functions with the OpenAI function definition schema, so the family
/// states it and the vision checkpoints say otherwise: for those, IBM documents no tool template.
/// The thinking came in two steps -- 3.2 and 3.3 have a toggle which starts off, 4.2 thinks unless
/// the request says otherwise, and the generations in between do not think at all.
///
/// Each generation is written twice. Ollama serves them as "granite4.2:8b", with the version glued
/// to the family name, while IBM writes "granite-4.2". The previous rules knew only IBM's spelling,
/// so everything anybody actually ran through Ollama quietly lost its thinking.
/// </remarks>
public sealed class GraniteFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.IBM;
/// <inheritdoc />
public override ModelSource Source => new("https://www.ibm.com/granite/docs/models/granite/", new DateOnly(2026, 9, 11), "Ported from the Granite block of ProviderExtensions.OpenSource.cs, with the spelling Ollama uses added to each generation.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("granite").AsSubstring()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API);
// The embedding checkpoints turn text into a vector; there is no conversation in them:
builder.Rule("granite-embedding").AsSubstring()
.Capabilities(TEXT_INPUT | EMBEDDING)
.Kind(ModelKind.EMBEDDING);
// The vision checkpoints look at pictures and have nothing to call a function with:
builder.Rule("granite").AsSubstring().AlsoContains("vision")
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
// From 4.2 on they think unless the request says otherwise:
builder.Rule("granite-4.2").AsSubstring().NotContains("vision")
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
builder.Rule("granite4.2").AsSubstring().NotContains("vision").Inherits();
// 3.2 and 3.3 have to be asked:
builder.Rule("granite-3.2").AsSubstring().NotContains("vision")
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.OPTIONAL);
builder.Rule("granite3.2").AsSubstring().NotContains("vision").Inherits();
builder.Rule("granite-3.3").AsSubstring().NotContains("vision").Inherits();
builder.Rule("granite3.3").AsSubstring().NotContains("vision").Inherits();
}
}
@@ -0,0 +1,55 @@
namespace AIStudio.Models;
/// <summary>
/// How many images a model accepts, where anybody has said so.
/// </summary>
/// <remarks>
/// Both numbers exist in the wild and they are not the same one: Anthropic documents a limit for a
/// whole request, while vLLM limits each prompt through --limit-mm-per-prompt and ships with that
/// set to one image. A model card may state either without the other, which is why each is optional
/// on its own instead of sharing one "is known" flag.
///
/// Zero is a real answer here, not a stand-in for unknown: an operator can configure an engine to
/// accept no images at all. Unknown is null.
/// </remarks>
/// <param name="MaxPerMessage">How many images fit into one message, or null when nobody has said.</param>
/// <param name="MaxPerRequest">How many images fit into one request, or null when nobody has said.</param>
public readonly record struct ImageLimits(int? MaxPerMessage, int? MaxPerRequest)
{
/// <summary>
/// The number to show a user, or to plan with, where nothing is known.
/// </summary>
/// <remarks>
/// This is a number for whoever needs one, never a limit to enforce. Today, saying that a model
/// takes several images says nothing about how many, and turning that into a hidden ceiling of
/// six would take something away from the models which handle a hundred.
/// </remarks>
public const int DEFAULT_MAX_IMAGES = 6;
/// <summary>
/// The limits of a model nobody has written anything about.
/// </summary>
public static readonly ImageLimits UNKNOWN = new(null, null);
/// <summary>
/// Whether either of the two numbers is known.
/// </summary>
public bool IsKnown => this.MaxPerMessage.HasValue || this.MaxPerRequest.HasValue;
/// <summary>
/// How many images may travel in one message, as far as anybody has said.
/// </summary>
/// <remarks>
/// A message is part of a request, so a message cannot carry more than a whole request may --
/// whichever of the two numbers is smaller decides, and a number nobody stated does not decide
/// anything. Null means nobody stated either, which is a gap and never a limit of zero.
/// </remarks>
public int? MaxInOneMessage => (this.MaxPerMessage, this.MaxPerRequest) switch
{
({ } perMessage, { } perRequest) => Math.Min(perMessage, perRequest),
({ } perMessage, null) => perMessage,
(null, { } perRequest) => perRequest,
_ => null,
};
}
@@ -0,0 +1,25 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models that work a screen instead of holding a conversation.
/// </summary>
/// <remarks>
/// They are named after the chat model they grew out of -- gemini-2.5-computer-use-preview -- and a
/// name is all they share with it. A request without the computer use tool is refused outright:
/// "This model requires the use of the Computer Use tool." So the resemblance is exactly the trap,
/// and this is the rule that keeps them out of the list a person picks a chat partner from.
/// </remarks>
public sealed class ComputerUseModelsFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://ai.google.dev/gemini-api/docs/computer-use", new DateOnly(2026, 9, 12), "Found while testing the switch-over: the model stood in the chat list although its API refuses every request which does not carry the computer use tool.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Modifier("computer-use").AsSegment().Kind(ModelKind.COMPUTER_USE);
}
@@ -0,0 +1,59 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models which turn text into a vector, whoever built them.
/// </summary>
/// <remarks>
/// Everything in this folder answers one question: what is a model made for, as opposed to what can
/// it do. The two used to be answered by two different pieces of code walking the same name, and
/// before that by every provider carrying a list of name fragments of its own -- lists which
/// disagreed, so that nomic-embed-text was an embedding model at one provider and a chat model at
/// the next.
///
/// These are modifiers rather than selectors, and that is the whole trick. A model keeps the family
/// it belongs to and this only says what it is for: llama-guard stays a Llama, and an embedding
/// checkpoint of a family we have rules for keeps those rules. Written as selectors they would have
/// to win against the family, and "embed" against "llama" is a contest neither of them should be
/// in -- both are five characters of substring, which is a tie, which is an error.
///
/// What none of them may become is a place for provider-specific knowledge. That "codestral" fills
/// in the middle at Mistral is true for Mistral; such a statement belongs to the family.
/// </remarks>
public sealed class EmbeddingModelsFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/models?pipeline_tag=feature-extraction", new DateOnly(2026, 9, 12), "Ported from the embedding markers of Provider/ModelKindExtensions.cs. The e5 line says it in its own family, so it is not repeated here.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Modifier("embed").AsSubstring().Kind(ModelKind.EMBEDDING);
builder.Modifier("bge").AsSubstring().Inherits();
builder.Modifier("mpnet").AsSubstring().Inherits();
builder.Modifier("paraphrase").AsSubstring().Inherits();
//
// The one marker which was really an organization rather than a model. It still holds where
// a name arrives whole, but the host takes the organization off before any rule sees the
// name, so the model this organization is known for has to stand next to it: all-MiniLM-L6-v2
// says nothing about embedding except through who published it.
//
builder.Modifier("sentence-transformers").AsSubstring().Inherits();
builder.Modifier("minilm").AsSubstring().Inherits();
builder.Modifier("gritlm").AsSubstring().Inherits();
// General Text Embeddings, from Alibaba. Written as a name part rather than as a substring,
// because three letters appear inside far too many unrelated words:
builder.Modifier("gte").AsSegment().Inherits();
}
}
@@ -0,0 +1,42 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models which draw rather than write.
/// </summary>
/// <remarks>
/// Google names its image models after the chat model they grew out of and appends the word:
/// gemini-3-pro-image, gemini-3.1-flash-image, gemini-2.5-flash-image. Read as a plain substring
/// that word is far too greedy -- it sits inside "imagenet" and "reimagined" as well, and a chat
/// model carrying such a word would disappear from the user's list. As a name part it says what it
/// is meant to say, and it covers OpenAI's gpt-image-1 along the way, which is why that name is not
/// stated a second time.
/// </remarks>
public sealed class ImageGenerationModelsFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/models?pipeline_tag=text-to-image", new DateOnly(2026, 9, 12), "Ported from the image generation markers of Provider/ModelKindExtensions.cs. Imagen and the Gemini image models state it in their own families as well, where the capabilities stand next to it.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Modifier("flux").AsSubstring().Kind(ModelKind.IMAGE_GENERATION);
builder.Modifier("stable-diffusion").AsSubstring().Inherits();
builder.Modifier("sdxl").AsSubstring().Inherits();
builder.Modifier("dall-e").AsSubstring().Inherits();
builder.Modifier("midjourney").AsSubstring().Inherits();
builder.Modifier("image").AsSegment().Inherits();
// The other half of Grok Imagine, which the video rule steps aside for:
builder.Modifier("grok-imagine").AsSegment().NotContains("video").Inherits();
}
}
@@ -0,0 +1,32 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models which judge content instead of writing it.
/// </summary>
/// <remarks>
/// The guard models are the reason this is stated as a plain substring rather than as a name part:
/// Meta writes Llama-Guard-3-8B, where the word stands on its own, but Alibaba writes Qwen3Guard-Gen-8B,
/// where it is glued to the version. A name part would see the first and miss the second.
///
/// Being a modifier is what makes that harmless. Llama-Guard keeps everything the Llama rules say
/// about it and is merely not offered as something to chat with -- which is also why this does not
/// collide with the family it belongs to, although both are substrings of the same length.
/// </remarks>
public sealed class ModerationModelsFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://platform.openai.com/docs/guides/moderation", new DateOnly(2026, 9, 12), "Ported unchanged from the moderation markers of Provider/ModelKindExtensions.cs.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Modifier("moderation").AsSubstring().Kind(ModelKind.MODERATION);
builder.Modifier("guard").AsSubstring().Inherits();
}
}
@@ -0,0 +1,32 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The entries a models endpoint lists which are no models.
/// </summary>
/// <remarks>
/// OpenAI lists its code interpreter's container resource among the models. Talking to it gets an
/// error, so it must not appear in any list the app shows -- and whatever else such a name might
/// suggest, none of the other kinds applies to it. That is why it outranks every one of them
/// instead of competing on the length of a word.
/// </remarks>
public sealed class NotAModelFamily : ModelFamily
{
/// <summary>
/// Why this outranks every other statement about a kind.
/// </summary>
private const string THERE_IS_NO_MODEL_TO_CLASSIFY = "An entry which is no model cannot be a model of some kind. Whatever else its name carries is beside the point, so no other statement may outweigh this one.";
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://platform.openai.com/docs/api-reference/containers", new DateOnly(2026, 9, 12), "Ported from the marker of Provider/ModelKindExtensions.cs which was checked before all others, written as a name part rather than as a substring so that a containerized model keeps its kind.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Modifier("container").AsSegment()
.Rank(2, THERE_IS_NO_MODEL_TO_CLASSIFY)
.Kind(ModelKind.OTHER);
}
@@ -0,0 +1,23 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models which read text off a page.
/// </summary>
/// <remarks>
/// A document goes in and its text comes out. There is no conversation in them, so they answer a
/// chat completion request with an error rather than with a reply.
/// </remarks>
public sealed class OcrModelsFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.mistral.ai/capabilities/OCR/basic_ocr/", new DateOnly(2026, 9, 12), "Ported unchanged from the OCR marker of Provider/ModelKindExtensions.cs.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Modifier("ocr").AsSubstring().Kind(ModelKind.OCR);
}
@@ -0,0 +1,42 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models which hold a spoken conversation over a live connection.
/// </summary>
/// <remarks>
/// They speak a protocol of their own, usually a WebSocket, and answer a chat completion request
/// with an error. Their names are built out of the models they grew from -- gpt-4o-realtime-preview,
/// gpt-realtime-mini -- so a name of this kind regularly carries a word about hearing or speaking as
/// well. Whichever of the two is longer would otherwise decide, and the live connection is the part
/// that makes the model unusable for a chat.
/// </remarks>
public sealed class RealtimeModelsFamily : ModelFamily
{
/// <summary>
/// Why this outranks what a name says about hearing or speaking.
/// </summary>
private const string THE_CONNECTION_DECIDES = "These names are built from the transcription and audio models they grew out of, so those markers match them too. The live connection is what rules out a chat, no matter what else the name says.";
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://developers.openai.com/api/docs/models/gpt-live-1", new DateOnly(2026, 9, 12), "Ported from the realtime marker of Provider/ModelKindExtensions.cs, where the same precedence was written as the order of two if statements. GPT-Live was added after it turned up in the chat list while testing.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Modifier("realtime").AsSubstring()
.Rank(1, THE_CONNECTION_DECIDES)
.Kind(ModelKind.REALTIME);
//
// The line which dropped the word. GPT-Live listens and speaks at the same time and leaves
// the thinking to a text model behind it, so there is even less of a conversation in it than
// in the realtime models it succeeds -- and nothing in the name says so any more.
//
builder.Modifier("gpt-live").AsSegment().Inherits();
}
}
@@ -0,0 +1,34 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models which put search results back into order.
/// </summary>
/// <remarks>
/// A reranker is almost always named after the embedding model it belongs to: bge-reranker sits
/// next to bge, gte-multilingual-reranker next to gte, Qwen3-VL-Reranker next to Qwen3-VL-Embedding.
/// So nearly every one of these names carries an embedding marker as well, and the computed
/// specificity has no way of knowing which of the two statements is the one about the model itself.
/// This is the one place where the order of asking is the knowledge, which is what the explicit rank
/// is for.
/// </remarks>
public sealed class RerankingModelsFamily : ModelFamily
{
/// <summary>
/// Why this outranks every statement about embedding models.
/// </summary>
private const string NAMED_AFTER_THE_EMBEDDING_MODEL = "A reranker carries the name of the embedding model it reorders for, so the embedding markers match it too. Which of them is right cannot be worked out of the text.";
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/models?pipeline_tag=text-ranking", new DateOnly(2026, 9, 12), "Ported from the reranking markers of Provider/ModelKindExtensions.cs, where the same precedence was written as the order of two if statements.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Modifier("rerank").AsSubstring()
.Rank(1, NAMED_AFTER_THE_EMBEDDING_MODEL)
.Kind(ModelKind.RERANKING);
}
@@ -0,0 +1,38 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models which speak.
/// </summary>
/// <remarks>
/// Besides the pure text-to-speech models this covers the ones which answer in audio, such as
/// gpt-audio and gpt-4o-audio-preview. Those do accept a text-only request, but they are made for
/// spoken conversations, and the providers offering them keep them out of their chat model lists as
/// well.
///
/// All three words are stated as name parts. The markers they replace carried a hyphen on one side
/// to say the same thing, which caught one name these do not: Coqui's XTTS glues the word to an x.
/// It is named outright rather than loosening all three into substrings, where "tts" would be three
/// characters claiming every name that happens to contain them.
/// </remarks>
public sealed class SpeechSynthesisModelsFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/models?pipeline_tag=text-to-speech", new DateOnly(2026, 9, 12), "Ported from the speech synthesis markers of Provider/ModelKindExtensions.cs, where each of the three was written twice to allow for a separator on either side.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Modifier("tts").AsSegment().Kind(ModelKind.SPEECH_SYNTHESIS);
builder.Modifier("xtts").AsSegment().Inherits();
builder.Modifier("speech").AsSegment().Inherits();
builder.Modifier("audio").AsSegment().Inherits();
}
}
@@ -0,0 +1,36 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models from before chat completions existed.
/// </summary>
/// <remarks>
/// Providers keep offering some of them -- Helmholtz Blablador still reports text-davinci-003 --
/// but asking any of them for a chat completion fails. They only answer through the completions
/// endpoint, which the app does not speak, so they must not stand among the chat models.
///
/// "ada" is deliberately not among these names: three letters appear in far too many unrelated ones,
/// and losing a chat model weighs heavier than keeping a dead one in the list.
/// </remarks>
public sealed class TextCompletionModelsFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://platform.openai.com/docs/api-reference/completions", new DateOnly(2026, 9, 12), "Ported unchanged from the text completion markers of Provider/ModelKindExtensions.cs.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Modifier("davinci").AsSubstring().Kind(ModelKind.TEXT_COMPLETION);
builder.Modifier("babbage").AsSubstring().Inherits();
builder.Modifier("curie").AsSubstring().Inherits();
// The one model of the 3.5 line which never learned to chat, next to the ones which did:
builder.Modifier("gpt-3.5-turbo-instruct").AsSegment().Inherits();
}
}
@@ -0,0 +1,31 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models which listen and write down what they heard.
/// </summary>
/// <remarks>
/// Whisper and Voxtral are missing here on purpose: both have a family of their own, where the
/// statement that they transcribe stands next to what they can do. Repeating it here would be a
/// second place to keep it right.
/// </remarks>
public sealed class TranscriptionModelsFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/models?pipeline_tag=automatic-speech-recognition", new DateOnly(2026, 9, 12), "Ported from the transcription markers of Provider/ModelKindExtensions.cs, minus the two which their own families now state.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
// OpenAI appends it to the model it grew out of: gpt-4o-transcribe, gpt-4o-mini-transcribe.
builder.Modifier("transcribe").AsSegment().Kind(ModelKind.TRANSCRIPTION);
builder.Modifier("wav2vec").AsSubstring().Inherits();
builder.Modifier("parakeet").AsSubstring().Inherits();
}
}
@@ -0,0 +1,39 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models which make video.
/// </summary>
/// <remarks>
/// Two of these names have to stand as a name part of their own. "kling" taken as a plain substring
/// also matches the organization Klingspor, the model Inkling, and the fine-tune
/// Llama-2-7b-chat-klingon -- all of them models to chat with, which would vanish from the user's
/// list. The models themselves are called kling-v1 and kling-video, where the name ends at a
/// separator. Google's veo is the same story with an even shorter word.
/// </remarks>
public sealed class VideoGenerationModelsFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/models?pipeline_tag=text-to-video", new DateOnly(2026, 9, 12), "Ported from the video generation markers of Provider/ModelKindExtensions.cs, where veo carried a trailing hyphen to say the same thing a name part says here. Grok Imagine was added after it turned up in the chat list while testing; see https://docs.x.ai/docs/models.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Modifier("sora").AsSubstring().Kind(ModelKind.VIDEO_GENERATION);
builder.Modifier("runway").AsSubstring().Inherits();
builder.Modifier("hailuo").AsSubstring().Inherits();
builder.Modifier("veo").AsSegment().Inherits();
builder.Modifier("kling").AsSegment().Inherits();
// Grok Imagine makes both stills and film; the word next to it says which:
builder.Modifier("grok-imagine").AsSegment().AlsoContains("video").Inherits();
}
}
@@ -0,0 +1,86 @@
using System.Collections.Concurrent;
using System.Collections.Frozen;
namespace AIStudio.Models.Live;
/// <summary>
/// What the configured providers last said about the models they serve.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class ListedModels
{
/// <summary>
/// The one the app reports into and asks.
/// </summary>
public static ListedModels Shared { get; } = new();
/// <summary>
/// Per configured provider instance, what its model list said about each model.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private readonly ConcurrentDictionary<string, FrozenDictionary<string, ModelListing>> byProvider = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Takes over what one provider instance said about its models, replacing what it said before.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="configuredProviderId">The instance that was asked. Nothing happens without one.</param>
/// <param name="listings">What its list stated, with the models it stated nothing about left in or out as convenient.</param>
public void Report(string configuredProviderId, IEnumerable<ModelListing> 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<string, ModelListing>(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);
}
/// <summary>
/// What one provider instance said about one of its models.
/// </summary>
/// <param name="configuredProviderId">The instance serving the model.</param>
/// <param name="modelId">The model, named the way that instance names it.</param>
/// <returns>What it stated, which is nothing when it was never asked or said nothing.</returns>
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;
}
}
@@ -0,0 +1,60 @@
namespace AIStudio.Models.Live;
/// <summary>
/// What a provider's own model list says about one of the models it serves.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="ModelId">The model, named the way the provider names it in its list.</param>
/// <param name="Context">The window the provider states for it, or unknown where it states none.</param>
public readonly record struct ModelListing(string ModelId, ContextWindow Context)
{
/// <summary>
/// What we have about a model nobody has reported anything about.
/// </summary>
public static readonly ModelListing NOTHING = new(string.Empty, ContextWindow.UNKNOWN);
/// <summary>
/// Whether this listing states anything at all.
/// </summary>
public bool IsKnown => this.Context.IsKnown;
/// <summary>
/// What a provider stated about one model, as every model list states it: a name and a number.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="modelId">The model, named the way the provider names it.</param>
/// <param name="contextWindowTokens">The window the provider stated, where it stated one.</param>
/// <returns>The listing, or nothing when there is nothing usable to keep.</returns>
public static ModelListing For(string modelId, int? contextWindowTokens) => string.IsNullOrWhiteSpace(modelId) || contextWindowTokens is not > 0
? NOTHING
: new(modelId, ContextWindow.Of(contextWindowTokens.Value));
/// <summary>
/// Puts what the provider stated over what the rules worked out.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="profile">What is known about the model without this listing.</param>
/// <returns>The profile, with what the provider stated in it.</returns>
public ModelProfile ApplyTo(in ModelProfile profile) => this.IsKnown ? profile with { Context = this.Context } : profile;
}
@@ -0,0 +1,42 @@
namespace AIStudio.Models.Matching;
/// <summary>
/// How tightly a pattern is bound to the name it matches.
/// </summary>
/// <remarks>
/// This is the first thing that decides which of two rules wins, and it is ordered by how much the
/// pattern claims to know: naming the whole model says more than naming how the name begins, which
/// says more than naming a part of it, which says more than appearing somewhere inside it.
/// </remarks>
public enum MatchKind
{
/// <summary>
/// The pattern is the whole name.
/// </summary>
EXACT,
/// <summary>
/// The name begins with the pattern, and a name part ends where the pattern ends.
/// </summary>
PREFIX,
/// <summary>
/// The pattern appears in the name as one or more whole name parts.
/// </summary>
/// <remarks>
/// This is the one to reach for by default. It is what the old rules meant when they said that
/// a family name counts "only where a name part begins", so that looking for the Yi family does
/// not answer for every model whose name happens to contain those two letters.
/// </remarks>
SEGMENT,
/// <summary>
/// The pattern appears anywhere in the name, boundaries or not.
/// </summary>
/// <remarks>
/// The last resort, for the names where a vendor glues things together, such as a version
/// number sitting inside a name part. It claims the least and therefore loses against every
/// other kind, which is what keeps it from swallowing families it was never meant for.
/// </remarks>
SUBSTRING,
}
@@ -0,0 +1,165 @@
using AIStudio.Provider;
namespace AIStudio.Models.Matching;
/// <summary>
/// What a rule says about the names it answers for.
/// </summary>
/// <remarks>
/// A pattern is written in the normalized form a model name is brought into: lowercase, hyphens
/// between the parts, dots kept. A pattern which is not in that form can never match anything, so
/// it is a mistake rather than a rule which happens to be quiet.
///
/// The extra conditions and the bindings are not only there to narrow a pattern down. They also
/// make it more specific, which is how a rule earns the right to win against a shorter one without
/// anybody writing an order.
/// </remarks>
public sealed record MatchPattern
{
/// <summary>
/// How tightly the text is bound to the name.
/// </summary>
public required MatchKind Kind { get; init; }
/// <summary>
/// The text to look for, in normalized form.
/// </summary>
public required string Text { get; init; }
/// <summary>
/// Name parts which have to be present as well.
/// </summary>
/// <remarks>
/// Each one is looked for as a whole name part, the same way the SEGMENT kind looks for its
/// text. Writing a hyphen into one of these is therefore both unnecessary and impossible: it
/// would not be a normalized pattern any more.
/// </remarks>
public IReadOnlyList<string> AlsoContains { get; init; } = [];
/// <summary>
/// Name parts whose presence rules this pattern out.
/// </summary>
public IReadOnlyList<string> NotContains { get; init; } = [];
/// <summary>
/// The provider this rule is written for, or null when it holds anywhere.
/// </summary>
/// <remarks>
/// This is what settles the cases where one name means two models depending on who serves it.
/// On Alibaba, "qwq" is qwq-plus, a commercial model; everywhere else it is the open weights
/// built on Qwen 2.5. Two rules, one of them bound.
/// </remarks>
public LLMProviders? OnlyOn { get; init; }
/// <summary>
/// The vendor this rule is written for, or null when it holds for any.
/// </summary>
/// <remarks>
/// A gateway which unwraps "anthropic/claude-sonnet-4-0" knows who built the model, and a rule
/// may insist on that instead of trusting a name.
/// </remarks>
public ModelVendor? OnlyFrom { get; init; }
/// <summary>
/// Moves this rule ahead of, or behind, everything the computed specificity would decide.
/// </summary>
/// <remarks>
/// The emergency exit, and it is meant to stay unused: the whole point of computing specificity
/// is that nobody writes an order by hand any more. A rule which sets this needs a comment
/// saying what the computation gets wrong, because the next person will read the rank as noise
/// otherwise. Negative values push a rule back.
/// </remarks>
public int ExplicitRank { get; init; }
/// <summary>
/// Whether every text of this pattern is written in normalized form.
/// </summary>
/// <remarks>
/// Normalizing is idempotent, so a text is normalized exactly when normalizing does not change
/// it. The compile time rule checks the same thing; this is what the tests and the verification
/// run use, and what catches a pattern which arrived from a plugin rather than from source.
/// </remarks>
public bool IsWellFormed => IsNormalized(this.Text) && this.AlsoContains.All(IsNormalized) && this.NotContains.All(IsNormalized);
/// <summary>
/// Whether this pattern answers for the given model.
/// </summary>
/// <param name="id">The model name, already normalized.</param>
/// <param name="provider">Who serves the model.</param>
/// <param name="vendor">Who built it, as far as anybody knows.</param>
/// <returns>True, when the rule applies.</returns>
public bool Matches(in ModelId id, LLMProviders provider, ModelVendor vendor)
{
if (this.OnlyOn is not null && this.OnlyOn.Value != provider)
return false;
if (this.OnlyFrom is not null && this.OnlyFrom.Value != vendor)
return false;
if (!this.MatchesText(id))
return false;
foreach (var required in this.AlsoContains)
if (!id.ContainsSegments(required))
return false;
foreach (var forbidden in this.NotContains)
if (id.ContainsSegments(forbidden))
return false;
return true;
}
/// <summary>
/// The name part the index files this pattern under, or an empty span when it cannot file it.
/// </summary>
/// <remarks>
/// A pattern which is bound to the start of a name, or to whole name parts, always begins at a
/// name part, so the first part of the pattern has to appear as a part of any name it matches.
/// That is what lets the index skip it for every other name. A substring pattern makes no such
/// promise and has to be checked against every name.
/// </remarks>
/// <returns>The first name part of the pattern, or empty.</returns>
public ReadOnlySpan<char> IndexKey()
{
if (this.Kind is MatchKind.SUBSTRING || string.IsNullOrWhiteSpace(this.Text))
return [];
var text = this.Text.AsSpan();
var separator = text.IndexOf(ModelId.SEGMENT_SEPARATOR);
return separator is -1 ? text : text[..separator];
}
/// <summary>
/// Everything about this pattern which decides what it matches, as one line of text.
/// </summary>
/// <remarks>
/// Two patterns with the same signature match exactly the same names, which is how the index
/// finds the rules that collide without having to reason about what a pattern could match. The
/// conditions are sorted, because stating them in a different order states the same thing.
/// </remarks>
/// <returns>The signature.</returns>
public string Signature()
{
var required = string.Join(',', this.AlsoContains.Order(StringComparer.Ordinal));
var forbidden = string.Join(',', this.NotContains.Order(StringComparer.Ordinal));
return $"{this.Kind}|{this.Text}|{this.OnlyOn}|{this.OnlyFrom}|+{required}|-{forbidden}";
}
/// <summary>
/// Whether a text is written the way a normalized model name is written.
/// </summary>
/// <param name="text">The text to check.</param>
/// <returns>True, when normalizing it would change nothing.</returns>
public static bool IsNormalized(string text) => !string.IsNullOrEmpty(text) && string.Equals(new ModelId(text).Normalized, text, StringComparison.Ordinal);
private bool MatchesText(in ModelId id) => this.Kind switch
{
MatchKind.EXACT => id.EqualsText(this.Text),
MatchKind.PREFIX => id.StartsWithSegments(this.Text),
MatchKind.SEGMENT => id.ContainsSegments(this.Text),
MatchKind.SUBSTRING => id.ContainsText(this.Text),
_ => false,
};
}
@@ -0,0 +1,234 @@
using System.Collections.Frozen;
using AIStudio.Provider;
namespace AIStudio.Models.Matching;
/// <summary>
/// Answers what is known about a model name, out of all the rules there are.
/// </summary>
/// <remarks>
/// The old rules asked every question in turn: a name arriving at the open weights block walked
/// past more than a hundred string comparisons before anything answered it, and it did so on every
/// render of every component which shows a provider. Here the name is cut into its parts and each
/// part looks up the handful of rules which mention it, so a name is measured against the rules
/// which could possibly apply to it and against nothing else.
///
/// Building the index costs a sort and a dictionary; that happens once. Answering allocates a small
/// list when several rules apply, which is the cold path -- the registry keeps the answers, so the
/// same model is not resolved twice.
///
/// Nothing here reaches for application state. A test can build an index and ask it questions
/// without the app ever having started.
/// </remarks>
public sealed class ModelFamilyIndex
{
private readonly FrozenDictionary<string, ModelRule[]>.AlternateLookup<ReadOnlySpan<char>> byNamePartLookup;
private readonly bool canLookUpNameParts;
private readonly ModelRule[] alwaysChecked;
private ModelFamilyIndex(ModelRule[] rules, FrozenDictionary<string, ModelRule[]> byNamePart, ModelRule[] alwaysChecked, IReadOnlyList<RuleAmbiguity> ambiguities)
{
this.alwaysChecked = alwaysChecked;
this.Rules = rules;
this.Ambiguities = ambiguities;
//
// Looking a name part up as a span rather than as a string is what keeps the lookup free of
// allocations. It needs a comparer which knows how to hash a span, and an index holding no
// rules at all has no comparer to speak of -- there is nothing to look up in that case
// either, so the flag simply skips the walk.
//
this.canLookUpNameParts = byNamePart.TryGetAlternateLookup(out this.byNamePartLookup);
}
/// <summary>
/// Every rule the index was built from, ordered by name.
/// </summary>
public IReadOnlyList<ModelRule> Rules { get; }
/// <summary>
/// Rules which claim exactly the same names as another rule.
/// </summary>
/// <remarks>
/// Found by comparing what the patterns say, which catches the case of two families claiming
/// one name outright. Two patterns which merely happen to overlap on some name cannot be found
/// this way -- deciding that in general is not a question about text any more. Those show up
/// when a name is actually resolved, as tied selectors, which is why the verification run
/// resolves the whole corpus instead of only reading the rules.
/// </remarks>
public IReadOnlyList<RuleAmbiguity> Ambiguities { get; }
/// <summary>
/// Builds an index over a set of rules.
/// </summary>
/// <param name="rules">The rules, in any order. The order they arrive in changes nothing.</param>
/// <returns>The index.</returns>
public static ModelFamilyIndex Build(IEnumerable<ModelRule> rules)
{
//
// Sorting by name, not by specificity: the comparison does the deciding, and a stable order
// is what makes two builds of the same rules produce the same answers, down to which rule
// is reported first in a conflict.
//
var ordered = rules.OrderBy(rule => rule.Description, StringComparer.Ordinal).ToArray();
var buckets = new Dictionary<string, List<ModelRule>>(StringComparer.Ordinal);
var alwaysChecked = new List<ModelRule>();
foreach (var rule in ordered)
{
var namePart = rule.Pattern.IndexKey();
if (namePart.IsEmpty)
{
alwaysChecked.Add(rule);
continue;
}
var key = namePart.ToString();
if (!buckets.TryGetValue(key, out var bucket))
buckets[key] = bucket = [];
bucket.Add(rule);
}
var byNamePart = buckets.ToFrozenDictionary(bucket => bucket.Key, bucket => bucket.Value.ToArray(), StringComparer.Ordinal);
return new(ordered, byNamePart, alwaysChecked.ToArray(), FindAmbiguities(ordered));
}
/// <summary>
/// Says what is known about a model.
/// </summary>
/// <param name="id">The model name.</param>
/// <param name="provider">Who serves the model.</param>
/// <param name="vendor">Who built it, as far as anybody knows.</param>
/// <returns>The profile, which is empty when no rule knows the name.</returns>
public ModelProfile Resolve(in ModelId id, LLMProviders provider, ModelVendor vendor) => this.Explain(id, provider, vendor).Profile;
/// <summary>
/// Says what is known about a model, and which rules said it.
/// </summary>
/// <param name="id">The model name.</param>
/// <param name="provider">Who serves the model.</param>
/// <param name="vendor">Who built it, as far as anybody knows.</param>
/// <returns>The profile together with the rules behind it.</returns>
public ModelResolution Explain(in ModelId id, LLMProviders provider, ModelVendor vendor)
{
if (id.IsEmpty)
return ModelResolution.NOTHING;
var match = new Match();
Consider(this.alwaysChecked, id, provider, vendor, ref match);
if (this.canLookUpNameParts)
foreach (var namePart in id.Segments)
if (this.byNamePartLookup.TryGetValue(namePart, out var candidates))
Consider(candidates, id, provider, vendor, ref match);
//
// Least specific first, so that the rule saying the most about this name has the last word.
// Sorting a list is not stable, so equally specific modifiers are ordered by name: applying
// them in a different order could otherwise produce a different profile on another machine.
//
match.Modifiers?.Sort(static (left, right) =>
{
var order = left.Specificity.CompareTo(right.Specificity);
return order is not 0 ? order : string.CompareOrdinal(left.Description, right.Description);
});
var profile = match.Selector?.Change.ApplyTo(ModelProfile.UNKNOWN) ?? ModelProfile.UNKNOWN;
if (match.Modifiers is not null)
foreach (var modifier in match.Modifiers)
profile = modifier.Change.ApplyTo(profile);
return new(profile, match.Selector, match.Modifiers ?? [], match.TiedSelectors ?? []);
}
private static void Consider(ModelRule[] candidates, in ModelId id, LLMProviders provider, ModelVendor vendor, ref Match match)
{
foreach (var rule in candidates)
{
if (!rule.Pattern.Matches(id, provider, vendor))
continue;
if (rule.Kind is ModelRuleKind.MODIFIER)
{
//
// A rule can be reached twice when a name repeats one of its parts. Applying a
// modifier twice would change nothing, but reporting it twice would read as if two
// rules had spoken.
//
match.Modifiers ??= [];
if (!match.Modifiers.Contains(rule))
match.Modifiers.Add(rule);
continue;
}
if (match.Selector is null)
{
match.Selector = rule;
continue;
}
if (ReferenceEquals(match.Selector, rule))
continue;
var order = rule.Specificity.CompareTo(match.Selector.Specificity);
if (order > 0)
{
match.Selector = rule;
match.TiedSelectors = null;
continue;
}
if (order < 0)
continue;
//
// Both rules claim the name with the same right, which the rules should not allow. The
// answer still has to be the same one on every machine and in every build, so the name
// of the rule decides rather than the order the rules arrived in.
//
var winner = string.CompareOrdinal(rule.Description, match.Selector.Description) < 0 ? rule : match.Selector;
var loser = ReferenceEquals(winner, rule) ? match.Selector : rule;
match.Selector = winner;
(match.TiedSelectors ??= []).Add(loser);
}
}
private static IReadOnlyList<RuleAmbiguity> FindAmbiguities(IReadOnlyList<ModelRule> rules)
{
var ambiguities = new List<RuleAmbiguity>();
var claimed = new Dictionary<string, ModelRule>(StringComparer.Ordinal);
foreach (var rule in rules)
{
if (rule.Kind is not ModelRuleKind.SELECTOR)
continue;
var signature = rule.Pattern.Signature();
if (claimed.TryGetValue(signature, out var other))
{
ambiguities.Add(new(other, rule, "Two selectors claim exactly the same model names."));
continue;
}
claimed[signature] = rule;
}
return ambiguities;
}
/// <summary>
/// What the walk over the candidate rules has found so far.
/// </summary>
private struct Match
{
public ModelRule? Selector;
public List<ModelRule>? TiedSelectors;
public List<ModelRule>? Modifiers;
}
}
@@ -0,0 +1,178 @@
namespace AIStudio.Models.Matching;
/// <summary>
/// A model ID in the form the rules are written in, next to the form the provider reported.
/// </summary>
/// <remarks>
/// Every provider names the same model differently, and the difference is rarely in the words: it
/// is in what sits between them. Ollama separates the variant with a colon ("qwen3.8:27b-mlx"),
/// Blablador answers with a whole sentence ("10 - Muse Glimmer 30b - the newest META model"),
/// Fireworks puts a path in front ("accounts/fireworks/models/llama-v3p1-405b-instruct"), and the
/// hubs use hyphens. Normalizing once, here, is what lets a rule be written once.
///
/// The dots stay. They carry the version boundary: llama3 and llama3.1 are different models, and
/// only the latter calls functions. Dropping them would merge the two. A hyphen, on the other hand,
/// is where one part of a name ends and the next begins -- which is why the patterns can say "at a
/// name part" and mean something.
/// </remarks>
/// <param name="modelId">The model ID as the provider reports it.</param>
public readonly struct ModelId(string modelId) : IEquatable<ModelId>
{
/// <summary>
/// What separates two parts of a normalized name.
/// </summary>
public const char SEGMENT_SEPARATOR = '-';
/// <summary>
/// The longest model ID we normalize without going to the heap.
/// </summary>
private const int MAX_STACK_ALLOCATED_MODEL_ID_LENGTH = 256;
private readonly string normalizedId = Normalize(modelId);
/// <summary>
/// The ID exactly as the provider reported it. This is what a person sees.
/// </summary>
public string Original => modelId ?? string.Empty;
/// <summary>
/// The ID in lowercase, with every separator written as a single hyphen.
/// </summary>
public string Normalized => this.normalizedId ?? string.Empty;
/// <summary>
/// Whether there is nothing here to match against.
/// </summary>
public bool IsEmpty => string.IsNullOrEmpty(this.normalizedId);
/// <summary>
/// The parts of the name, in order, without allocating anything.
/// </summary>
public ModelIdSegments Segments => new(this.Normalized.AsSpan());
/// <summary>
/// Whether the whole name is exactly this text.
/// </summary>
/// <param name="text">The text to compare against, already normalized.</param>
/// <returns>True, when the name and the text are the same.</returns>
public bool EqualsText(ReadOnlySpan<char> text) => !text.IsEmpty && this.Normalized.AsSpan().SequenceEqual(text);
/// <summary>
/// Whether the name begins with this text and a name part ends there.
/// </summary>
/// <remarks>
/// The boundary is what keeps "gpt-5" away from "gpt-55", and what keeps it away from "gpt-5.1"
/// as well: a dot is a version boundary, not a name part boundary, so those are two models and
/// a rule for one of them does not answer for the other.
/// </remarks>
/// <param name="text">The text to look for, already normalized.</param>
/// <returns>True, when the name starts with the text.</returns>
public bool StartsWithSegments(ReadOnlySpan<char> text)
{
if (text.IsEmpty)
return false;
var name = this.Normalized.AsSpan();
return name.StartsWith(text) && IsBoundaryAt(name, text.Length);
}
/// <summary>
/// Whether this text appears in the name as one or more whole name parts.
/// </summary>
/// <param name="text">The text to look for, already normalized.</param>
/// <returns>True, when the text sits between two name part boundaries.</returns>
public bool ContainsSegments(ReadOnlySpan<char> text)
{
if (text.IsEmpty)
return false;
var name = this.Normalized.AsSpan();
var searchedUpTo = 0;
while (searchedUpTo <= name.Length - text.Length)
{
var offset = name[searchedUpTo..].IndexOf(text);
if (offset is -1)
return false;
var start = searchedUpTo + offset;
if (IsBoundaryAt(name, start - 1) && IsBoundaryAt(name, start + text.Length))
return true;
// The same text may appear again further on, at a boundary this time:
searchedUpTo = start + 1;
}
return false;
}
/// <summary>
/// Whether this text appears anywhere in the name, boundaries or not.
/// </summary>
/// <param name="text">The text to look for, already normalized.</param>
/// <returns>True, when the name contains the text.</returns>
public bool ContainsText(ReadOnlySpan<char> text) => !text.IsEmpty && this.Normalized.AsSpan().IndexOf(text) is not -1;
public bool Equals(ModelId other) => string.Equals(this.Normalized, other.Normalized, StringComparison.Ordinal);
public override bool Equals(object? obj) => obj is ModelId other && this.Equals(other);
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(this.Normalized);
public override string ToString() => this.Original;
/// <summary>
/// Whether a name part begins or ends at this position.
/// </summary>
/// <remarks>
/// Positions outside the name count: the start of the name and its end are boundaries, which is
/// what makes a one part name match a rule written for that part.
/// </remarks>
/// <param name="name">The normalized name.</param>
/// <param name="index">The position to look at, which may be outside the name.</param>
/// <returns>True, when there is a boundary at this position.</returns>
private static bool IsBoundaryAt(ReadOnlySpan<char> name, int index) => index < 0 || index >= name.Length || name[index] is SEGMENT_SEPARATOR;
/// <summary>
/// Brings a model ID into the form the capability rules are written in.
/// </summary>
/// <param name="modelId">The model ID as the provider reports it, which may be nothing at all.</param>
/// <returns>The model ID in lowercase, with every separator written as a single hyphen.</returns>
private static string Normalize(string? modelId)
{
if (string.IsNullOrWhiteSpace(modelId))
return string.Empty;
//
// Normalizing never makes a name longer, so the original length is always enough room.
// Model IDs are short, which is why the buffer lives on the stack: the longest ones we
// know of are the descriptive names Blablador answers with, at around 75 characters. A
// provider reporting something longer still gets a correct answer, just from the heap.
//
Span<char> normalized = modelId.Length <= MAX_STACK_ALLOCATED_MODEL_ID_LENGTH
? stackalloc char[modelId.Length]
: new char[modelId.Length];
var length = 0;
foreach (var character in modelId)
{
if (char.IsAsciiLetterOrDigit(character) || character is '.')
{
normalized[length++] = char.ToLowerInvariant(character);
continue;
}
// Anything else separates two parts of the name. A leading separator, and a repeated
// one, say nothing and would only get in the way of the patterns:
if (length is 0 || normalized[length - 1] is SEGMENT_SEPARATOR)
continue;
normalized[length++] = SEGMENT_SEPARATOR;
}
// A trailing separator carries no meaning either:
if (length > 0 && normalized[length - 1] is SEGMENT_SEPARATOR)
length--;
return new string(normalized[..length]);
}
}
@@ -0,0 +1,57 @@
namespace AIStudio.Models.Matching;
/// <summary>
/// Walks the parts of a normalized model name without cutting it into strings.
/// </summary>
/// <remarks>
/// The index looks up every part of a name to find the rules which could possibly apply to it. That
/// happens for every model of every configured provider, so the walk itself must not allocate: the
/// parts stay slices of the name they came from. This is both the enumerable and the enumerator,
/// which is what lets foreach use it without an interface in between.
/// </remarks>
/// <param name="normalizedId">The normalized model name to walk.</param>
public ref struct ModelIdSegments(ReadOnlySpan<char> normalizedId)
{
private ReadOnlySpan<char> remaining = normalizedId;
/// <summary>
/// The part the walk currently stands on.
/// </summary>
public ReadOnlySpan<char> Current { get; private set; } = default;
/// <summary>
/// Hands foreach the walk itself.
/// </summary>
/// <returns>This walk, at its beginning.</returns>
public readonly ModelIdSegments GetEnumerator() => this;
/// <summary>
/// Steps to the next part of the name.
/// </summary>
/// <returns>True, as long as there was one.</returns>
public bool MoveNext()
{
while (!this.remaining.IsEmpty)
{
var separator = this.remaining.IndexOf(ModelId.SEGMENT_SEPARATOR);
if (separator is -1)
{
this.Current = this.remaining;
this.remaining = default;
return true;
}
this.Current = this.remaining[..separator];
this.remaining = this.remaining[(separator + 1)..];
//
// Normalizing leaves no empty part behind, so this only guards against a name which
// never went through it. Skipping is the right answer: an empty part matches nothing.
//
if (!this.Current.IsEmpty)
return true;
}
return false;
}
}
@@ -0,0 +1,37 @@
namespace AIStudio.Models.Matching;
/// <summary>
/// What the index made of one model name, and how it got there.
/// </summary>
/// <remarks>
/// The profile alone is what the app asks for. The rest is for the people maintaining the rules:
/// which rule answered, what adjusted the answer afterwards, and whether two rules claimed the name
/// with the same right. The verification run reads all of it; a test that wants to know why a model
/// came out the way it did reads it too.
/// </remarks>
/// <param name="Profile">Everything known about the model.</param>
/// <param name="Selector">The rule which chose the model, or null when no rule knows the name.</param>
/// <param name="Modifiers">The rules which adjusted the answer, in the order they were applied.</param>
/// <param name="TiedSelectors">Rules which claimed the name just as strongly as the selector did.</param>
public sealed record ModelResolution(ModelProfile Profile, ModelRule? Selector, IReadOnlyList<ModelRule> Modifiers, IReadOnlyList<ModelRule> TiedSelectors)
{
/// <summary>
/// The answer for a name no rule was even asked about.
/// </summary>
public static readonly ModelResolution NOTHING = new(ModelProfile.UNKNOWN, null, [], []);
/// <summary>
/// Whether more than one rule claimed this name with the same specificity.
/// </summary>
/// <remarks>
/// Always a mistake in the rules. The answer is still the same one every time, so a build never
/// depends on the order the rules were registered in, but which of the two was meant is
/// something only a person can say.
/// </remarks>
public bool IsAmbiguous => this.TiedSelectors.Count > 0;
/// <summary>
/// Whether any rule at all knew this name.
/// </summary>
public bool IsKnown => this.Selector is not null;
}
@@ -0,0 +1,43 @@
namespace AIStudio.Models.Matching;
/// <summary>
/// One statement about a set of model names: which names, and what holds for them.
/// </summary>
/// <param name="pattern">Which names this rule answers for.</param>
/// <param name="kind">Whether the rule chooses the model or adjusts the choice.</param>
/// <param name="change">What the rule states.</param>
/// <param name="origin">Who wrote the rule, so that a conflict can name both sides.</param>
public sealed class ModelRule(MatchPattern pattern, ModelRuleKind kind, ModelProfileChange change, string origin)
{
/// <summary>
/// Which names this rule answers for.
/// </summary>
public MatchPattern Pattern { get; } = pattern;
/// <summary>
/// Whether the rule chooses the model or adjusts the choice.
/// </summary>
public ModelRuleKind Kind { get; } = kind;
/// <summary>
/// What the rule states.
/// </summary>
public ModelProfileChange Change { get; } = change;
/// <summary>
/// Who wrote the rule: a family, a host, or a plugin.
/// </summary>
public string Origin { get; } = origin;
/// <summary>
/// How much this rule claims to know, worked out once when the rule is built.
/// </summary>
public RuleSpecificity Specificity { get; } = RuleSpecificity.Of(pattern);
/// <summary>
/// Names the rule in one line, for conflict reports and for breaking ties the same way twice.
/// </summary>
public string Description { get; } = $"{origin}: {kind} {pattern.Kind} \"{pattern.Text}\"";
public override string ToString() => this.Description;
}
@@ -0,0 +1,24 @@
namespace AIStudio.Models.Matching;
/// <summary>
/// What a rule does once it matches.
/// </summary>
public enum ModelRuleKind
{
/// <summary>
/// Chooses which model this is. Exactly one selector wins, the most specific one.
/// </summary>
SELECTOR,
/// <summary>
/// Adjusts whatever the selector chose. Every matching modifier applies.
/// </summary>
/// <remarks>
/// This is for the statements which hold across families, and which every family would
/// otherwise have to repeat: a base checkpoint was never instruction tuned no matter who built
/// it, and a gateway serving somebody else's model cannot offer that vendor's own API. In the
/// old rules those had to sit at the very top of the file, which is why anything below them
/// could not state an exception.
/// </remarks>
MODIFIER,
}
@@ -0,0 +1,12 @@
namespace AIStudio.Models.Matching;
/// <summary>
/// Two rules which claim the same names with the same right.
/// </summary>
/// <param name="First">One of the two rules.</param>
/// <param name="Second">The other one.</param>
/// <param name="Reason">What makes them collide, in a sentence a person can act on.</param>
public sealed record RuleAmbiguity(ModelRule First, ModelRule Second, string Reason)
{
public override string ToString() => $"{this.Reason} ({this.First.Description} <-> {this.Second.Description})";
}
@@ -0,0 +1,59 @@
namespace AIStudio.Models.Matching;
/// <summary>
/// How much a rule claims to know, computed from the rule itself.
/// </summary>
/// <remarks>
/// This is the heart of the whole rebuild. In the old rules, which branch won was decided by where
/// it stood in the file, so a block for one family could swallow another one -- the Llama block ate
/// the DeepSeek distills because it happened to come first -- and nothing in the language noticed.
/// Here nobody writes an order. A rule saying more about a name beats a rule saying less, and
/// "deepseek-r1" says more than "llama" without anyone deciding that it should.
///
/// Two rules of equal specificity which can match the same name are a mistake, not a coin toss.
/// The index reports them, and resolving still picks the same one every time, so a build never
/// depends on which rule was registered first.
/// </remarks>
/// <param name="ExplicitRank">What a rule wrote down by hand to override all of the below.</param>
/// <param name="Kind">How tightly the pattern is bound to the name.</param>
/// <param name="PatternLength">How much of the name the pattern spells out.</param>
/// <param name="Conditions">How many further name parts the rule requires or forbids.</param>
/// <param name="Binding">Whether the rule is tied to a provider, a vendor, or both.</param>
public readonly record struct RuleSpecificity(int ExplicitRank, int Kind, int PatternLength, int Conditions, int Binding) : IComparable<RuleSpecificity>
{
/// <summary>
/// Works out how specific a pattern is.
/// </summary>
/// <param name="pattern">The pattern to measure.</param>
/// <returns>Its specificity.</returns>
public static RuleSpecificity Of(MatchPattern pattern) => new(
ExplicitRank: pattern.ExplicitRank,
Kind: WeightOf(pattern.Kind),
PatternLength: pattern.Text.Length,
Conditions: pattern.AlsoContains.Count + pattern.NotContains.Count,
Binding: (pattern.OnlyOn is null ? 0 : 1) + (pattern.OnlyFrom is null ? 0 : 1));
/// <summary>
/// Compares two specificities, most specific last.
/// </summary>
/// <remarks>
/// The criteria are weighed in the order they are written in this type, and a tuple compares
/// exactly that way: the first difference decides, the rest is never looked at. The hand
/// written rank comes first because an emergency exit which the length of some other pattern
/// can overrule is not an exit at all.
/// </remarks>
/// <param name="other">The specificity to compare against.</param>
/// <returns>A negative number when this one is less specific, zero when they are equal.</returns>
public int CompareTo(RuleSpecificity other) =>
(this.ExplicitRank, this.Kind, this.PatternLength, this.Conditions, this.Binding)
.CompareTo((other.ExplicitRank, other.Kind, other.PatternLength, other.Conditions, other.Binding));
private static int WeightOf(MatchKind kind) => kind switch
{
MatchKind.EXACT => 3,
MatchKind.PREFIX => 2,
MatchKind.SEGMENT => 1,
_ => 0,
};
}
@@ -0,0 +1,68 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Meta;
/// <summary>
/// Llama, from the text-only generations to the natively multimodal 4 line.
/// </summary>
/// <remarks>
/// Every rule here is written as a substring, which no other family needs and this one cannot do
/// without. The same checkpoint arrives as "llama3.1", as "meta-llama-3.1", and as "llama-v3p1",
/// because Fireworks writes a version with a "p" where the dot belongs. There is no name part all
/// three share to anchor a rule to, so the three spellings are stated as three rules.
///
/// What decides is the generation: 3.1 was the first Llama trained to call functions, which is why
/// the rules carrying the dot are the ones stating it. "llama3" without a dot is Llama 3.0 and does
/// not get it -- the dot in the pattern is what keeps the two apart.
/// </remarks>
public sealed class LlamaFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.META;
/// <inheritdoc />
public override ModelSource Source => new("https://www.llama.com/docs/model-cards-and-prompt-formats/", new DateOnly(2026, 9, 12), "Capabilities ported unchanged from the Llama block of ProviderExtensions.OpenSource.cs. The model cards give the 3.x generations a 128k window; the 4 line is not stated here, because Scout and Maverick differ by an order of magnitude and the name alone does not say which one it is.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
// Whatever else a Llama is, it reads and writes text:
builder.Rule("llama").AsSubstring()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
//
// The 3.2 vision checkpoints look at pictures and were never trained for tools. The word
// sits wherever the provider puts it -- "llama3.2-vision:11b" on Ollama, but
// "Llama-3.2-11B-Vision-Instruct" on the hub -- so there is nothing to anchor to here
// either, and the generations below have to step aside for it by name.
//
builder.Rule("llama").AsSubstring().AlsoContains("vision")
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
//
// From 3.1 on, Llama calls functions and reads 128k tokens. Three spellings, one statement.
// What an operator actually serves is another matter: Ollama ships with a far smaller window
// until somebody raises num_ctx, which is why the window of a self-hosted model is a ceiling
// rather than a promise.
//
builder.Rule("llama3.").AsSubstring().NotContains("vision")
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.ContextWindow(131_072);
builder.Rule("llama-3.").AsSubstring().NotContains("vision").Inherits();
builder.Rule("llama-v3p").AsSubstring().NotContains("vision").Inherits();
// The 4 line was trained on text and images together, so every one of them sees:
builder.Rule("llama4").AsSubstring()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API);
builder.Rule("llama-4").AsSubstring().Inherits();
builder.Rule("llama-v4").AsSubstring().Inherits();
}
}
@@ -0,0 +1,30 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Meta;
/// <summary>
/// Muse, the Meta models whose names do not say Llama.
/// </summary>
/// <remarks>
/// That is the whole reason this is a family of its own: nothing about "muse-glimmer-30b" tells the
/// Llama rules that Meta built it, and a rule for one name is cheaper than teaching them.
///
/// Glimmer always thinks. Its chat template opens the thinking channel whatever the request says,
/// and only the strength of the thinking can be turned down, so there is no mode in which it
/// answers straight away.
/// </remarks>
public sealed class MuseFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.META;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/meta-llama", new DateOnly(2026, 9, 11), "Ported unchanged from the Muse block of ProviderExtensions.OpenSource.cs.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("muse-glimmer").AsSegment()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.ALWAYS);
}
@@ -0,0 +1,31 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Microsoft;
/// <summary>
/// E5, the embedding models built on somebody else's weights.
/// </summary>
/// <remarks>
/// "e5-mistral-7b-instruct" is what made this a family of its own. It is an embedding model, and it
/// carries the name of the model it was trained from, so the Mistral rules answer for it and tell
/// it that it chats and calls functions. Saying which name means what it says is cheaper than
/// teaching every family whose weights somebody built an embedder from.
///
/// The E5 part is the whole statement: the rest of the name says nothing about what the model does.
/// </remarks>
public sealed class E5Family : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.MICROSOFT;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/intfloat/e5-mistral-7b-instruct", new DateOnly(2026, 9, 11), "The app lists this under IProvider.GetEmbeddingModels, which is where the statement that it embeds comes from.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("e5").AsSegment()
.Capabilities(TEXT_INPUT | EMBEDDING)
.Kind(ModelKind.EMBEDDING);
}
@@ -0,0 +1,62 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Microsoft;
/// <summary>
/// Phi, the small Microsoft models, of which the fourth generation is the one with rules.
/// </summary>
/// <remarks>
/// What a Phi 4 checkpoint can do is written in its name, and two of those words can stand in the
/// same one. "Phi-4-mini-reasoning" is both, and the previous rules had to look for the thinking
/// first so the mini check would not claim it and state the opposite. Here the mini rule says out
/// loud that it does not speak for the thinking checkpoints, which is the same statement without an
/// order behind it.
///
/// Tool calling follows the chat template rather than the size: the mini and multimodal checkpoints
/// carry tool tokens, the 14B model has no tool role at all, and neither do the thinking ones.
/// </remarks>
public sealed class PhiFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.MICROSOFT;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/microsoft", new DateOnly(2026, 9, 11), "Ported unchanged from the Phi block of ProviderExtensions.OpenSource.cs.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
// The 14B model answers in text and has nothing to call a function with:
builder.Rule("phi4").AsSubstring()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
builder.Rule("phi-4").AsSubstring().Inherits();
// The mini checkpoints call functions, and they are not the thinking ones:
builder.Rule("phi4").AsSubstring().AlsoContains("mini").NotContains("reasoning").Inherits()
.Capabilities(FUNCTION_CALLING);
builder.Rule("phi-4").AsSubstring().AlsoContains("mini").NotContains("reasoning").Inherits();
// The multimodal one reads pictures and listens:
builder.Rule("phi4").AsSubstring().AlsoContains("multimodal").Inherits()
.Capabilities(MULTIPLE_IMAGE_INPUT | AUDIO_INPUT);
builder.Rule("phi-4").AsSubstring().AlsoContains("multimodal").Inherits();
// The thinking checkpoints always think, and they call nothing:
builder.Rule("phi4").AsSubstring().AlsoContains("reasoning")
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.ALWAYS);
builder.Rule("phi-4").AsSubstring().AlsoContains("reasoning").Inherits();
// One of them looks at pictures while it does:
builder.Rule("phi4").AsSubstring().AlsoContains("reasoning", "vision").Inherits()
.Capabilities(MULTIPLE_IMAGE_INPUT);
builder.Rule("phi-4").AsSubstring().AlsoContains("reasoning", "vision").Inherits();
}
}
@@ -0,0 +1,31 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.MiniMax;
/// <summary>
/// MiniMax, whose M line thinks while it works.
/// </summary>
/// <remarks>
/// What MiniMax calls interleaved thinking is reasoning between the tool calls: it is part of the
/// answer rather than something the request switches on, so the M models always think. The older
/// Text-01 answers directly.
/// </remarks>
public sealed class MiniMaxFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.MINIMAX;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/MiniMaxAI", new DateOnly(2026, 9, 11), "Ported unchanged from the MiniMax block of ProviderExtensions.OpenSource.cs.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("minimax").AsSubstring()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API);
builder.Rule("minimax-m").AsSubstring().Inherits()
.Reasoning(ReasoningSupport.ALWAYS);
}
}
@@ -0,0 +1,27 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Mistral;
/// <summary>
/// Codestral, the Mistral models for writing code.
/// </summary>
/// <remarks>
/// The previous rules never named it. Its name contains neither "mistral" nor any of the other
/// words the Mistral block looked for, so it walked past every rule and reached the answer meant
/// for everything nobody had written one for. That answer happened to describe it correctly, which
/// is why nothing looked wrong -- and is exactly the situation this rebuild is meant to end.
/// </remarks>
public sealed class CodestralFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.MISTRAL_AI;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.mistral.ai/getting-started/models/models_overview/", new DateOnly(2026, 9, 11), "The answer the previous rules gave it through their fallback: text in, text out, tool calling.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("codestral").AsSegment()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API);
}
@@ -0,0 +1,22 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Mistral;
/// <summary>
/// Magistral, the Mistral models which always think before they answer.
/// </summary>
public sealed class MagistralFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.MISTRAL_AI;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.mistral.ai/getting-started/models/models_overview/", new DateOnly(2026, 9, 11), "Ported unchanged from the rules in ProviderExtensions.OpenSource.cs: images, tool calling, and thinking which cannot be switched off.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("magistral").AsSegment()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.ALWAYS);
}
@@ -0,0 +1,33 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Mistral;
/// <summary>
/// Ministral, the small ones, which see from the third generation on and never reason.
/// </summary>
/// <remarks>
/// The name is one letter away from the rest of the range and shares no name part with it, which
/// the previous rules had to say out loud: the Ministral check sat above the Mistral block because
/// "ministral" does not contain "mistral". Here that is not a question anybody has to ask -- the
/// rules answer for the name part they were written for and for no other.
/// </remarks>
public sealed class MinistralFamily : MistralReleaseDatedFamily
{
/// <inheritdoc />
public override ModelSource Source => new("https://docs.mistral.ai/getting-started/models/models_overview/", new DateOnly(2026, 9, 11), "Ported unchanged from the rules in ProviderExtensions.Mistral.cs: images from Ministral 3 on, and no reasoning in any release.");
/// <inheritdoc />
protected override int VisionSince => 2512;
/// <inheritdoc />
protected override int ReasoningSince => MistralReleases.NEVER;
/// <inheritdoc />
protected override int LatestRelease => 2512;
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("ministral").AsSegment()
.Capabilities(WHAT_THEY_COULD_ALWAYS_DO)
.Apis(CHAT_COMPLETION_API);
}
@@ -0,0 +1,37 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Mistral;
/// <summary>
/// The Mistral models which carry no further family name: Mistral 7B, Mistral 3, and their kin.
/// </summary>
/// <remarks>
/// The open weights are where these live. Mistral's own API sells the named ranges -- Small,
/// Medium, Large -- while the plain checkpoints are the ones people run themselves, which is why
/// nothing here is dated: those names carry a size and a quantization instead of a release.
///
/// A substring, and it has to be one: this is the fallback of the whole range, and every family
/// with a name of its own beats it by saying more. What it must not do is claim Ministral or
/// Magistral, and it does not -- neither of those two names contains "mistral".
/// </remarks>
public sealed class MistralFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.MISTRAL_AI;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.mistral.ai/getting-started/models/weights/", new DateOnly(2026, 9, 11), "Ported unchanged from the Mistral block of ProviderExtensions.OpenSource.cs: its default answer, and the rule for the 3 line.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("mistral").AsSubstring()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API);
// The 3 line reads images and thinks when it is asked to:
builder.Rule("mistral-3").AsSegment().Inherits()
.Capabilities(MULTIPLE_IMAGE_INPUT)
.Reasoning(ReasoningSupport.OPTIONAL);
}
}
@@ -0,0 +1,28 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Mistral;
/// <summary>
/// Mistral Large, which learned to see and to think with the same release.
/// </summary>
public sealed class MistralLargeFamily : MistralReleaseDatedFamily
{
/// <inheritdoc />
public override ModelSource Source => new("https://docs.mistral.ai/models/mistral-large-3-25-12", new DateOnly(2026, 9, 12), "Capabilities ported unchanged from ProviderExtensions.Mistral.cs: images and reasoning from Mistral Large 3 on. The model card states a 256k window.");
/// <inheritdoc />
protected override int VisionSince => 2512;
/// <inheritdoc />
protected override int ReasoningSince => 2512;
/// <inheritdoc />
protected override int LatestRelease => 2512;
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("mistral-large").AsSegment()
.Capabilities(WHAT_THEY_COULD_ALWAYS_DO)
.Apis(CHAT_COMPLETION_API)
.ContextWindow(256_000);
}
@@ -0,0 +1,28 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Mistral;
/// <summary>
/// Mistral Medium, which could see for almost a year before it could think.
/// </summary>
public sealed class MistralMediumFamily : MistralReleaseDatedFamily
{
/// <inheritdoc />
public override ModelSource Source => new("https://docs.mistral.ai/models/model-cards/mistral-medium-3-5-26-04", new DateOnly(2026, 9, 12), "Capabilities ported unchanged from ProviderExtensions.Mistral.cs: images from Mistral Medium 3 on, reasoning from Mistral Medium 3.5 on. The model card states a 256k window.");
/// <inheritdoc />
protected override int VisionSince => 2505;
/// <inheritdoc />
protected override int ReasoningSince => 2604;
/// <inheritdoc />
protected override int LatestRelease => 2604;
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("mistral-medium").AsSegment()
.Capabilities(WHAT_THEY_COULD_ALWAYS_DO)
.Apis(CHAT_COMPLETION_API)
.ContextWindow(256_000);
}
@@ -0,0 +1,25 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Mistral;
/// <summary>
/// Mistral NeMo, the open model Mistral built with NVIDIA.
/// </summary>
/// <remarks>
/// Mistral's own API serves it as "open-mistral-nemo", the hubs as "mistral-nemo". Whole name
/// parts cover both, which is why nothing here cares which of the two arrived.
/// </remarks>
public sealed class MistralNemoFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.MISTRAL_AI;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.mistral.ai/getting-started/models/models_overview/", new DateOnly(2026, 9, 11), "Ported unchanged from the rules in ProviderExtensions.OpenSource.cs: text in, text out, tool calling.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("mistral-nemo").AsSegment()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API);
}
@@ -0,0 +1,59 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Mistral;
/// <summary>
/// A Mistral family whose abilities depend on when the model was released rather than on its name.
/// </summary>
/// <remarks>
/// This is the case the refinement exists for. Four Mistral families gained image input and
/// reasoning at some release and carried the same name before and after, so no pattern can tell
/// the two apart: mistral-large-2411 and mistral-large-2512 differ in what they can do and in
/// nothing a rule could match on.
///
/// So the rule states what the family has always been able to do, and each family says from which
/// release on it gained the rest. Everything shared sits here; a family below is three numbers and
/// one rule.
/// </remarks>
public abstract class MistralReleaseDatedFamily : ModelFamily
{
/// <summary>
/// What every one of these families could do from its very first release.
/// </summary>
protected const Capability WHAT_THEY_COULD_ALWAYS_DO = Capability.TEXT_INPUT | Capability.TEXT_OUTPUT | Capability.FUNCTION_CALLING;
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.MISTRAL_AI;
/// <summary>
/// The release from which this family accepts images.
/// </summary>
protected abstract int VisionSince { get; }
/// <summary>
/// The release from which this family can reason, or never.
/// </summary>
protected abstract int ReasoningSince { get; }
/// <summary>
/// The release this family's "latest" alias currently points at.
/// </summary>
/// <remarks>
/// Mistral moves the alias on with every release, so it has to behave like the release it
/// resolves to instead of carrying rules of its own.
/// </remarks>
protected abstract int LatestRelease { get; }
/// <inheritdoc />
public override ModelProfile Refine(in ModelId id, in ModelProfile selected)
{
var release = MistralReleases.Of(id, this.LatestRelease);
return selected with
{
Capabilities = release >= this.VisionSince ? selected.Capabilities | Capability.MULTIPLE_IMAGE_INPUT : selected.Capabilities,
Reasoning = release >= this.ReasoningSince ? ReasoningSupport.OPTIONAL : selected.Reasoning,
};
}
}
@@ -0,0 +1,139 @@
using AIStudio.Models.Matching;
namespace AIStudio.Models.Mistral;
/// <summary>
/// Reads the release a Mistral model belongs to out of its name.
/// </summary>
/// <remarks>
/// Mistral names its models after the month they came out: mistral-large-2512 is Mistral Large 3
/// from December 2025. The marketing version lives in the marketing name only, so a rule written
/// against it would miss nearly every model the API actually serves. What a family can state is
/// therefore not "this model reads images" but "this family reads images from this release on",
/// and that is a calculation, not a pattern -- which is what the families do in Refine.
/// </remarks>
public static class MistralReleases
{
/// <summary>
/// A threshold no release can ever reach, for a family which never gained the ability at all.
/// </summary>
public const int NEVER = int.MaxValue;
/// <summary>
/// What a name says when it carries no release at all.
/// </summary>
/// <remarks>
/// Nothing is granted for it. That is the safe direction: offering an ability the model does
/// not have makes the request fail, while a missing one can be handed back by a person through
/// the expert settings.
/// </remarks>
public const int UNKNOWN = 0;
/// <summary>
/// How many digits a release is written with.
/// </summary>
private const int RELEASE_LENGTH = 4;
/// <summary>
/// Mistral released its first date-named model in 2023.
/// </summary>
/// <remarks>
/// Anything below that is not a release date but a parameter count or a context size which
/// happens to have four digits.
/// </remarks>
private const int FIRST_RELEASE_YEAR = 23;
/// <summary>
/// The releases the marketing versions stand for.
/// </summary>
/// <remarks>
/// Mistral serves some models under their marketing version as well, and writes the version
/// separator both ways: mistral-medium-3.5 and mistral-medium-3-5 are the same model. Those
/// names carry no release date, so they are mapped onto the release they stand for. Ollama
/// leaves the separator out altogether for the Small checkpoints, which is a third spelling of
/// the same statement.
///
/// The order matters, and it is the one place in this rebuild where it still does: these are
/// read as plain text rather than as patterns, so "mistral-medium-3" would answer for
/// "mistral-medium-3.5" if it came first.
/// </remarks>
private static readonly (string VersionName, int Release)[] VERSION_NAMES =
[
("mistral-large-3", 2512),
("mistral-medium-3.5", 2604),
("mistral-medium-3-5", 2604),
("mistral-medium-3.1", 2508),
("mistral-medium-3-1", 2508),
("mistral-medium-3", 2505),
("mistral-small-4", 2603),
("mistral-small-3.2", 2506),
("mistral-small-3-2", 2506),
("mistral-small-3.1", 2503),
("mistral-small-3-1", 2503),
("mistral-small-3", 2501),
("mistral-small4", 2603),
("mistral-small3.2", 2506),
("mistral-small3.1", 2503),
("mistral-small3", 2501),
];
/// <summary>
/// The release a model name belongs to.
/// </summary>
/// <param name="id">The model name.</param>
/// <param name="latestRelease">The release this family's "latest" alias currently points at.</param>
/// <returns>The release as YYMM, or unknown.</returns>
public static int Of(in ModelId id, int latestRelease)
{
// The "latest" alias always points at the newest release of its family:
if (id.ContainsSegments("latest"))
return latestRelease;
foreach (var (versionName, release) in VERSION_NAMES)
if (id.ContainsText(versionName))
return release;
return ReadFrom(id.Normalized.AsSpan());
}
/// <summary>
/// Reads the four-digit release out of a name.
/// </summary>
/// <remarks>
/// The block has to be exactly four digits long and has to read as a plausible year and month.
/// Without that, the size of a model would be mistaken for its release: ministral-14b-2512 has
/// to resolve to 2512 and not to anything the "14b" part could be read as.
/// </remarks>
/// <param name="modelName">The normalized model name.</param>
/// <returns>The release as YYMM, or unknown.</returns>
private static int ReadFrom(ReadOnlySpan<char> modelName)
{
for (var index = 0; index + RELEASE_LENGTH <= modelName.Length; index++)
{
// A digit next to the block means the block is longer than four digits:
if (index > 0 && char.IsAsciiDigit(modelName[index - 1]))
continue;
if (index + RELEASE_LENGTH < modelName.Length && char.IsAsciiDigit(modelName[index + RELEASE_LENGTH]))
continue;
var candidate = modelName.Slice(index, RELEASE_LENGTH);
if (!char.IsAsciiDigit(candidate[0]) || !char.IsAsciiDigit(candidate[1]) ||
!char.IsAsciiDigit(candidate[2]) || !char.IsAsciiDigit(candidate[3]))
continue;
var release = int.Parse(candidate);
var year = release / 100;
var month = release % 100;
if (year < FIRST_RELEASE_YEAR || month is < 1 or > 12)
continue;
return release;
}
return UNKNOWN;
}
}
@@ -0,0 +1,26 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Mistral;
/// <summary>
/// Mistral Saba, the regional model for the Middle East and South Asia.
/// </summary>
/// <remarks>
/// The one Mistral in this range which calls no tools at all. It needs its own rule for that
/// reason alone: without it, the length of "mistral-small" and "mistral-large" would not matter,
/// but the shape they share would be handed to a model which does not have it.
/// </remarks>
public sealed class MistralSabaFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.MISTRAL_AI;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.mistral.ai/getting-started/models/models_overview/", new DateOnly(2026, 9, 11), "Ported unchanged from the rules in ProviderExtensions.Mistral.cs: text in, text out, and nothing else.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("mistral-saba").AsSegment()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
}
@@ -0,0 +1,33 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Mistral;
/// <summary>
/// Mistral Small, which gained images with 3.1 and reasoning with 4.
/// </summary>
/// <remarks>
/// The one family of the range whose name arrives glued to its version: Ollama publishes the open
/// weights as "mistral-small3.1" and "mistral-small3.2", without the separator Mistral's own API
/// writes. A substring covers both spellings, and it stays specific enough that nothing else in the
/// range can be mistaken for it.
/// </remarks>
public sealed class MistralSmallFamily : MistralReleaseDatedFamily
{
/// <inheritdoc />
public override ModelSource Source => new("https://docs.mistral.ai/getting-started/models/models_overview/", new DateOnly(2026, 9, 11), "Ported unchanged from the rules in ProviderExtensions.Mistral.cs: images from Mistral Small 3.1 on, reasoning from Mistral Small 4 on.");
/// <inheritdoc />
protected override int VisionSince => 2503;
/// <inheritdoc />
protected override int ReasoningSince => 2603;
/// <inheritdoc />
protected override int LatestRelease => 2603;
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("mistral-small").AsSubstring()
.Capabilities(WHAT_THEY_COULD_ALWAYS_DO)
.Apis(CHAT_COMPLETION_API);
}
@@ -0,0 +1,25 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Mistral;
/// <summary>
/// Pixtral, the Mistral models built to look at pictures.
/// </summary>
/// <remarks>
/// They read images from the first release, so nothing here depends on a date.
/// </remarks>
public sealed class PixtralFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.MISTRAL_AI;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.mistral.ai/getting-started/models/models_overview/", new DateOnly(2026, 9, 12), "Capabilities ported unchanged from ProviderExtensions.Mistral.cs: images in every release. Mistral states a 128k window for Pixtral.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("pixtral").AsSegment()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.ContextWindow(128_000);
}
@@ -0,0 +1,34 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Mistral;
/// <summary>
/// Voxtral, the Mistral models which listen.
/// </summary>
/// <remarks>
/// They take speech as input and answer in text, which makes them neither a chat model nor a
/// transcription model but something in between: they understand what was said rather than only
/// writing it down.
///
/// The app has to pick one of the two all the same, and the provider decides it: asking Mistral for
/// a chat completion with voxtral-mini-latest is answered with "Invalid model". So they are
/// transcription models, which is what keeps them out of the chat list, while the capabilities above
/// still say what they understand.
/// </remarks>
public sealed class VoxtralFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.MISTRAL_AI;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.mistral.ai/getting-started/models/models_overview/", new DateOnly(2026, 9, 12), "Capabilities ported unchanged from ProviderExtensions.OpenSource.cs: speech in, text out, tool calling. That they count as transcription models comes from Provider/ModelKindExtensions.cs.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("voxtral").AsSegment()
.Capabilities(TEXT_INPUT | SPEECH_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.Kind(ModelKind.TRANSCRIPTION);
}
@@ -0,0 +1,82 @@
using AIStudio.Models.Matching;
namespace AIStudio.Models;
/// <summary>
/// Everything the app knows about one family of models, in one place.
/// </summary>
/// <remarks>
/// A family is a class, and adding one is all it takes: the source generator finds it at compile
/// time and the registry asks it for its rules. There is no list to remember to add it to, which is
/// what the old code got wrong in the other direction -- there, a new family meant editing a file
/// which had already grown past a thousand lines, and putting the block in the wrong place changed
/// the answer for models nobody was thinking about.
///
/// The source is an abstract member, so the compiler asks for it. That is deliberate: a rule
/// without a page behind it is a guess, and a guess which nobody can check ages into a defect.
/// </remarks>
public abstract class ModelFamily
{
private IReadOnlyList<ModelRule>? declaredRules;
/// <summary>
/// Who builds the models of this family.
/// </summary>
public abstract ModelVendor Vendor { get; }
/// <summary>
/// Where the statements below were read, and when.
/// </summary>
public abstract ModelSource Source { get; }
/// <summary>
/// The other pages this family was read from, where one was not enough.
/// </summary>
/// <remarks>
/// A vendor keeps what a model can do, how much it reads and how many images it takes on three
/// different pages often enough. The source above stays the one to start from; these are the
/// rest, and the same is asked of them -- a page and a day, so that every number in the family
/// leads back to something somebody can open.
/// </remarks>
public virtual IReadOnlyList<ModelSource> FurtherSources => [];
/// <summary>
/// What this family is called, which is what its rules name as their origin.
/// </summary>
public string Name => this.GetType().Name;
/// <summary>
/// The rules this family states, worked out once.
/// </summary>
public IReadOnlyList<ModelRule> Rules => this.declaredRules ??= this.BuildRules();
/// <summary>
/// Adjusts a profile in a way no pattern can express.
/// </summary>
/// <remarks>
/// The way out for the handful of families whose capabilities are computed from the name rather
/// than looked up: Mistral encodes a release date as four digits and gains abilities from a
/// certain date onwards, and Z AI marks its vision models by putting a "v" behind the version
/// number. Writing one rule per possible date is not a rule set, it is a table of everything.
///
/// Everything which can be said with a pattern belongs in a pattern, where the specificity can
/// see it. This runs afterwards, on the family whose rule won.
/// </remarks>
/// <param name="id">The model name.</param>
/// <param name="selected">What the rules made of it.</param>
/// <returns>The profile, adjusted.</returns>
public virtual ModelProfile Refine(in ModelId id, in ModelProfile selected) => selected;
/// <summary>
/// States the rules of this family.
/// </summary>
/// <param name="builder">What to state them with.</param>
protected abstract void Declare(ModelFamilyBuilder builder);
private IReadOnlyList<ModelRule> BuildRules()
{
var builder = new ModelFamilyBuilder(this.Name);
this.Declare(builder);
return builder.Build();
}
}
@@ -0,0 +1,73 @@
using AIStudio.Models.Matching;
namespace AIStudio.Models;
/// <summary>
/// Collects the rules of one family as they are stated.
/// </summary>
/// <remarks>
/// The order rules are stated in changes nothing about which one wins -- that is what the computed
/// specificity is for. It matters in one place only: a variant which inherits takes what the rule
/// before it stated, so that a family can say what its models have in common once and then say
/// only what makes each variant different.
/// </remarks>
/// <param name="origin">What the rules name as their origin, which is the family's name.</param>
public sealed class ModelFamilyBuilder(string origin)
{
private readonly List<ModelRuleBuilder> stated = [];
/// <summary>
/// States a rule which chooses the model.
/// </summary>
/// <param name="text">The name, or the part of it, this rule answers for. In normalized form.</param>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder Rule(string text) => this.Add(text, ModelRuleKind.SELECTOR);
/// <summary>
/// States a rule which adjusts whatever chose the model.
/// </summary>
/// <param name="text">The name, or the part of it, this rule answers for. In normalized form.</param>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder Modifier(string text) => this.Add(text, ModelRuleKind.MODIFIER);
/// <summary>
/// Turns everything stated into rules.
/// </summary>
/// <returns>The rules, in the order they were stated.</returns>
internal IReadOnlyList<ModelRule> Build()
{
//
// The same text may well be stated twice, with different conditions on top -- that is how
// a variant of a generation is written. What cannot be done is naming that text to inherit
// from, because it names two rules and taking either of them would be a coin toss. Found
// before anything is built, so that where the two stand in the file makes no difference.
//
var statedMoreThanOnce = this.stated
.GroupBy(statement => statement.PatternText, StringComparer.Ordinal)
.Where(group => group.Count() > 1)
.Select(group => group.Key)
.ToHashSet(StringComparer.Ordinal);
var built = new List<ModelRule>(this.stated.Count);
var byPatternText = new Dictionary<string, ModelProfileChange>(StringComparer.Ordinal);
ModelProfileChange? previous = null;
foreach (var statement in this.stated)
{
var rule = statement.Build(statement.InheritanceBasis(byPatternText, statedMoreThanOnce, previous));
built.Add(rule);
byPatternText[rule.Pattern.Text] = rule.Change;
previous = rule.Change;
}
return built;
}
private ModelRuleBuilder Add(string text, ModelRuleKind kind)
{
var statement = new ModelRuleBuilder(text, kind, origin);
this.stated.Add(statement);
return statement;
}
}
@@ -0,0 +1,113 @@
using AIStudio.Provider;
namespace AIStudio.Models;
/// <summary>
/// Everything the app knows about one model.
/// </summary>
/// <remarks>
/// This is the answer the registry gives, and it is a struct on purpose. The question is asked from
/// inside components which re-render on every streamed chunk, so an answer which allocates a list
/// each time is an answer asked too often. Testing a capability is one bit test here, and because
/// the value cannot be changed after it was built, the same answer can be handed to every caller.
///
/// The reasoning question is answered by the Reasoning field alone. The three reasoning members of
/// the capability enum are override vocabulary and are never part of Capabilities, so that the
/// contradictory combinations of them cannot be expressed in a result at all.
/// </remarks>
public readonly record struct ModelProfile
{
/// <summary>
/// The three capability members which say something about reasoning.
/// </summary>
/// <remarks>
/// They are the vocabulary a person writes an override in, not something a profile carries.
/// Kept here as one value so that the rule engine, the tests, and the verification run all mean
/// the same three members by it.
/// </remarks>
public const Capability REASONING_VOCABULARY = Capability.OPTIONAL_REASONING | Capability.ALWAYS_REASONING | Capability.REASONING_BY_DEFAULT;
/// <summary>
/// What we know about a model nobody has written a rule for.
/// </summary>
/// <remarks>
/// Nothing, which is what the default value of this type says already. Note that this still
/// reports the model as a chat model: that is the deliberate fallback of ModelKind, because a
/// model we fail to recognize has to stay visible to the user rather than disappear.
/// </remarks>
public static readonly ModelProfile UNKNOWN = new();
/// <summary>
/// What the app assumes about a model when no rule says anything about it.
/// </summary>
/// <remarks>
/// Hugging Face alone carries more than a hundred thousand models, so falling through here is
/// the normal case rather than a gap somebody forgot to close. The assumption describes what an
/// instruction-tuned model of the last few years does: it reads and writes text, it speaks the
/// chat completion API, and it calls functions.
///
/// Tool calling is the part that was weighed rather than observed. Counted over the corpus, 17
/// of the models which reach this answer would be described wrongly without it and 8 with it --
/// and those 8 are named, in WithoutToolCallingFamily. A model that is offered tools it cannot
/// use fails visibly, and the person turns tool calling off in the expert settings; a model
/// that is never offered any fails invisibly, because nothing ever asks it. On top of that, a
/// model released from here on is far more likely to call functions than not.
///
/// This is the whole assumption. Everything else stays unknown on purpose: a context window
/// nobody stated is not 4096 tokens, and a model whose name says nothing about images does not
/// get image input for free -- that is what the expert settings and the model plugins are for.
/// </remarks>
public static readonly ModelProfile ASSUMED = new()
{
Capabilities = Capability.TEXT_INPUT | Capability.TEXT_OUTPUT | Capability.CHAT_COMPLETION_API | Capability.FUNCTION_CALLING,
};
/// <summary>
/// What the model can do.
/// </summary>
public Capability Capabilities { get; init; }
/// <summary>
/// How the model reasons.
/// </summary>
public ReasoningSupport Reasoning { get; init; }
/// <summary>
/// What the model is made for.
/// </summary>
public ModelKind Kind { get; init; }
/// <summary>
/// How much the model can read and write in one conversation.
/// </summary>
public ContextWindow Context { get; init; }
/// <summary>
/// Which tokenizer counts this model's tokens.
/// </summary>
public TokenizerRef Tokenizer { get; init; }
/// <summary>
/// How many images the model accepts.
/// </summary>
public ImageLimits Images { get; init; }
/// <summary>
/// Whether the model has every one of the given capabilities.
/// </summary>
/// <remarks>
/// Asking for no capability at all is a mistake rather than a question with a trivial answer,
/// which is why it says no: without that, a variable which happens to hold NONE would report
/// every model as able to do it.
/// </remarks>
/// <param name="capability">One capability, or several combined with the or operator.</param>
/// <returns>True, when the model has all of them.</returns>
public bool Has(Capability capability) => capability is not Capability.NONE && (this.Capabilities & capability) == capability;
/// <summary>
/// Whether the model has at least one of the given capabilities.
/// </summary>
/// <param name="capabilities">Several capabilities combined with the or operator.</param>
/// <returns>True, when the model has any of them.</returns>
public bool HasAny(Capability capabilities) => (this.Capabilities & capabilities) is not Capability.NONE;
}
@@ -0,0 +1,84 @@
using AIStudio.Provider;
namespace AIStudio.Models;
/// <summary>
/// What a rule states about a model, as a change to what is known so far.
/// </summary>
/// <remarks>
/// A selector applies its change to nothing and so states a whole profile; a modifier applies its
/// change to whatever the selector decided. One type for both, because "adds web search" and "takes
/// web search away again" are the same kind of sentence.
///
/// Everything left unsaid stays as it was. That is what lets a rule for a variant say only what
/// makes the variant different, instead of repeating the family it belongs to.
/// </remarks>
public sealed record ModelProfileChange
{
/// <summary>
/// A change which states nothing.
/// </summary>
public static readonly ModelProfileChange NOTHING = new();
/// <summary>
/// Capabilities the model has.
/// </summary>
public Capability Adds { get; init; }
/// <summary>
/// Capabilities the model does not have, applied after the ones it has.
/// </summary>
public Capability Removes { get; init; }
/// <summary>
/// How the model reasons, or null to leave that as it was.
/// </summary>
public ReasoningSupport? Reasoning { get; init; }
/// <summary>
/// What the model is made for, or null to leave that as it was.
/// </summary>
public ModelKind? Kind { get; init; }
/// <summary>
/// The context window, or null to leave it as it was.
/// </summary>
public ContextWindow? Context { get; init; }
/// <summary>
/// The tokenizer reference, or null to leave it as it was.
/// </summary>
public TokenizerRef? Tokenizer { get; init; }
/// <summary>
/// The image limits, or null to leave them as they were.
/// </summary>
public ImageLimits? Images { get; init; }
/// <summary>
/// Applies this change to a profile.
/// </summary>
/// <remarks>
/// The three reasoning members of the capability enum are dropped here rather than trusted to
/// stay out: they are the vocabulary a person writes an override in, and a profile which
/// carried them could say that a model both always reasons and reasons on request. A rule which
/// declares one has still made a mistake, which is why the tests and the verification run look
/// for it instead of relying on this line to hide it.
///
/// Every member of a profile is named below, so the copy could be written as a new profile
/// instead. It stays a copy on purpose: the day a profile learns something this change does not
/// know about yet, a modifier has to hand that on rather than reset it to nothing.
/// </remarks>
/// <param name="profile">What is known so far.</param>
/// <returns>What is known afterward.</returns>
// ReSharper disable once WithExpressionModifiesAllMembers
public ModelProfile ApplyTo(in ModelProfile profile) => profile with
{
Capabilities = (profile.Capabilities | this.Adds) & ~this.Removes & ~ModelProfile.REASONING_VOCABULARY,
Reasoning = this.Reasoning ?? profile.Reasoning,
Kind = this.Kind ?? profile.Kind,
Context = this.Context ?? profile.Context,
Tokenizer = this.Tokenizer ?? profile.Tokenizer,
Images = this.Images ?? profile.Images,
};
}
@@ -0,0 +1,352 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models;
/// <summary>
/// One rule, while it is being stated.
/// </summary>
/// <remarks>
/// Everything left unsaid stays unsaid: a rule which says nothing about the context window does not
/// claim that nobody knows it, it simply makes no statement, and whatever else does gets to keep
/// its answer. That is what lets a variant state one sentence instead of repeating its family.
/// </remarks>
/// <param name="patternText">The name, or the part of it, this rule answers for. In normalized form.</param>
/// <param name="ruleKind">Whether the rule chooses the model or adjusts the choice.</param>
/// <param name="origin">What the rule names as its origin, which is the family's name.</param>
public sealed class ModelRuleBuilder(string patternText, ModelRuleKind ruleKind, string origin)
{
private readonly List<string> alsoContains = [];
private readonly List<string> notContains = [];
private MatchKind matchKind = MatchKind.SEGMENT;
private LLMProviders? onlyOn;
private ModelVendor? onlyFrom;
private int explicitRank;
private bool inheritsFromPrevious;
private string? inheritsFromText;
private Capability adds;
private Capability removes;
private ReasoningSupport? reasoning;
private ModelKind? modelKind;
private ContextWindow? context;
private TokenizerRef? tokenizer;
private ImageLimits? images;
/// <summary>
/// The text this rule answers for, before anything was stated about it.
/// </summary>
/// <remarks>
/// Read by the family builder before it builds anything, to find the texts which name more
/// than one rule.
/// </remarks>
internal string PatternText => patternText;
/// <summary>
/// The text is the whole model name.
/// </summary>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder AsExact() => this.MatchingAs(MatchKind.EXACT);
/// <summary>
/// The name begins with the text, and a name part ends there.
/// </summary>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder AsPrefix() => this.MatchingAs(MatchKind.PREFIX);
/// <summary>
/// The text appears in the name as one or more whole name parts. This is the default.
/// </summary>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder AsSegment() => this.MatchingAs(MatchKind.SEGMENT);
/// <summary>
/// The text appears anywhere in the name, boundaries or not.
/// </summary>
/// <remarks>
/// The last resort, for the names where a vendor glues things together. It claims the least and
/// therefore loses against every other kind.
/// </remarks>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder AsSubstring() => this.MatchingAs(MatchKind.SUBSTRING);
/// <summary>
/// Further name parts the model's name has to carry.
/// </summary>
/// <param name="nameParts">The name parts, each in normalized form.</param>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder AlsoContains(params string[] nameParts)
{
this.alsoContains.AddRange(nameParts);
return this;
}
/// <summary>
/// Name parts whose presence rules this rule out.
/// </summary>
/// <param name="nameParts">The name parts, each in normalized form.</param>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder NotContains(params string[] nameParts)
{
this.notContains.AddRange(nameParts);
return this;
}
/// <summary>
/// Restricts this rule to one provider.
/// </summary>
/// <param name="provider">The provider serving the model.</param>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder OnlyOn(LLMProviders provider)
{
this.onlyOn = provider;
return this;
}
/// <summary>
/// Restricts this rule to models of one vendor.
/// </summary>
/// <param name="vendor">Who built the model.</param>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder OnlyFrom(ModelVendor vendor)
{
this.onlyFrom = vendor;
return this;
}
/// <summary>
/// What the model can do.
/// </summary>
/// <param name="capabilities">The capabilities, combined with the or operator.</param>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder Capabilities(Capability capabilities)
{
this.adds |= capabilities;
return this;
}
/// <summary>
/// Which APIs the model answers through.
/// </summary>
/// <remarks>
/// The same thing as stating a capability, said separately because it reads as a different kind
/// of sentence: what a model is able to do, and how one talks to it.
/// </remarks>
/// <param name="apis">The API capabilities, combined with the or operator.</param>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder Apis(Capability apis)
{
this.adds |= apis;
return this;
}
/// <summary>
/// What the model cannot do, applied after everything it can.
/// </summary>
/// <param name="capabilities">The capabilities, combined with the or operator.</param>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder Removes(Capability capabilities)
{
this.removes |= capabilities;
return this;
}
/// <summary>
/// How the model reasons.
/// </summary>
/// <param name="support">The way it reasons.</param>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder Reasoning(ReasoningSupport support)
{
this.reasoning = support;
return this;
}
/// <summary>
/// What the model is made for, when it is not a chat model.
/// </summary>
/// <param name="kind">The kind of model.</param>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder Kind(ModelKind kind)
{
this.modelKind = kind;
return this;
}
/// <summary>
/// How much the model reads and writes in one conversation.
/// </summary>
/// <param name="defaultTokens">What it does as it ships.</param>
/// <param name="raisableTo">What an operator can raise it to, where that is documented.</param>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder ContextWindow(int defaultTokens, int? raisableTo = null)
{
this.context = Models.ContextWindow.Of(defaultTokens, raisableTo);
return this;
}
/// <summary>
/// Takes back a context window this rule inherited, because nobody states one for this variant.
/// </summary>
/// <remarks>
/// A variant can already hand back a capability its family granted; a number has to be handed
/// back too. Without this, a generation whose window nobody documents would quietly carry the
/// number of the generation it inherits from -- and the app would then show a person that
/// number as a fact about their model.
/// </remarks>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder WithoutContextWindow()
{
this.context = Models.ContextWindow.UNKNOWN;
return this;
}
/// <summary>
/// Which tokenizer counts this model's tokens.
/// </summary>
/// <param name="kind">What sort of tokenizer it is.</param>
/// <param name="id">Its name, in whatever spelling that sort uses.</param>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder Tokenizer(TokenizerKind kind, string id)
{
this.tokenizer = new TokenizerRef(kind, id);
return this;
}
/// <summary>
/// How many images the model accepts.
/// </summary>
/// <param name="maxPerMessage">How many fit into one message, where that is documented.</param>
/// <param name="maxPerRequest">How many fit into one request, where that is documented.</param>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder Images(int? maxPerMessage = null, int? maxPerRequest = null)
{
this.images = new ImageLimits(maxPerMessage, maxPerRequest);
return this;
}
/// <summary>
/// Takes everything the rule stated before this one and goes on from there.
/// </summary>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder Inherits()
{
this.inheritsFromPrevious = true;
return this;
}
/// <summary>
/// Takes everything one particular rule of this family stated and goes on from there.
/// </summary>
/// <remarks>
/// Worth preferring over the plain form in a family with more than one generation: naming the
/// rule survives somebody reordering the file, while "the one before" does not.
/// </remarks>
/// <param name="inheritedPatternText">The text of the rule to inherit from.</param>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder InheritsFrom(string inheritedPatternText)
{
this.inheritsFromText = inheritedPatternText;
return this;
}
/// <summary>
/// Moves this rule ahead of, or behind, everything the computed specificity would decide.
/// </summary>
/// <remarks>
/// The emergency exit, and it is meant to stay unused.
/// </remarks>
/// <param name="rank">Positive to move the rule ahead, negative to push it back.</param>
/// <param name="reason">
/// What the computation gets wrong here. It is not kept: it stands in the source so that the
/// next reader finds an explanation next to the rank instead of a number nobody can account for.
/// </param>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder Rank(int rank, string reason)
{
//
// Asking for a reason is what the second parameter does; insisting that it says something
// is what keeps an empty string from passing for one. Without this, the way to write a rank
// nobody can account for is still open, and it is the one thing the computed specificity
// exists to get rid of.
//
if (string.IsNullOrWhiteSpace(reason))
throw new ArgumentException($"The rule \"{patternText}\" of {origin} sets the rank {rank} without saying what the computed specificity gets wrong here.", nameof(reason));
this.explicitRank = rank;
return this;
}
/// <summary>
/// What this rule goes on from, if it goes on from anything.
/// </summary>
/// <param name="byPatternText">What the rules stated so far, by their pattern text.</param>
/// <param name="statedMoreThanOnce">The texts which name more than one rule of this family.</param>
/// <param name="previous">What the rule stated right before this one, if there was one.</param>
/// <returns>The statement to start from, or null when the rule states everything itself.</returns>
internal ModelProfileChange? InheritanceBasis(IReadOnlyDictionary<string, ModelProfileChange> byPatternText, IReadOnlySet<string> statedMoreThanOnce, ModelProfileChange? previous)
{
if (this.inheritsFromText is not null)
{
//
// A text stated twice names two rules, and taking whichever happened to come last
// would be a coin toss nobody sees. The way out is the plain form, which says "the one
// before" and means exactly one rule.
//
if (statedMoreThanOnce.Contains(this.inheritsFromText))
throw new InvalidOperationException($"The rule \"{patternText}\" of {origin} inherits from \"{this.inheritsFromText}\", which this family states more than once. Use Inherits() right after the rule to go on from, or give the rule a text of its own.");
return byPatternText.TryGetValue(this.inheritsFromText, out var named)
? named
: throw new InvalidOperationException($"The rule \"{patternText}\" of {origin} inherits from \"{this.inheritsFromText}\", which this family does not state before it.");
}
if (!this.inheritsFromPrevious)
return null;
return previous ?? throw new InvalidOperationException($"The rule \"{patternText}\" of {origin} inherits, but it is the first rule this family states.");
}
/// <summary>
/// Turns the statement into a rule.
/// </summary>
/// <param name="basis">What to go on from, or null to state everything from nothing.</param>
/// <returns>The rule.</returns>
internal ModelRule Build(ModelProfileChange? basis)
{
var pattern = new MatchPattern
{
Kind = this.matchKind,
Text = patternText,
AlsoContains = this.alsoContains.ToArray(),
NotContains = this.notContains.ToArray(),
OnlyOn = this.onlyOn,
OnlyFrom = this.onlyFrom,
ExplicitRank = this.explicitRank,
};
return new(pattern, ruleKind, this.ChangeOnTopOf(basis), origin);
}
private ModelProfileChange ChangeOnTopOf(ModelProfileChange? basis) => new()
{
//
// What this rule states wins over what it inherited, in both directions: a variant may take
// away what its family has, and it may hand back what its family took away.
//
Adds = ((basis?.Adds ?? Capability.NONE) | this.adds) & ~this.removes,
Removes = ((basis?.Removes ?? Capability.NONE) | this.removes) & ~this.adds,
Reasoning = this.reasoning ?? basis?.Reasoning,
Kind = this.modelKind ?? basis?.Kind,
Context = this.context ?? basis?.Context,
Tokenizer = this.tokenizer ?? basis?.Tokenizer,
Images = this.images ?? basis?.Images,
};
private ModelRuleBuilder MatchingAs(MatchKind kind)
{
this.matchKind = kind;
return this;
}
}
@@ -0,0 +1,28 @@
namespace AIStudio.Models;
/// <summary>
/// Where the statements about a model were read, and when somebody last looked.
/// </summary>
/// <remarks>
/// Model cards change without telling anybody. A vendor adds tool calling to a checkpoint, raises a
/// context window, or quietly stops offering an API, and the rule written from the old page keeps
/// answering as if nothing happened. Naming the page and the day it was read is what turns "this is
/// what the rules say" into something a person can check in a minute.
///
/// This is not optional: a family has to state it, and the compiler asks for it. The verification
/// run reports the ones which have gone stale.
/// </remarks>
/// <param name="Url">The page the statements were read from.</param>
/// <param name="CheckedOn">The day somebody last read it.</param>
/// <param name="Note">What that page actually said, in a sentence, so a reader knows what to look for.</param>
public sealed record ModelSource(string Url, DateOnly CheckedOn, string Note)
{
/// <summary>
/// Whether this source names a page and a day.
/// </summary>
/// <remarks>
/// The compiler can insist that a family states a source; it cannot insist that the source says
/// anything. This is what the verification run asks.
/// </remarks>
public bool IsStated => !string.IsNullOrWhiteSpace(this.Url) && this.CheckedOn != default;
}
@@ -0,0 +1,53 @@
namespace AIStudio.Models;
/// <summary>
/// Who built a model, as opposed to who serves it.
/// </summary>
/// <remarks>
/// The two are different questions, and mixing them up is what made the old rules delegate between
/// vendors until they called each other in circles. A provider is where a request goes; a vendor is
/// whose model answers it. Llama comes from Meta whether it arrives through Groq, Fireworks, or a
/// local Ollama.
///
/// A rule may bind itself to a vendor, which matters where the same name means two different models
/// depending on who made it. It is also what a gateway declares when it unwraps a name such as
/// "anthropic/claude-sonnet-4-0".
///
/// This list grows with the families being ported. Only vendors whose models the app already has
/// rules for are named here; adding a member is part of adding the family, not a step of its own.
/// </remarks>
public enum ModelVendor
{
/// <summary>
/// We do not know who built this model. This is the answer for everything not recognized.
/// </summary>
UNKNOWN,
OPEN_AI,
ANTHROPIC,
GOOGLE,
MISTRAL_AI,
ALIBABA,
DEEP_SEEK,
PERPLEXITY,
XAI,
META,
MICROSOFT,
NVIDIA,
IBM,
COHERE,
MOONSHOT_AI,
TENCENT,
Z_AI,
MINIMAX,
AI2,
BYTE_DANCE,
TII,
INCLUSION_AI,
BAIDU,
HUGGING_FACE,
SERVICE_NOW,
SHANGHAI_AI_LAB,
SWISS_AI,
NOMIC_AI,
}
@@ -0,0 +1,53 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.MoonshotAI;
/// <summary>
/// Kimi, and the older Moonshot line next to it.
/// </summary>
/// <remarks>
/// Moonshot builds these for agentic work, and the K2 model card says it plainly: pass the tools
/// with the request and the model decides on its own when to call them. So the family states tool
/// calling, and the exception has to say otherwise -- which is the vision checkpoint, the one Kimi
/// no vendor lists among the models which call functions.
///
/// The variants are written for the Kimi names only, because that is where Moonshot puts them. The
/// "moonshot" names are the older API line, which answers straight away and has no variants.
/// </remarks>
public sealed class KimiFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.MOONSHOT_AI;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/moonshotai", new DateOnly(2026, 9, 11), "Ported unchanged from the Moonshot block of ProviderExtensions.OpenSource.cs.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("kimi").AsSubstring()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API);
builder.Rule("moonshot").AsSubstring().Inherits();
// The thinking variants say what they are in their name:
builder.Rule("kimi").AsSubstring().AlsoContains("thinking").Inherits()
.Reasoning(ReasoningSupport.ALWAYS);
// The vision checkpoint thinks as well, and it is the one which calls nothing:
builder.Rule("kimi-vl").AsSubstring()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.ALWAYS);
builder.Rule("kimi-k2.7-code").AsSubstring()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.ALWAYS);
// The K3 line watches videos on top:
builder.Rule("kimi-k3").AsSubstring().Inherits()
.Capabilities(VIDEO_INPUT);
}
}
@@ -0,0 +1,41 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.NVIDIA;
/// <summary>
/// Nemotron, which NVIDIA builds for agentic work and mostly out of somebody else's weights.
/// </summary>
/// <remarks>
/// That last part is what the rules have to get right. Llama-3.3-Nemotron-Super carries two family
/// names, and the previous rules answered it as a Llama for no better reason than that the Llama
/// block stood higher up in the file. What NVIDIA changed about those weights is exactly the part
/// the answer is about: the thinking switch and the tool template. Here the name part wins over the
/// substring, so the model is answered by the family which made it what it is.
///
/// Every generation is text only. The point releases carry a line of their own because a dot
/// separates versions rather than name parts, so "nemotron-3" does not answer for "nemotron-3.5".
/// </remarks>
public sealed class NemotronFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.NVIDIA;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/nvidia", new DateOnly(2026, 9, 11), "Ported unchanged from the Nemotron block of ProviderExtensions.OpenSource.cs.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
// The earlier generations have to be asked to think:
builder.Rule("nemotron").AsSegment()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.OPTIONAL);
// The third one thinks unless the request says otherwise, through enable_thinking=False:
builder.Rule("nemotron-3").AsSegment().Inherits()
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
builder.Rule("nemotron-3.5").AsSegment().Inherits();
}
}
@@ -0,0 +1,29 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Nomic;
/// <summary>
/// The Nomic embedding models, which everybody runs locally and nobody chats with.
/// </summary>
/// <remarks>
/// One of the most widely served models there is: it is what a local setup reaches for when it
/// needs vectors. The previous rules had no idea it existed, so it fell into the assumption that an
/// unknown model chats and calls functions -- three statements about a model which does none of
/// them, and the one thing it does was not said at all.
/// </remarks>
public sealed class NomicEmbedFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.NOMIC_AI;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/nomic-ai", new DateOnly(2026, 9, 11), "The app lists these under IProvider.GetEmbeddingModels, which is where the statement that they embed comes from.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("nomic-embed").AsSubstring()
.Capabilities(TEXT_INPUT | EMBEDDING)
.Kind(ModelKind.EMBEDDING);
}
@@ -0,0 +1,41 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.OpenAI;
/// <summary>
/// GPT-3.5, which answers with text and does nothing else.
/// </summary>
public sealed class Gpt35Family : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.OPEN_AI;
/// <inheritdoc />
public override ModelSource Source => new("https://platform.openai.com/docs/models", new DateOnly(2026, 9, 11), "Ported unchanged from the rules in ProviderExtensions.OpenAI.cs: text in, text out, no tools and no images.");
/// <inheritdoc />
public override IReadOnlyList<ModelSource> FurtherSources =>
[
new("https://github.com/openai/tiktoken/blob/main/tiktoken/model.py", new DateOnly(2026, 9, 12), "OpenAI's own mapping from model names to encodings. It maps \"gpt-3.5\", \"gpt-3.5-turbo\" and the prefix \"gpt-3.5-turbo-\" to cl100k_base.")
];
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("gpt-3.5").AsPrefix()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API)
.Tokenizer(TokenizerKind.TIKTOKEN, "cl100k_base");
//
// The odd one out, and kept odd on purpose: the previous rules put this one model on the
// Responses API and every other GPT-3.5 on the chat completion API. It reads like an
// oversight, but what the app answers today is what the snapshot pins, and correcting it is
// a decision of its own rather than something to slip into a port.
//
builder.Rule("gpt-3.5-turbo").AsExact()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
.Apis(RESPONSES_API)
.Tokenizer(TokenizerKind.TIKTOKEN, "cl100k_base");
}
}
@@ -0,0 +1,40 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.OpenAI;
/// <summary>
/// GPT-4 and GPT-4 Turbo.
/// </summary>
/// <remarks>
/// GPT-4o is not one of these, which the name hides and the matching does not: a rule bound to the
/// start of a name only answers where a name part ends, and in "gpt-4o" the part goes on. The
/// previous rules had to say that twice, once as an exact comparison and once as a prefix.
/// </remarks>
public sealed class Gpt4Family : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.OPEN_AI;
/// <inheritdoc />
public override ModelSource Source => new("https://developers.openai.com/api/docs/models/gpt-4-turbo", new DateOnly(2026, 9, 12), "Capabilities ported unchanged from ProviderExtensions.OpenAI.cs: GPT-4 is text only, Turbo adds images and tool calling. The windows are the documented 8,192 tokens of GPT-4 and the 128,000 Turbo raised it to.");
/// <inheritdoc />
public override IReadOnlyList<ModelSource> FurtherSources =>
[
new("https://github.com/openai/tiktoken/blob/main/tiktoken/model.py", new DateOnly(2026, 9, 12), "OpenAI's own mapping from model names to encodings. It maps \"gpt-4\" and the prefix \"gpt-4-\" to cl100k_base, so Turbo uses it too -- the newer o200k_base begins with the 4o line, which is a family of its own here.")
];
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("gpt-4").AsPrefix()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
.Apis(RESPONSES_API)
.ContextWindow(8_192)
.Tokenizer(TokenizerKind.TIKTOKEN, "cl100k_base");
builder.Rule("gpt-4-turbo").AsPrefix().Inherits()
.Capabilities(MULTIPLE_IMAGE_INPUT | FUNCTION_CALLING)
.ContextWindow(128_000);
}
}
@@ -0,0 +1,56 @@
using static AIStudio.Provider.Capability;
// ReSharper disable InconsistentNaming
namespace AIStudio.Models.OpenAI;
/// <summary>
/// GPT-4o, including its mini and its audio preview.
/// </summary>
/// <remarks>
/// The previous rules never named this family. Its models reached the last line of the OpenAI
/// function, the one that answers for everything nobody wrote a rule for, and that line happened
/// to describe GPT-4o exactly. Writing it down changes no answer and takes the family out of the
/// fallback, where a wrong answer looks like no answer.
/// </remarks>
public sealed class Gpt4oFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.OPEN_AI;
/// <inheritdoc />
public override ModelSource Source => new("https://developers.openai.com/api/docs/models/gpt-4o", new DateOnly(2026, 9, 12), "The answer the previous rules gave these models through their fallback: images, tool calling, and web search on the Responses API. The model page states a 128,000 token window, which the minis and the search previews share.");
/// <inheritdoc />
public override IReadOnlyList<ModelSource> FurtherSources =>
[
new("https://github.com/openai/tiktoken/blob/main/tiktoken/model.py", new DateOnly(2026, 9, 12), "OpenAI's own mapping from model names to encodings. It maps the prefix \"gpt-4o-\" to o200k_base, which covers the minis and the search previews as well.")
];
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("gpt-4o").AsPrefix()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | FUNCTION_CALLING | WEB_SEARCH)
.Apis(RESPONSES_API)
.ContextWindow(128_000)
.Tokenizer(TokenizerKind.TIKTOKEN, "o200k_base");
//
// The search previews are the same generation and almost nothing like it: they search the
// web and do nothing else, no images and no tools, and they answer only through the chat
// completion API. Stated in full rather than inherited, because there is barely anything of
// the family left in them.
//
builder.Rule("gpt-4o-search-preview").AsExact()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | WEB_SEARCH)
.Apis(CHAT_COMPLETION_API)
.ContextWindow(128_000)
.Tokenizer(TokenizerKind.TIKTOKEN, "o200k_base");
builder.Rule("gpt-4o-mini-search-preview").AsExact()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | WEB_SEARCH)
.Apis(CHAT_COMPLETION_API)
.ContextWindow(128_000)
.Tokenizer(TokenizerKind.TIKTOKEN, "o200k_base");
}
}
@@ -0,0 +1,80 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.OpenAI;
/// <summary>
/// The whole GPT-5 line, from GPT-5 to GPT-5.6.
/// </summary>
/// <remarks>
/// One family rather than six, because the generations differ in one sentence each and stating that
/// sentence is the entire content: GPT-5 reasons always and answers only through the Responses API,
/// GPT-5.1 reasons on request and answers through both, GPT-5.5 reasons unless told not to.
///
/// The dot is what keeps the generations apart. A rule bound to the start of a name ends at a name
/// part, and a dot does not end one, so "gpt-5" does not answer for "gpt-5.1" -- which is exactly
/// what the previous rules spelled out one comparison at a time.
///
/// None of these models writes images itself. They can ask for one through the image generation
/// tool, which is a tool call producing a picture from a separate model, and reporting that as an
/// output modality would have the chat offer to receive images which never arrive.
/// </remarks>
public sealed class Gpt5Family : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.OPEN_AI;
/// <inheritdoc />
public override ModelSource Source => new("https://developers.openai.com/api/docs/models", new DateOnly(2026, 9, 12), "Capabilities ported unchanged from ProviderExtensions.OpenAI.cs, one rule per generation, except that the chat alias no longer inherits the reasoning it is named for not having. Context windows read per generation from the model pages below that URL.");
/// <inheritdoc />
public override IReadOnlyList<ModelSource> FurtherSources =>
[
new("https://github.com/openai/tiktoken/blob/main/tiktoken/model.py", new DateOnly(2026, 9, 12), "OpenAI's own mapping from model names to encodings. It maps the prefix \"gpt-5\" to o200k_base, which covers every model of this line.")
];
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
//
// The window grows once in this line, between 5.3 and 5.4: everything up to 5.2 is
// documented at 400,000 tokens and everything from 5.4 on at 1,050,000. Both numbers are
// the whole window, input and output together, which is how OpenAI states them.
//
builder.Rule("gpt-5").AsPrefix()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | FUNCTION_CALLING | WEB_SEARCH)
.Apis(RESPONSES_API)
.Reasoning(ReasoningSupport.ALWAYS)
.ContextWindow(400_000)
.Tokenizer(TokenizerKind.TIKTOKEN, "o200k_base");
//
// The alias for the model of this generation which does not reason. The previous rules had
// it swallowed by the prefix above and told it that it always reasons, which is the one
// thing its name rules out. Here the longer pattern simply wins.
//
builder.Rule("gpt-5-chat").AsPrefix().Inherits()
.Reasoning(ReasoningSupport.NONE);
builder.Rule("gpt-5.1").AsPrefix().InheritsFrom("gpt-5")
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.OPTIONAL);
builder.Rule("gpt-5.2").AsPrefix().InheritsFrom("gpt-5.1");
//
// The one generation OpenAI documents nothing about: there is no model page for it, so the
// rule exists to keep a 5.3 answering like the rest of the line if one ever appears. What it
// must not do is carry 5.1's window as if somebody had looked it up.
//
builder.Rule("gpt-5.3").AsPrefix().InheritsFrom("gpt-5.1")
.WithoutContextWindow();
builder.Rule("gpt-5.4").AsPrefix().InheritsFrom("gpt-5.1")
.ContextWindow(1_050_000);
builder.Rule("gpt-5.5").AsPrefix().InheritsFrom("gpt-5.4")
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
builder.Rule("gpt-5.6").AsPrefix().InheritsFrom("gpt-5.5");
}
}
@@ -0,0 +1,27 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.OpenAI;
/// <summary>
/// GPT-6 Astra.
/// </summary>
/// <remarks>
/// Unlike the 5.5 and 5.6 models it reasons on every request: the effort reaches from low to max,
/// and there is no setting which switches thinking off.
/// </remarks>
public sealed class Gpt6AstraFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.OPEN_AI;
/// <inheritdoc />
public override ModelSource Source => new("https://developers.openai.com/api/docs/models", new DateOnly(2026, 9, 12), "Capabilities ported unchanged from ProviderExtensions.OpenAI.cs: reasons on every request, both APIs. The models page states the window as 1.05M tokens.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("gpt-6-astra").AsPrefix()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | FUNCTION_CALLING | WEB_SEARCH)
.Apis(RESPONSES_API | CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.ALWAYS)
.ContextWindow(1_050_000);
}
Loaded 100 of 287 files, more files were not shown because too many files have changed in this diff. Show more