Add the model family API, its compile-time registry, and MWAIS0013

This commit is contained in:
Thorsten Sommer committed 2026-09-11 18:28:24 +02:00
1 parent 3302915d84
commit d3b28134d9
15 files changed
+1554

No files matched your search

@@ -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,71 @@
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>
/// 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,61 @@
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()
{
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, 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,308 @@
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 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>
/// 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="patternText">The text of the rule to inherit from.</param>
/// <returns>The rule, to go on stating.</returns>
public ModelRuleBuilder InheritsFrom(string patternText)
{
this.inheritsFromText = patternText;
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)
{
_ = 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="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, ModelProfileChange? previous)
{
if (this.inheritsFromText is not null)
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;
}