mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-04 16:09:40 +00:00
Add the model matching engine and make capabilities a flags enum
This commit is contained in:
27 files changed
+2198
-48
No files matched your search
@@ -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,38 @@
|
||||
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;
|
||||
}
|
||||
@@ -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,179 @@
|
||||
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 originalId = modelId ?? string.Empty;
|
||||
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 => this.originalId ?? 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,60 @@
|
||||
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,
|
||||
MatchKind.SUBSTRING => 0,
|
||||
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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 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,79 @@
|
||||
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.
|
||||
/// </remarks>
|
||||
/// <param name="profile">What is known so far.</param>
|
||||
/// <returns>What is known afterwards.</returns>
|
||||
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,52 @@
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace AIStudio.Models;
|
||||
|
||||
/// <summary>
|
||||
/// States how a model reasons.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the resolved answer to a question the capability flags could only ask three times at
|
||||
/// once. A model reasons in exactly one of these ways, so one value says it, and the combinations
|
||||
/// which contradict each other cannot be written down any more.
|
||||
///
|
||||
/// The user interface has always thought in these terms: the expert dialog offers "no reasoning",
|
||||
/// "can be enabled", "on by default", and "always on", and used to recompute them from three flags
|
||||
/// on every render.
|
||||
/// </remarks>
|
||||
public enum ReasoningSupport
|
||||
{
|
||||
/// <summary>
|
||||
/// The model does not reason. This is the answer for everything we have no statement about.
|
||||
/// </summary>
|
||||
NONE,
|
||||
|
||||
/// <summary>
|
||||
/// The model can reason, but only when the request asks it to.
|
||||
/// </summary>
|
||||
OPTIONAL,
|
||||
|
||||
/// <summary>
|
||||
/// The model reasons unless the request turns it off.
|
||||
/// </summary>
|
||||
ON_BY_DEFAULT,
|
||||
|
||||
/// <summary>
|
||||
/// The model always reasons. There is no way to turn it off.
|
||||
/// </summary>
|
||||
ALWAYS,
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace AIStudio.Models;
|
||||
|
||||
/// <summary>
|
||||
/// What sort of tokenizer a model uses, and therefore how its name would have to be resolved.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A bare name would be a lie. The runtime loads Hugging Face tokenizer.json files, OpenAI names
|
||||
/// tiktoken encodings such as o200k_base, and Anthropic and Google publish no tokenizer at all but
|
||||
/// offer an API which counts for you. Without the kind next to the name, somebody would eventually
|
||||
/// try to fetch "o200k_base" from a model hub.
|
||||
/// </remarks>
|
||||
public enum TokenizerKind
|
||||
{
|
||||
/// <summary>
|
||||
/// We have no statement about this model's tokenizer, so the built-in default one is used.
|
||||
/// </summary>
|
||||
UNKNOWN,
|
||||
|
||||
/// <summary>
|
||||
/// A repository on the Hugging Face hub which ships a tokenizer.json.
|
||||
/// </summary>
|
||||
HUGGING_FACE,
|
||||
|
||||
/// <summary>
|
||||
/// A tiktoken encoding, named the way OpenAI names it.
|
||||
/// </summary>
|
||||
TIKTOKEN,
|
||||
|
||||
/// <summary>
|
||||
/// The vendor counts tokens through an API of its own instead of publishing a tokenizer.
|
||||
/// </summary>
|
||||
PROVIDER_API,
|
||||
|
||||
/// <summary>
|
||||
/// The model has no tokenizer to speak of, such as an image or audio model.
|
||||
/// </summary>
|
||||
NONE,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace AIStudio.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Points at the tokenizer a model uses, without fetching it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only the reference is recorded here. Obtaining a tokenizer is a feature of its own, and today
|
||||
/// only the Hugging Face kind could be resolved at all; the other kinds document what would have to
|
||||
/// happen. Unknown means the built-in default tokenizer, which is what every model uses today.
|
||||
/// </remarks>
|
||||
/// <param name="Kind">What sort of tokenizer this is, which decides how the name would be resolved.</param>
|
||||
/// <param name="Id">The name, in whatever spelling the kind uses. Meaningless unless the reference is known.</param>
|
||||
public readonly record struct TokenizerRef(TokenizerKind Kind, string Id)
|
||||
{
|
||||
/// <summary>
|
||||
/// The tokenizer of a model we have no statement about: the built-in default one.
|
||||
/// </summary>
|
||||
public static readonly TokenizerRef UNKNOWN = new(TokenizerKind.UNKNOWN, string.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// Whether this reference names something. Read the ID only when it does.
|
||||
/// </summary>
|
||||
public bool IsKnown => this.Kind is not TokenizerKind.UNKNOWN && !string.IsNullOrWhiteSpace(this.Id);
|
||||
}
|
||||
@@ -3,115 +3,145 @@ namespace AIStudio.Provider;
|
||||
/// <summary>
|
||||
/// Represents the capabilities of an AI model.
|
||||
/// </summary>
|
||||
public enum Capability
|
||||
/// <remarks>
|
||||
/// A set of capabilities is one value, not a collection: a model profile carries this enum as a
|
||||
/// single field, and asking whether a capability is present is one bit test instead of a walk
|
||||
/// through a list. That is why the members are powers of two.
|
||||
///
|
||||
/// The numeric values are an implementation detail and are never written anywhere. Overrides,
|
||||
/// plugins, and the settings file all address a capability by its name, so the names are the part
|
||||
/// which must not change. Removing a member would silently drop the override an organization wrote
|
||||
/// for it, which is why the members we no longer hand out ourselves are still here.
|
||||
///
|
||||
/// Adding a member means adding the next free bit. Sixty-four of them fit; should they ever run
|
||||
/// out, the answer is a second enum next to this one rather than a wider underlying type, because
|
||||
/// widening changes the meaning of every value already written down.
|
||||
/// </remarks>
|
||||
[Flags]
|
||||
public enum Capability : ulong
|
||||
{
|
||||
/// <summary>
|
||||
/// No capabilities specified.
|
||||
/// </summary>
|
||||
NONE,
|
||||
|
||||
NONE = 0,
|
||||
|
||||
/// <summary>
|
||||
/// We don't know what the AI model can do.
|
||||
/// </summary>
|
||||
UNKNOWN,
|
||||
|
||||
UNKNOWN = 1UL << 0,
|
||||
|
||||
/// <summary>
|
||||
/// The AI model can perform text input.
|
||||
/// </summary>
|
||||
TEXT_INPUT,
|
||||
|
||||
TEXT_INPUT = 1UL << 1,
|
||||
|
||||
/// <summary>
|
||||
/// The AI model can perform audio input, such as music or sound.
|
||||
/// </summary>
|
||||
AUDIO_INPUT,
|
||||
|
||||
AUDIO_INPUT = 1UL << 2,
|
||||
|
||||
/// <summary>
|
||||
/// The AI model can perform one image input, such as one photo or drawing.
|
||||
/// </summary>
|
||||
SINGLE_IMAGE_INPUT,
|
||||
|
||||
SINGLE_IMAGE_INPUT = 1UL << 3,
|
||||
|
||||
/// <summary>
|
||||
/// The AI model can perform multiple images as input, such as multiple photos or drawings.
|
||||
/// </summary>
|
||||
MULTIPLE_IMAGE_INPUT,
|
||||
|
||||
MULTIPLE_IMAGE_INPUT = 1UL << 4,
|
||||
|
||||
/// <summary>
|
||||
/// The AI model can perform speech input.
|
||||
/// </summary>
|
||||
SPEECH_INPUT,
|
||||
|
||||
SPEECH_INPUT = 1UL << 5,
|
||||
|
||||
/// <summary>
|
||||
/// The AI model can perform video input, such as video files or streams.
|
||||
/// </summary>
|
||||
VIDEO_INPUT,
|
||||
|
||||
VIDEO_INPUT = 1UL << 6,
|
||||
|
||||
/// <summary>
|
||||
/// The AI model can generate text output.
|
||||
/// </summary>
|
||||
TEXT_OUTPUT,
|
||||
|
||||
TEXT_OUTPUT = 1UL << 7,
|
||||
|
||||
/// <summary>
|
||||
/// The AI model can generate audio output, such as music or sound.
|
||||
/// </summary>
|
||||
AUDIO_OUTPUT,
|
||||
|
||||
AUDIO_OUTPUT = 1UL << 8,
|
||||
|
||||
/// <summary>
|
||||
/// The AI model can generate image output, such as photos or drawings.
|
||||
/// </summary>
|
||||
IMAGE_OUTPUT,
|
||||
|
||||
IMAGE_OUTPUT = 1UL << 9,
|
||||
|
||||
/// <summary>
|
||||
/// The AI model can generate speech output.
|
||||
/// </summary>
|
||||
SPEECH_OUTPUT,
|
||||
|
||||
SPEECH_OUTPUT = 1UL << 10,
|
||||
|
||||
/// <summary>
|
||||
/// The AI model can generate video output.
|
||||
/// </summary>
|
||||
VIDEO_OUTPUT,
|
||||
|
||||
VIDEO_OUTPUT = 1UL << 11,
|
||||
|
||||
/// <summary>
|
||||
/// The AI model can perform reasoning tasks. You can enable reasoning optionally, but it is disabled by default.
|
||||
/// </summary>
|
||||
OPTIONAL_REASONING,
|
||||
|
||||
/// <remarks>
|
||||
/// Override vocabulary. A model profile states how a model reasons through its ReasoningSupport
|
||||
/// field and never sets this flag, because the three reasoning flags can be combined into
|
||||
/// answers no model can give. Asking a profile whether it has this capability always says no.
|
||||
/// </remarks>
|
||||
OPTIONAL_REASONING = 1UL << 12,
|
||||
|
||||
/// <summary>
|
||||
/// The AI model always performs reasoning. There is no option to disable reasoning.
|
||||
/// </summary>
|
||||
ALWAYS_REASONING,
|
||||
/// <remarks>
|
||||
/// Override vocabulary. A model profile states how a model reasons through its ReasoningSupport
|
||||
/// field and never sets this flag, because the three reasoning flags can be combined into
|
||||
/// answers no model can give. Asking a profile whether it has this capability always says no.
|
||||
/// </remarks>
|
||||
ALWAYS_REASONING = 1UL << 13,
|
||||
|
||||
/// <summary>
|
||||
/// The AI model performs optional reasoning, but it is enabled by default.
|
||||
/// </summary>
|
||||
REASONING_BY_DEFAULT,
|
||||
/// <remarks>
|
||||
/// Override vocabulary. A model profile states how a model reasons through its ReasoningSupport
|
||||
/// field and never sets this flag, because the three reasoning flags can be combined into
|
||||
/// answers no model can give. Asking a profile whether it has this capability always says no.
|
||||
/// </remarks>
|
||||
REASONING_BY_DEFAULT = 1UL << 14,
|
||||
|
||||
/// <summary>
|
||||
/// The AI model can embed information or data.
|
||||
/// </summary>
|
||||
EMBEDDING,
|
||||
|
||||
EMBEDDING = 1UL << 15,
|
||||
|
||||
/// <summary>
|
||||
/// The AI model can perform in real-time.
|
||||
/// </summary>
|
||||
REALTIME,
|
||||
|
||||
REALTIME = 1UL << 16,
|
||||
|
||||
/// <summary>
|
||||
/// The AI model can perform function calling, such as invoking APIs or executing functions.
|
||||
/// </summary>
|
||||
FUNCTION_CALLING,
|
||||
|
||||
FUNCTION_CALLING = 1UL << 17,
|
||||
|
||||
/// <summary>
|
||||
/// The AI model can perform web search to retrieve information from the internet.
|
||||
/// </summary>
|
||||
WEB_SEARCH,
|
||||
|
||||
WEB_SEARCH = 1UL << 18,
|
||||
|
||||
/// <summary>
|
||||
/// The AI model is used via the Chat Completion API.
|
||||
/// </summary>
|
||||
CHAT_COMPLETION_API,
|
||||
|
||||
CHAT_COMPLETION_API = 1UL << 19,
|
||||
|
||||
/// <summary>
|
||||
/// The AI model is used via the Responses API.
|
||||
/// </summary>
|
||||
RESPONSES_API,
|
||||
RESPONSES_API = 1UL << 20,
|
||||
}
|
||||
Reference in new issue
Block a user