Let organizations describe their own models

This commit is contained in:
Thorsten Sommer committed 2026-09-12 15:29:55 +02:00
1 parent f4078856d1
commit 6d80e6de8a
18 files changed
+1266 -48

No files matched your search

@@ -0,0 +1,404 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using AIStudio.Models.Matching;
using AIStudio.Provider;
using AIStudio.Tools.PluginSystem;
using Lua;
namespace AIStudio.Models.Plugins;
/// <summary>
/// What an organization states about a set of model names, read from one of its model plugins.
/// </summary>
/// <remarks>
/// A declaration says exactly what a family in the source says, and it is measured by the same
/// engine: a pattern, what the models matching it can do, and where that was read. What it must not
/// be is half a statement. A declaration replaces what the built-in rules would have answered, so
/// one which named a context window and nothing else would take away every capability the rules
/// knew -- which is why stating the capabilities is not optional here.
///
/// Whoever only wants to correct one number for their own installation has the better tool already:
/// the expert settings of the configured provider, which a configuration plugin writes as well.
/// A model plugin is for the models the built-in rules do not know, or know wrongly.
/// </remarks>
public sealed record ModelDeclaration : ILivePluginContent
{
/// <summary>
/// Which model names this declaration answers for.
/// </summary>
public required MatchPattern Pattern { get; init; }
/// <summary>
/// What it states about them.
/// </summary>
public required ModelProfileChange Change { get; init; }
/// <summary>
/// Where that was read, and when somebody last looked.
/// </summary>
public required ModelSource Source { get; init; }
/// <summary>
/// What the rule built from this declaration names as its origin, so a conflict can name both sides.
/// </summary>
public required string Origin { get; init; }
/// <inheritdoc />
public Guid EnterpriseConfigurationPluginId { get; init; }
/// <summary>
/// What identifies this declaration when two plugins collide.
/// </summary>
/// <remarks>
/// The pattern itself, because that is what a collision is here: two declarations claiming
/// exactly the same names. They would otherwise both enter the index and tie there, and a tie
/// is something only a person can settle. Two declarations about different names never meet.
/// </remarks>
public string Id => this.Pattern.Signature();
/// <summary>
/// Turns the declaration into a rule of the matching engine.
/// </summary>
/// <remarks>
/// Always a selector, never a modifier: a plugin states what a model is, not how to adjust
/// somebody else's answer about it. And never with an explicit rank -- a declaration already
/// comes before the built-in rules, so within the plugins the computed specificity decides,
/// exactly as it does in the source.
/// </remarks>
/// <returns>The rule.</returns>
public ModelRule ToRule() => new(this.Pattern, ModelRuleKind.SELECTOR, this.Change, this.Origin);
/// <summary>
/// Reads one entry of a model plugin's MODELS table.
/// </summary>
/// <remarks>
/// Anything it cannot read is rejected as a whole rather than read in part. A declaration is
/// one statement, and half of one would answer for the models it matches just as firmly as a
/// complete one -- with whatever the unreadable half was supposed to say silently missing.
/// </remarks>
/// <param name="index">Which entry of the table this is, so a warning can name it.</param>
/// <param name="table">The entry.</param>
/// <param name="pluginId">The plugin which declared it.</param>
/// <param name="origin">What the resulting rule names as its origin.</param>
/// <param name="logger">Where to report what could not be read.</param>
/// <param name="declaration">The declaration, when the entry could be read.</param>
/// <returns>True, when the entry could be read.</returns>
public static bool TryParse(int index, LuaTable table, Guid pluginId, string origin, ILogger logger, [NotNullWhen(true)] out ModelDeclaration? declaration)
{
declaration = null;
if (!TryReadText(table, "PATTERN", out var patternText))
{
logger.LogWarning("The model declaration {DeclarationIndex} does not name a PATTERN. Every declaration has to say which model names it answers for. (model plugin id: {PluginId})", index, pluginId);
return false;
}
if (!MatchPattern.IsNormalized(patternText))
{
logger.LogWarning("The model declaration {DeclarationIndex} names the PATTERN '{Pattern}', which is not written the way a model name is written and can therefore never match anything. Write it as '{NormalizedPattern}'. (model plugin id: {PluginId})", index, patternText, new ModelId(patternText).Normalized, pluginId);
return false;
}
if (!TryReadEnum<MatchKind>(table, "MATCH", index, pluginId, logger, out var matchKind, MatchKind.SEGMENT))
return false;
if (!TryReadNameParts(table, "ALSO_CONTAINS", index, pluginId, logger, out var alsoContains))
return false;
if (!TryReadNameParts(table, "NOT_CONTAINS", index, pluginId, logger, out var notContains))
return false;
if (!TryReadOptionalEnum<LLMProviders>(table, "ONLY_ON", index, pluginId, logger, out var onlyOn))
return false;
if (!TryReadOptionalEnum<ModelVendor>(table, "ONLY_FROM", index, pluginId, logger, out var onlyFrom))
return false;
if (!TryReadCapabilities(table, index, pluginId, logger, out var capabilities))
return false;
if (!TryReadEnum<ReasoningSupport>(table, "REASONING", index, pluginId, logger, out var reasoning, ReasoningSupport.NONE))
return false;
if (!TryReadEnum<ModelKind>(table, "KIND", index, pluginId, logger, out var modelKind, ModelKind.CHAT))
return false;
if (!TryReadContextWindow(table, index, pluginId, logger, out var context))
return false;
if (!TryReadTokenizer(table, index, pluginId, logger, out var tokenizer))
return false;
if (!TryReadImageLimits(table, index, pluginId, logger, out var images))
return false;
if (!TryReadSource(table, index, pluginId, logger, out var source))
return false;
declaration = new()
{
Pattern = new()
{
Kind = matchKind,
Text = patternText,
AlsoContains = alsoContains,
NotContains = notContains,
OnlyOn = onlyOn,
OnlyFrom = onlyFrom,
},
Change = new()
{
Adds = capabilities,
Reasoning = reasoning,
Kind = modelKind,
Context = context,
Tokenizer = tokenizer,
Images = images,
},
Source = source,
Origin = origin,
EnterpriseConfigurationPluginId = pluginId,
};
return true;
}
private static bool TryReadText(LuaTable table, string key, out string text)
{
text = string.Empty;
if (!table.TryGetValue(key, out var value) || !value.TryRead<string>(out var read))
return false;
text = read;
return !string.IsNullOrWhiteSpace(text);
}
/// <summary>
/// Reads a key which names one member of an enum, falling back to a default when it is absent.
/// </summary>
/// <remarks>
/// A member is named, never combined and never numbered. Enum.TryParse accepts both of those,
/// so the check that the value is actually a member of the enum is what rejects them -- writing
/// two kinds into one key, or a number nobody can read back, would otherwise pass.
/// </remarks>
private static bool TryReadEnum<T>(LuaTable table, string key, int index, Guid pluginId, ILogger logger, out T parsed, T fallback) where T : struct, Enum
{
parsed = fallback;
if (!table.TryGetValue(key, out var value))
return true;
if (value.TryRead<string>(out var text) && Enum.TryParse(text, true, out parsed) && Enum.IsDefined(parsed))
return true;
logger.LogWarning("The model declaration {DeclarationIndex} states an unknown {Key}. Valid values are: {ValidValues}. (model plugin id: {PluginId})", index, key, string.Join(", ", Enum.GetNames<T>()), pluginId);
return false;
}
private static bool TryReadOptionalEnum<T>(LuaTable table, string key, int index, Guid pluginId, ILogger logger, out T? parsed) where T : struct, Enum
{
parsed = null;
if (!table.TryGetValue(key, out var value))
return true;
if (value.TryRead<string>(out var text) && Enum.TryParse<T>(text, true, out var read) && Enum.IsDefined(read))
{
parsed = read;
return true;
}
logger.LogWarning("The model declaration {DeclarationIndex} states an unknown {Key}. Valid values are: {ValidValues}. (model plugin id: {PluginId})", index, key, string.Join(", ", Enum.GetNames<T>()), pluginId);
return false;
}
private static bool TryReadNameParts(LuaTable table, string key, int index, Guid pluginId, ILogger logger, out string[] nameParts)
{
nameParts = [];
if (!table.TryGetValue(key, out var value))
return true;
if (!value.TryRead<LuaTable>(out var partsTable))
{
logger.LogWarning("The model declaration {DeclarationIndex} states {Key}, but not as a list of name parts. (model plugin id: {PluginId})", index, key, pluginId);
return false;
}
var read = new string[partsTable.ArrayLength];
for (var i = 1; i <= partsTable.ArrayLength; i++)
{
if (!partsTable[i].TryRead<string>(out var namePart) || !MatchPattern.IsNormalized(namePart))
{
logger.LogWarning("The model declaration {DeclarationIndex} states a {Key} entry which is not a name part written the way a model name is written. (model plugin id: {PluginId})", index, key, pluginId);
return false;
}
read[i - 1] = namePart;
}
nameParts = read;
return true;
}
/// <summary>
/// Reads the capabilities, which every declaration has to state.
/// </summary>
/// <remarks>
/// The three reasoning words are rejected rather than dropped. They are the vocabulary of the
/// expert settings, where a person answers three questions with yes and no; here one key says
/// how a model reasons, and the three of them together can state answers no model can give.
/// </remarks>
private static bool TryReadCapabilities(LuaTable table, int index, Guid pluginId, ILogger logger, out Capability capabilities)
{
capabilities = Capability.NONE;
if (!table.TryGetValue("CAPABILITIES", out var value) || !value.TryRead<LuaTable>(out var capabilitiesTable) || capabilitiesTable.ArrayLength is 0)
{
logger.LogWarning("The model declaration {DeclarationIndex} does not state its CAPABILITIES. A declaration replaces what AI Studio would otherwise know about these models, so it has to say what they can do. (model plugin id: {PluginId})", index, pluginId);
return false;
}
for (var i = 1; i <= capabilitiesTable.ArrayLength; i++)
{
if (!capabilitiesTable[i].TryRead<string>(out var capabilityText) || !Enum.TryParse<Capability>(capabilityText, true, out var capability) || !Enum.IsDefined(capability) || capability is Capability.NONE or Capability.UNKNOWN)
{
logger.LogWarning("The model declaration {DeclarationIndex} states an unknown capability. Name one capability per entry, e.g. TEXT_INPUT. (model plugin id: {PluginId})", index, pluginId);
return false;
}
if ((capability & ModelProfile.REASONING_VOCABULARY) is not Capability.NONE)
{
logger.LogWarning("The model declaration {DeclarationIndex} states the capability {Capability}, which says how a model reasons. Use the REASONING key instead, which takes exactly one of: {ValidValues}. (model plugin id: {PluginId})", index, capability, string.Join(", ", Enum.GetNames<ReasoningSupport>()), pluginId);
return false;
}
capabilities |= capability;
}
return true;
}
private static bool TryReadContextWindow(LuaTable table, int index, Guid pluginId, ILogger logger, out ContextWindow? context)
{
context = null;
var raisableIsStated = table.TryGetValue("CONTEXT_WINDOW_RAISABLE_TO", out var raisableValue);
if (!table.TryGetValue("CONTEXT_WINDOW", out var value))
{
if (!raisableIsStated)
return true;
logger.LogWarning("The model declaration {DeclarationIndex} states CONTEXT_WINDOW_RAISABLE_TO without stating the CONTEXT_WINDOW it can be raised from. (model plugin id: {PluginId})", index, pluginId);
return false;
}
if (!value.TryRead<int>(out var defaultTokens) || defaultTokens <= 0)
{
logger.LogWarning("The model declaration {DeclarationIndex} states a CONTEXT_WINDOW which is not a number of tokens greater than zero. (model plugin id: {PluginId})", index, pluginId);
return false;
}
int? raisableTo = null;
if (raisableIsStated)
{
if (!raisableValue.TryRead<int>(out var raisable) || raisable < defaultTokens)
{
logger.LogWarning("The model declaration {DeclarationIndex} states a CONTEXT_WINDOW_RAISABLE_TO which is not a number of tokens of at least the CONTEXT_WINDOW itself. (model plugin id: {PluginId})", index, pluginId);
return false;
}
raisableTo = raisable;
}
context = ContextWindow.Of(defaultTokens, raisableTo);
return true;
}
private static bool TryReadTokenizer(LuaTable table, int index, Guid pluginId, ILogger logger, out TokenizerRef? tokenizer)
{
tokenizer = null;
var kindIsStated = table.TryGetValue("TOKENIZER_KIND", out _);
var idIsStated = TryReadText(table, "TOKENIZER_ID", out var tokenizerId);
if (!kindIsStated && !idIsStated)
return true;
if (!kindIsStated || !idIsStated)
{
logger.LogWarning("The model declaration {DeclarationIndex} states only one half of its tokenizer. A tokenizer reference needs both TOKENIZER_KIND and TOKENIZER_ID, because the kind is what says how the ID would be resolved. (model plugin id: {PluginId})", index, pluginId);
return false;
}
if (!TryReadEnum<TokenizerKind>(table, "TOKENIZER_KIND", index, pluginId, logger, out var tokenizerKind, TokenizerKind.UNKNOWN))
return false;
tokenizer = new(tokenizerKind, tokenizerId);
return true;
}
private static bool TryReadImageLimits(LuaTable table, int index, Guid pluginId, ILogger logger, out ImageLimits? images)
{
images = null;
if (!TryReadImageLimit(table, "MAX_IMAGES_PER_MESSAGE", index, pluginId, logger, out var maxPerMessage))
return false;
if (!TryReadImageLimit(table, "MAX_IMAGES_PER_REQUEST", index, pluginId, logger, out var maxPerRequest))
return false;
if (maxPerMessage.HasValue || maxPerRequest.HasValue)
images = new(maxPerMessage, maxPerRequest);
return true;
}
/// <summary>
/// Reads one of the two image limits.
/// </summary>
/// <remarks>
/// Zero is a real answer, not a way of saying that nobody knows: an engine can be configured to
/// take no images at all. Unknown is the key being absent.
/// </remarks>
private static bool TryReadImageLimit(LuaTable table, string key, int index, Guid pluginId, ILogger logger, out int? limit)
{
limit = null;
if (!table.TryGetValue(key, out var value))
return true;
if (!value.TryRead<int>(out var read) || read < 0)
{
logger.LogWarning("The model declaration {DeclarationIndex} states a {Key} which is not a number of images of zero or more. (model plugin id: {PluginId})", index, key, pluginId);
return false;
}
limit = read;
return true;
}
/// <summary>
/// Reads where the declaration was read from, which it has to name.
/// </summary>
/// <remarks>
/// The compiler asks a family in the source for its source, and the same reasoning holds here:
/// a model card changes without telling anybody, and a statement nobody can check ages into a
/// defect. An organization's declaration outlives whoever wrote it, so the page and the day are
/// what lets the next administrator find out whether it still holds.
/// </remarks>
private static bool TryReadSource(LuaTable table, int index, Guid pluginId, ILogger logger, out ModelSource source)
{
source = new(string.Empty, default, string.Empty);
if (!TryReadText(table, "SOURCE_URL", out var url))
{
logger.LogWarning("The model declaration {DeclarationIndex} does not name a SOURCE_URL. State where these models are described, e.g. a model card or a page of your own documentation. (model plugin id: {PluginId})", index, pluginId);
return false;
}
if (!TryReadText(table, "SOURCE_CHECKED_ON", out var checkedOnText) || !DateOnly.TryParseExact(checkedOnText, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var checkedOn))
{
logger.LogWarning("The model declaration {DeclarationIndex} does not name a SOURCE_CHECKED_ON as a date of the form YYYY-MM-DD. State the day somebody last read that page. (model plugin id: {PluginId})", index, pluginId);
return false;
}
TryReadText(table, "SOURCE_NOTE", out var note);
source = new(url, checkedOn, note);
return true;
}
}
@@ -3,6 +3,7 @@ using System.Collections.Frozen;
using AIStudio.Models.Hosting;
using AIStudio.Models.Matching;
using AIStudio.Models.Plugins;
using AIStudio.Provider;
namespace AIStudio.Models.Registry;
@@ -11,11 +12,12 @@ namespace AIStudio.Models.Registry;
/// Everything the app knows about models, as one question with one answer.
/// </summary>
/// <remarks>
/// Four things happen to a name here, and the order they happen in is the whole design. The host
/// A few things happen to a name here, and the order they happen in is the whole design. The host
/// takes off whatever wrapping the provider put around the name, so that a rule can be written once
/// instead of once per provider. The rules answer the bare name, and the most specific of them
/// wins, computed rather than written down. The family which won may then work something out of the
/// name that no rule can express. And the host says what the way there took away.
/// instead of once per provider. What an organization declared about its own models answers first,
/// where it says anything. Otherwise the built-in rules answer the bare name, and the most specific
/// of them wins, computed rather than written down. The family which won may then work something
/// out of the name that no rule can express. And the host says what the way there took away.
///
/// Nothing in here reaches for application state, so a test can build a registry and ask it
/// questions without the app ever having started.
@@ -34,16 +36,14 @@ public sealed class ModelRegistry
private readonly FrozenDictionary<string, ModelFamily> familiesByName;
/// <summary>
/// The answers already worked out, so that a name is measured against the rules once.
/// What the plugins declare, and the answers worked out while they declared it.
/// </summary>
/// <remarks>
/// This is the reason the whole rebuild is worth doing at all. The question is asked from
/// components which re-render on every streamed chunk, and the expert dialog asks it about a
/// dozen times per render. A profile cannot be changed after it was built, so handing the same
/// one to every caller is safe -- unlike the old code, which handed out a list and had one
/// caller quietly change it.
/// The two belong together and are therefore replaced together. A cache which outlived the
/// declarations it was filled under would keep handing out what the rules said before a
/// plugin arrived, and a reader holding one half of a swap would mix the two.
/// </remarks>
private readonly ConcurrentDictionary<(LLMProviders Provider, string ModelId), ModelProfile> answered = new();
private volatile Answers answers = new(null);
private ModelRegistry(IReadOnlyList<ModelFamily> families, ModelFamilyIndex rules, ModelHostIndex hosts, FrozenDictionary<string, ModelFamily> familiesByName)
{
@@ -73,6 +73,29 @@ public sealed class ModelRegistry
/// </summary>
public ModelHostIndex Hosts { get; }
/// <summary>
/// The rules the running model plugins declare, in the order the index holds them.
/// </summary>
public IReadOnlyList<ModelRule> Declared => this.answers.Declared?.Rules ?? [];
/// <summary>
/// Takes over what the model plugins declare, replacing whatever they declared before.
/// </summary>
/// <remarks>
/// Replacing rather than adding, because this is called again whenever the plugins are
/// reloaded: a plugin somebody removed has to stop being heard, and a declaration somebody
/// corrected must not go on answering alongside its correction.
///
/// The declarations are pushed in rather than fetched. Nothing in here knows that plugins
/// exist, which is what keeps a registry buildable in a test without the plugin system, the
/// settings, or the app having started.
/// </remarks>
/// <param name="declarations">What the plugins declare, with each pattern claimed by one of them.</param>
public void Declare(IReadOnlyList<ModelDeclaration> declarations)
{
this.answers = new(declarations.Count is 0 ? null : ModelFamilyIndex.Build(declarations.Select(declaration => declaration.ToRule())));
}
/// <summary>
/// Builds a registry over a set of families and hosts.
/// </summary>
@@ -112,7 +135,12 @@ public sealed class ModelRegistry
if (NothingCanBeSaid(provider, modelId))
return ModelProfile.UNKNOWN;
return this.answered.GetOrAdd((provider, modelId), static (key, registry) => registry.Explain(key.Provider, key.ModelId).Profile, this);
//
// Read once, then used throughout: the plugins may be reloaded while this is running, and
// an answer worked out from one set of declarations belongs in the cache of that same set.
//
var current = this.answers;
return current.Cached.GetOrAdd((provider, modelId), static (key, state) => state.Registry.Explain(key.Provider, key.ModelId, state.Answers).Profile, (Registry: this, Answers: current));
}
/// <summary>
@@ -126,14 +154,39 @@ public sealed class ModelRegistry
/// <param name="provider">Who serves the model.</param>
/// <param name="modelId">The model ID exactly as that provider reports it.</param>
/// <returns>The resolution, including the profile as the provider serves it.</returns>
public ModelResolution Explain(LLMProviders provider, string modelId)
public ModelResolution Explain(LLMProviders provider, string modelId) => this.Explain(provider, modelId, this.answers);
/// <summary>
/// Says what is known about a model, against one particular set of plugin declarations.
/// </summary>
/// <param name="provider">Who serves the model.</param>
/// <param name="modelId">The model ID exactly as that provider reports it.</param>
/// <param name="current">The declarations to answer against, and the cache belonging to them.</param>
/// <returns>The resolution, including the profile as the provider serves it.</returns>
private ModelResolution Explain(LLMProviders provider, string modelId, Answers current)
{
if (NothingCanBeSaid(provider, modelId))
return ModelResolution.NOTHING;
var id = new ModelId(modelId);
var bare = this.Hosts.Unwrap(id, provider, out var declaredVendor);
var resolution = this.Rules.Explain(bare, provider, declaredVendor ?? ModelVendor.UNKNOWN);
var vendor = declaredVendor ?? ModelVendor.UNKNOWN;
//
// What an organization declared about a model comes before what the built-in rules work out
// of its name, and it comes instead of it rather than on top of it: a declaration is the
// whole statement about the models it matches. Letting the built-in rules add to it would
// mean a modifier nobody was thinking about could overrule what an organization stated --
// "guard" would still turn their own chat model into a moderation model.
//
// What stays is the transport, because that is not a statement about the model at all: a
// gateway which cannot pass an API through does not pass it through, whoever describes the
// model behind it.
//
if (current.Declared?.Explain(bare, provider, vendor) is { IsKnown: true } declared)
return declared with { Profile = this.Hosts.ApplyTransport(declared.Profile, provider) };
var resolution = this.Rules.Explain(bare, provider, vendor);
//
// Only the family which chose the model refines it. A modifier adjusts an answer; it does
@@ -162,4 +215,31 @@ public sealed class ModelRegistry
/// <param name="selector">The rule which chose the model.</param>
/// <returns>The family, or nothing when no rule chose.</returns>
private ModelFamily? FamilyOf(ModelRule? selector) => selector is null ? null : this.familiesByName.GetValueOrDefault(selector.Origin);
/// <summary>
/// What the registry answers with, and what it has answered so far.
/// </summary>
/// <remarks>
/// The cache is the reason the whole rebuild is worth doing at all. The question is asked from
/// components which re-render on every streamed chunk, and the expert dialog asks it about a
/// dozen times per render. A profile cannot be changed after it was built, so handing the same
/// one to every caller is safe -- unlike the old code, which handed out a list and had one
/// caller quietly change it.
///
/// It sits next to the declarations rather than beside them, so that replacing what the plugins
/// say throws away exactly the answers which were given while they said something else.
/// </remarks>
/// <param name="declared">What the running model plugins declare, or null when they declare nothing.</param>
private sealed class Answers(ModelFamilyIndex? declared)
{
/// <summary>
/// What the running model plugins declare.
/// </summary>
public ModelFamilyIndex? Declared { get; } = declared;
/// <summary>
/// The answers already worked out, so that a name is measured against the rules once.
/// </summary>
public ConcurrentDictionary<(LLMProviders Provider, string ModelId), ModelProfile> Cached { get; } = new();
}
}