mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-06 17:29:40 +00:00
Add the model family API, its compile-time registry, and MWAIS0013
This commit is contained in:
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;
|
||||
}
|
||||
Reference in new issue
Block a user