Let organizations describe their own models

This commit is contained in:
Thorsten Sommer 2026-09-12 15:26:54 +02:00
parent f4078856d1
commit 6d80e6de8a
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
18 changed files with 1266 additions and 48 deletions

View File

@ -11422,6 +11422,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINLANGUAGE::T4112586014"] =
-- The field LANG_NAME does not exist or is not a valid string. -- The field LANG_NAME does not exist or is not a valid string.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINLANGUAGE::T4204700759"] = "The field LANG_NAME does not exist or is not a valid string." UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINLANGUAGE::T4204700759"] = "The field LANG_NAME does not exist or is not a valid string."
-- The table MODELS does not exist or is using an invalid syntax.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINMODELS::T976664425"] = "The table MODELS does not exist or is using an invalid syntax."
-- Artists -- Artists
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T1142248183"] = "Artists" UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T1142248183"] = "Artists"
@ -11464,6 +11467,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T62
-- Software developers -- Software developers
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T831424531"] = "Software developers" UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T831424531"] = "Software developers"
-- Model plugin
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTYPEEXTENSIONS::T1507522553"] = "Model plugin"
-- Theme plugin -- Theme plugin
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTYPEEXTENSIONS::T1682350097"] = "Theme plugin" UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTYPEEXTENSIONS::T1682350097"] = "Theme plugin"

View File

@ -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;
}
}

View File

@ -3,6 +3,7 @@ using System.Collections.Frozen;
using AIStudio.Models.Hosting; using AIStudio.Models.Hosting;
using AIStudio.Models.Matching; using AIStudio.Models.Matching;
using AIStudio.Models.Plugins;
using AIStudio.Provider; using AIStudio.Provider;
namespace AIStudio.Models.Registry; namespace AIStudio.Models.Registry;
@ -11,11 +12,12 @@ namespace AIStudio.Models.Registry;
/// Everything the app knows about models, as one question with one answer. /// Everything the app knows about models, as one question with one answer.
/// </summary> /// </summary>
/// <remarks> /// <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 /// 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 /// instead of once per provider. What an organization declared about its own models answers first,
/// wins, computed rather than written down. The family which won may then work something out of the /// where it says anything. Otherwise the built-in rules answer the bare name, and the most specific
/// name that no rule can express. And the host says what the way there took away. /// 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 /// Nothing in here reaches for application state, so a test can build a registry and ask it
/// questions without the app ever having started. /// questions without the app ever having started.
@ -34,16 +36,14 @@ public sealed class ModelRegistry
private readonly FrozenDictionary<string, ModelFamily> familiesByName; private readonly FrozenDictionary<string, ModelFamily> familiesByName;
/// <summary> /// <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> /// </summary>
/// <remarks> /// <remarks>
/// This is the reason the whole rebuild is worth doing at all. The question is asked from /// The two belong together and are therefore replaced together. A cache which outlived the
/// components which re-render on every streamed chunk, and the expert dialog asks it about a /// declarations it was filled under would keep handing out what the rules said before a
/// dozen times per render. A profile cannot be changed after it was built, so handing the same /// plugin arrived, and a reader holding one half of a swap would mix the two.
/// one to every caller is safe -- unlike the old code, which handed out a list and had one
/// caller quietly change it.
/// </remarks> /// </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) private ModelRegistry(IReadOnlyList<ModelFamily> families, ModelFamilyIndex rules, ModelHostIndex hosts, FrozenDictionary<string, ModelFamily> familiesByName)
{ {
@ -73,6 +73,29 @@ public sealed class ModelRegistry
/// </summary> /// </summary>
public ModelHostIndex Hosts { get; } 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> /// <summary>
/// Builds a registry over a set of families and hosts. /// Builds a registry over a set of families and hosts.
/// </summary> /// </summary>
@ -112,7 +135,12 @@ public sealed class ModelRegistry
if (NothingCanBeSaid(provider, modelId)) if (NothingCanBeSaid(provider, modelId))
return ModelProfile.UNKNOWN; 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> /// <summary>
@ -126,14 +154,39 @@ public sealed class ModelRegistry
/// <param name="provider">Who serves the model.</param> /// <param name="provider">Who serves the model.</param>
/// <param name="modelId">The model ID exactly as that provider reports it.</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> /// <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)) if (NothingCanBeSaid(provider, modelId))
return ModelResolution.NOTHING; return ModelResolution.NOTHING;
var id = new ModelId(modelId); var id = new ModelId(modelId);
var bare = this.Hosts.Unwrap(id, provider, out var declaredVendor); 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 // 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> /// <param name="selector">The rule which chose the model.</param>
/// <returns>The family, or nothing when no rule chose.</returns> /// <returns>The family, or nothing when no rule chose.</returns>
private ModelFamily? FamilyOf(ModelRule? selector) => selector is null ? null : this.familiesByName.GetValueOrDefault(selector.Origin); 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();
}
} }

View File

@ -95,7 +95,8 @@
<AssistantPluginSecurityCard Plugin="@assistantPlugin" Compact="@true"/> <AssistantPluginSecurityCard Plugin="@assistantPlugin" Compact="@true"/>
} }
@if (context is { IsInternal: false, Type: not PluginType.CONFIGURATION }) @* A model plugin runs like a configuration plugin, without anybody switching it on: *@
@if (context is { IsInternal: false, Type: not (PluginType.CONFIGURATION or PluginType.MODEL) })
{ {
var isEnabled = this.SettingsManager.IsPluginEnabled(context); var isEnabled = this.SettingsManager.IsPluginEnabled(context);
var activationSwitchDisabled = this.IsActivationSwitchDisabled(context, isEnabled); var activationSwitchDisabled = this.IsActivationSwitchDisabled(context, isEnabled);

View File

@ -0,0 +1,186 @@
-- ------
-- This is an example of a model plugin. Please replace
-- the placeholders and assign a valid ID.
-- All IDs should be lower-case.
-- ------
-- The ID for this plugin:
ID = "00000000-0000-0000-0000-000000000000"
-- The name of the plugin:
NAME = "<Company Name> - Models of <Department Name>"
-- The description of the plugin:
DESCRIPTION = "Describes the models <Company Name> runs itself"
-- The version of the plugin:
VERSION = "1.0.0"
-- The type of the plugin:
TYPE = "MODEL"
-- The priority of this model plugin. Optional, defaults to 0.
--
-- It only matters when two of your model plugins describe exactly the same
-- model names. The plugin with the higher priority wins then. Two plugins
-- describing different models never get in each other's way, and both are used.
--
-- The priority never lifts a locally placed model plugin above one of your
-- organization: what your IT department deployed always wins.
PRIORITY = 0
-- The authors of the plugin:
AUTHORS = {"<Company Name>"}
-- The support contact for the plugin:
SUPPORT_CONTACT = "<IT Department of Company Name>"
-- The source URL for the plugin. Can be a HTTP(S) URL or a mailto link:
SOURCE_URL = "<Any internal Git repository>"
-- The categories for the plugin:
CATEGORIES = { "CORE" }
-- The target groups for the plugin:
TARGET_GROUPS = { "EVERYONE" }
-- The flag for whether the plugin is maintained:
IS_MAINTAINED = true
-- When the plugin is deprecated, this message will be shown to users:
DEPRECATION_MESSAGE = ""
-- ------
-- What a model plugin is for
-- ------
--
-- AI Studio knows what the models of the large vendors can do. It cannot know
-- what your own models can do: a fine-tune of your own, a model behind an
-- internal name, or an engine you configured differently from its model card.
-- This is where you tell it.
--
-- A model plugin only describes. It names no server, carries no API key, and
-- runs no code. Which server a model is reached through stays where it was: in
-- the LLM providers of your configuration plugin.
--
-- Each entry below replaces what AI Studio would otherwise work out about the
-- model names it matches. It is the whole statement about them, which is why
-- CAPABILITIES is required: write each entry as if AI Studio knew nothing about
-- these models at all.
--
-- If you only want to correct one detail of a model AI Studio already knows --
-- one provider which accepts no images, say -- do not write an entry here. Use
-- the CapabilityOverrides of that LLM provider in your configuration plugin
-- instead. Your users can set the same thing in the expert settings of their
-- provider, and both win over everything below.
MODELS = {}
-- An example: a fine-tune an organization serves on its own vLLM.
-- MODELS[#MODELS+1] = {
--
-- -- Which model names this entry describes. Write it the way a model name
-- -- is written: lower case, hyphens between the parts. A pattern which is
-- -- written differently can never match anything and is rejected.
-- ["PATTERN"] = "acme-assistant",
--
-- -- How the pattern is bound to the name. Optional, defaults to SEGMENT.
-- --
-- -- EXACT The pattern is the whole model name.
-- -- PREFIX The name begins with the pattern, at a part boundary.
-- -- "acme-assistant" then also covers "acme-assistant-7b".
-- -- SEGMENT The pattern appears in the name as whole parts. This is
-- -- the one to reach for.
-- -- SUBSTRING The pattern appears anywhere in the name, boundaries or
-- -- not. The last resort, for names a vendor glued together.
-- --
-- -- Note that a dot separates versions rather than name parts: a pattern
-- -- "acme-assistant-3" does not match "acme-assistant-3.1". Write the
-- -- version you mean.
-- ["MATCH"] = "PREFIX",
--
-- -- Optional: further name parts the name has to carry, and name parts
-- -- whose presence rules this entry out. This is how you describe two
-- -- variants which share a name.
-- -- ["ALSO_CONTAINS"] = { "vision" },
-- -- ["NOT_CONTAINS"] = { "base" },
--
-- -- Optional: restrict this entry to one LLM provider, for the case where
-- -- the same name means different things depending on who serves it.
-- -- Allowed values are: OPEN_AI, ANTHROPIC, MISTRAL, GOOGLE, X, DEEP_SEEK,
-- -- ALIBABA_CLOUD, PERPLEXITY, OPEN_ROUTER, HETZNER, IONOS, LITE_LLM,
-- -- FIREWORKS, GROQ, HUGGINGFACE, SELF_HOSTED, HELMHOLTZ, GWDG
-- ["ONLY_ON"] = "SELF_HOSTED",
--
-- -- Optional: restrict this entry to models of one vendor. Only gateways
-- -- which name the vendor alongside the model, such as OpenRouter, can
-- -- answer this at all.
-- -- ["ONLY_FROM"] = "META",
--
-- -- What these models can do. Required: this entry replaces everything
-- -- AI Studio would otherwise say about them.
-- -- Name one capability per entry. Allowed values are:
-- -- TEXT_INPUT, AUDIO_INPUT, SINGLE_IMAGE_INPUT, MULTIPLE_IMAGE_INPUT,
-- -- SPEECH_INPUT, VIDEO_INPUT, TEXT_OUTPUT, AUDIO_OUTPUT, IMAGE_OUTPUT,
-- -- SPEECH_OUTPUT, VIDEO_OUTPUT, EMBEDDING, REALTIME, FUNCTION_CALLING,
-- -- WEB_SEARCH, CHAT_COMPLETION_API, RESPONSES_API
-- -- Name at least the APIs the model answers through, otherwise AI Studio
-- -- does not know how to talk to it.
-- ["CAPABILITIES"] = {
-- "TEXT_INPUT",
-- "MULTIPLE_IMAGE_INPUT",
-- "TEXT_OUTPUT",
-- "FUNCTION_CALLING",
-- "CHAT_COMPLETION_API",
-- },
--
-- -- How the model reasons (thinks). Optional, defaults to NONE.
-- -- Allowed values are:
-- -- NONE The model does not reason.
-- -- OPTIONAL Reasoning can be switched on, and is off by default.
-- -- ON_BY_DEFAULT Reasoning is on unless a parameter switches it off.
-- -- ALWAYS Reasoning cannot be switched off.
-- -- Whether the indicator lights up also depends on the additional API
-- -- parameters of the configured provider.
-- ["REASONING"] = "OPTIONAL",
--
-- -- What the model is made for. Optional, defaults to CHAT.
-- -- Allowed values are: CHAT, TEXT_COMPLETION, EMBEDDING, RERANKING,
-- -- IMAGE_GENERATION, VIDEO_GENERATION, TRANSCRIPTION, SPEECH_SYNTHESIS,
-- -- REALTIME, COMPUTER_USE, OCR, MODERATION, OTHER
-- -- This decides which lists the model appears in. Use OTHER for entries
-- -- which are no models at all.
-- ["KIND"] = "CHAT",
--
-- -- Optional: how many tokens the model reads and writes in one
-- -- conversation, as it is served.
-- ["CONTEXT_WINDOW"] = 131072,
--
-- -- Optional: what an operator can raise that window to. Only state this
-- -- when you also state CONTEXT_WINDOW, and never below it.
-- -- ["CONTEXT_WINDOW_RAISABLE_TO"] = 262144,
--
-- -- Optional: which tokenizer counts this model's tokens. Both keys
-- -- belong together, because the kind says how the ID would be read.
-- -- Allowed kinds are: HUGGING_FACE, TIKTOKEN, PROVIDER_API, NONE
-- -- AI Studio records the reference; it does not fetch a tokenizer.
-- -- ["TOKENIZER_KIND"] = "HUGGING_FACE",
-- -- ["TOKENIZER_ID"] = "acme/assistant",
--
-- -- Optional: how many images the model accepts. Both numbers exist and
-- -- are not the same one, so state whichever your source names. Zero is a
-- -- real answer here; leaving a key out means nobody knows.
-- -- Note that vLLM accepts one image per prompt unless the operator
-- -- raised --limit-mm-per-prompt.
-- -- ["MAX_IMAGES_PER_MESSAGE"] = 1,
-- -- ["MAX_IMAGES_PER_REQUEST"] = 8,
--
-- -- Where all of this was read, and when somebody last looked. Required.
-- -- A model card changes without telling anybody, and a statement nobody
-- -- can check ages into a defect. Your entry will outlive whoever wrote
-- -- it, so name the page and the day: it is what lets the next
-- -- administrator find out in a minute whether it still holds.
-- ["SOURCE_URL"] = "https://intranet.company.org/ai/acme-assistant",
-- ["SOURCE_CHECKED_ON"] = "2026-09-12",
-- ["SOURCE_NOTE"] = "Internal model card: tools, images, 128k context",
-- }

View File

@ -0,0 +1,19 @@
namespace AIStudio.Tools.PluginSystem;
/// <summary>
/// A plugin which contributes live content, and therefore takes part in deciding a collision.
/// </summary>
/// <remarks>
/// Two plugins may well say something about the same thing. Which of them is heard is decided the
/// same way for every kind of content: a plugin acting on behalf of the organization wins, and
/// among plugins of the same origin the declared priority does. Where the plugin was stored is
/// known from its path; what it declared has to come from the plugin itself, which is all this
/// interface is for.
/// </remarks>
public interface ILivePluginContentSource
{
/// <summary>
/// The priority this plugin declares. Zero when it declares none.
/// </summary>
public int Priority { get; }
}

View File

@ -9,7 +9,7 @@ using Lua;
namespace AIStudio.Tools.PluginSystem; namespace AIStudio.Tools.PluginSystem;
public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginType type) : PluginBase(isInternal, state, type) public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginType type) : PluginBase(isInternal, state, type), ILivePluginContentSource
{ {
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginConfiguration).Namespace, nameof(PluginConfiguration)); private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginConfiguration).Namespace, nameof(PluginConfiguration));
private static SettingsManager SettingsManagerAccess => Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>(); private static SettingsManager SettingsManagerAccess => Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();

View File

@ -427,7 +427,12 @@ public static partial class PluginFactory
var assistantPlugin = new PluginAssistants(isInternal, state, type); var assistantPlugin = new PluginAssistants(isInternal, state, type);
assistantPlugin.TryLoad(); assistantPlugin.TryLoad();
return assistantPlugin; return assistantPlugin;
case PluginType.MODEL:
var modelPlugin = new PluginModels(isInternal, state, type);
modelPlugin.TryLoad();
return modelPlugin;
default: default:
return new NoPlugin("This plugin type is not supported yet. Please try again with a future version of AI Studio."); return new NoPlugin("This plugin type is not supported yet. Please try again with a future version of AI Studio.");
} }

View File

@ -1,3 +1,5 @@
using AIStudio.Models.Registry;
namespace AIStudio.Tools.PluginSystem; namespace AIStudio.Tools.PluginSystem;
public static partial class PluginFactory public static partial class PluginFactory
@ -64,6 +66,7 @@ public static partial class PluginFactory
// declare an ID which differs from its directory name, and a single directory may even hold // declare an ID which differs from its directory name, and a single directory may even hold
// several plugins: // several plugins:
// //
var unloadedAModelPlugin = false;
foreach (var plugin in AVAILABLE_PLUGINS.Where(plugin => IsPathInside(configurationDirectory, plugin.LocalPath)).ToList()) foreach (var plugin in AVAILABLE_PLUGINS.Where(plugin => IsPathInside(configurationDirectory, plugin.LocalPath)).ToList())
{ {
AVAILABLE_PLUGINS.Remove(plugin); AVAILABLE_PLUGINS.Remove(plugin);
@ -71,6 +74,7 @@ public static partial class PluginFactory
if (RUNNING_PLUGINS.FirstOrDefault(runningPlugin => runningPlugin.Id == plugin.Id) is { } runningPluginToRemove) if (RUNNING_PLUGINS.FirstOrDefault(runningPlugin => runningPlugin.Id == plugin.Id) is { } runningPluginToRemove)
{ {
RUNNING_PLUGINS.Remove(runningPluginToRemove); RUNNING_PLUGINS.Remove(runningPluginToRemove);
unloadedAModelPlugin |= runningPluginToRemove is PluginModels;
// The plugin is unloaded, so its Lua runtime is of no use anymore: // The plugin is unloaded, so its Lua runtime is of no use anymore:
runningPluginToRemove.Dispose(); runningPluginToRemove.Dispose();
@ -79,6 +83,15 @@ public static partial class PluginFactory
LOG.LogInformation("Unloaded the plugin '{PluginName}' ({PluginId}). Reason: {Reason}.", plugin.Name, plugin.Id, reason); LOG.LogInformation("Unloaded the plugin '{PluginName}' ({PluginId}). Reason: {Reason}.", plugin.Name, plugin.Id, reason);
} }
//
// This clean-up runs after the plugins were started, and nothing starts them again
// afterwards. A model plugin whose configuration is gone would otherwise go on describing
// models until the next restart, which is the one thing withdrawing a configuration has to
// stop:
//
if (unloadedAModelPlugin)
ModelRegistry.Shared.Declare(GetModelDeclarations());
if (!Directory.Exists(configurationDirectory)) if (!Directory.Exists(configurationDirectory))
return; return;

View File

@ -1,4 +1,5 @@
using System.Text; using System.Text;
using AIStudio.Models.Registry;
using AIStudio.Settings; using AIStudio.Settings;
using AIStudio.Settings.DataModel; using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem.Assistants; using AIStudio.Tools.PluginSystem.Assistants;
@ -92,7 +93,13 @@ public static partial class PluginFactory
try try
{ {
if (availablePlugin.IsInternal || SettingsManagerAccess.IsPluginEnabled(availablePlugin) || availablePlugin.Type == PluginType.CONFIGURATION || availablePlugin.Type == PluginType.ASSISTANT) //
// A model plugin runs like a configuration plugin, without anybody switching it on:
// it describes models an organization deployed it to describe, and a description
// somebody has to enable first would leave half the installations answering
// differently from the other half for no reason anyone could see.
//
if (availablePlugin.IsInternal || SettingsManagerAccess.IsPluginEnabled(availablePlugin) || availablePlugin.Type is PluginType.CONFIGURATION or PluginType.ASSISTANT or PluginType.MODEL)
if(await Start(availablePlugin, cancellationToken) is { IsValid: true } plugin) if(await Start(availablePlugin, cancellationToken) is { IsValid: true } plugin)
{ {
if (plugin is PluginConfiguration configPlugin) if (plugin is PluginConfiguration configPlugin)
@ -108,7 +115,15 @@ public static partial class PluginFactory
} }
LogAssistantPluginStartupState(); LogAssistantPluginStartupState();
//
// Hand what the model plugins declare to the registry before anything is told that the
// plugins are up. Whoever reacts to that message may ask about a model right away, and the
// registry keeps the answers it gives: an answer handed out before the declarations arrived
// would be the answer everybody gets until the next reload.
//
ModelRegistry.Shared.Declare(GetModelDeclarations());
// Inform all components that the plugins have been reloaded or started: // Inform all components that the plugins have been reloaded or started:
await MessageBus.INSTANCE.SendMessage<bool>(null, Event.PLUGINS_RELOADED); await MessageBus.INSTANCE.SendMessage<bool>(null, Event.PLUGINS_RELOADED);
return configObjects; return configObjects;

View File

@ -1,3 +1,4 @@
using AIStudio.Models.Plugins;
using AIStudio.Settings; using AIStudio.Settings;
using AIStudio.Settings.DataModel; using AIStudio.Settings.DataModel;
@ -437,37 +438,53 @@ public static partial class PluginFactory
public static IReadOnlyList<DataMandatoryInfo> GetMandatoryInfos() public static IReadOnlyList<DataMandatoryInfo> GetMandatoryInfos()
{ {
return ResolveLivePluginContent<DataMandatoryInfo>("mandatory info", plugin => plugin.MandatoryInfos).ToList(); return ResolveLivePluginContent<PluginConfiguration, DataMandatoryInfo>("mandatory info", plugin => plugin.MandatoryInfos).ToList();
} }
public static IReadOnlyList<DataIntroduction> GetIntroductions() public static IReadOnlyList<DataIntroduction> GetIntroductions()
{ {
return ResolveLivePluginContent<DataIntroduction>("introduction", plugin => plugin.Introductions) return ResolveLivePluginContent<PluginConfiguration, DataIntroduction>("introduction", plugin => plugin.Introductions)
.OrderBy(introduction => introduction.Index) .OrderBy(introduction => introduction.Index)
.ThenBy(introduction => introduction.Id, StringComparer.Ordinal) .ThenBy(introduction => introduction.Id, StringComparer.Ordinal)
.ToList(); .ToList();
} }
/// <summary> /// <summary>
/// Collects live content from all running configuration plugins, so that each content ID appears exactly once. /// Collects what the running model plugins declare about models.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The IDs of live content are chosen by whoever writes the configuration, so two configuration /// A declaration is identified by its pattern, so two plugins claiming exactly the same model
/// plugins may use the same ID. We resolve such a collision the same way a collision on a setting /// names are a collision like any other and are settled the same way. Two plugins describing
/// is resolved: a configuration which acts on behalf of the organization wins, so nobody can push /// different models never meet, and both are heard.
/// aside what an organization deployed. Among configurations of the same origin, the declared /// </remarks>
/// priority decides, and when even that is equal, the plugin which started later wins.<br/><br/> /// <returns>The declarations of all model plugins, with every pattern resolved to one winner.</returns>
public static IReadOnlyList<ModelDeclaration> GetModelDeclarations()
{
return ResolveLivePluginContent<PluginModels, ModelDeclaration>("model declaration", plugin => plugin.Declarations).ToList();
}
/// <summary>
/// Collects live content from all running plugins of one kind, so that each content ID appears exactly once.
/// </summary>
/// <remarks>
/// The IDs of live content are chosen by whoever writes the plugin, so two plugins may use the
/// same ID. We resolve such a collision the same way a collision on a setting is resolved: a
/// plugin which acts on behalf of the organization wins, so nobody can push aside what an
/// organization deployed. Among plugins of the same origin, the declared priority decides, and
/// when even that is equal, the plugin which started later wins.<br/><br/>
/// Duplicates are not merely a cosmetic problem: the home page keys its panels by the introduction /// Duplicates are not merely a cosmetic problem: the home page keys its panels by the introduction
/// ID, and the acceptance of a mandatory info is stored per ID as well. /// ID, the acceptance of a mandatory info is stored per ID as well, and two model declarations
/// claiming the same names would tie in the matching engine, which only a person can settle.
/// </remarks> /// </remarks>
/// <param name="contentKind">The kind of content, used to report a collision in the log.</param> /// <param name="contentKind">The kind of content, used to report a collision in the log.</param>
/// <param name="selector">Selects the content of one configuration plugin.</param> /// <param name="selector">Selects the content of one plugin.</param>
/// <typeparam name="TPlugin">The kind of plugin providing the content.</typeparam>
/// <typeparam name="T">The type of the live plugin content.</typeparam> /// <typeparam name="T">The type of the live plugin content.</typeparam>
/// <returns>The content of all configuration plugins, with every ID resolved to one winner.</returns> /// <returns>The content of all those plugins, with every ID resolved to one winner.</returns>
private static IEnumerable<T> ResolveLivePluginContent<T>(string contentKind, Func<PluginConfiguration, IEnumerable<T>> selector) where T : ILivePluginContent private static IEnumerable<T> ResolveLivePluginContent<TPlugin, T>(string contentKind, Func<TPlugin, IEnumerable<T>> selector) where TPlugin : PluginBase, ILivePluginContentSource where T : ILivePluginContent
{ {
var contentById = new Dictionary<string, (T Content, int Authority, int Priority)>(StringComparer.Ordinal); var contentById = new Dictionary<string, (T Content, int Authority, int Priority)>(StringComparer.Ordinal);
foreach (var plugin in RUNNING_PLUGINS.OfType<PluginConfiguration>()) foreach (var plugin in RUNNING_PLUGINS.OfType<TPlugin>())
{ {
var authority = GetConfigurationAuthority(plugin.PluginPath); var authority = GetConfigurationAuthority(plugin.PluginPath);
foreach (var content in selector(plugin)) foreach (var content in selector(plugin))
@ -484,14 +501,14 @@ public static partial class PluginFactory
var ignoredPluginId = isTakingOver ? currentWinner.Content.EnterpriseConfigurationPluginId : content.EnterpriseConfigurationPluginId; var ignoredPluginId = isTakingOver ? currentWinner.Content.EnterpriseConfigurationPluginId : content.EnterpriseConfigurationPluginId;
if (winnerPluginId == ignoredPluginId) if (winnerPluginId == ignoredPluginId)
LOG.LogWarning($"The configuration plugin '{winnerPluginId}' defines the {contentKind} ID '{content.Id}' more than once. Using its last definition and ignoring the earlier one. Please use each ID only once."); LOG.LogWarning($"The plugin '{winnerPluginId}' defines the {contentKind} ID '{content.Id}' more than once. Using its last definition and ignoring the earlier one. Please use each ID only once.");
else else
{ {
var reason = isTakingOver var reason = isTakingOver
? DescribeConfigurationPrecedence(authority, plugin.Priority, currentWinner.Authority, currentWinner.Priority) ? DescribeConfigurationPrecedence(authority, plugin.Priority, currentWinner.Authority, currentWinner.Priority)
: DescribeConfigurationPrecedence(currentWinner.Authority, currentWinner.Priority, authority, plugin.Priority); : DescribeConfigurationPrecedence(currentWinner.Authority, currentWinner.Priority, authority, plugin.Priority);
LOG.LogWarning($"Multiple configuration plugins define the {contentKind} ID '{content.Id}'. Using the one from the configuration plugin '{winnerPluginId}' and ignoring the one from the configuration plugin '{ignoredPluginId}', because {reason}."); LOG.LogWarning($"Multiple plugins define the {contentKind} ID '{content.Id}'. Using the one from the plugin '{winnerPluginId}' and ignoring the one from the plugin '{ignoredPluginId}', because {reason}.");
} }
if (!isTakingOver) if (!isTakingOver)
@ -506,7 +523,7 @@ public static partial class PluginFactory
} }
/// <summary> /// <summary>
/// Explains in one phrase why one configuration plugin won a collision against another. /// Explains in one phrase why one plugin won a collision against another.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Administrators read this in the log while they are testing their configuration. Naming the /// Administrators read this in the log while they are testing their configuration. Naming the
@ -515,11 +532,11 @@ public static partial class PluginFactory
private static string DescribeConfigurationPrecedence(int winnerAuthority, int winnerPriority, int ignoredAuthority, int ignoredPriority) private static string DescribeConfigurationPrecedence(int winnerAuthority, int winnerPriority, int ignoredAuthority, int ignoredPriority)
{ {
if (winnerAuthority != ignoredAuthority) if (winnerAuthority != ignoredAuthority)
return "a configuration which acts on behalf of your organization takes precedence over a locally placed one"; return "a plugin which acts on behalf of your organization takes precedence over a locally placed one";
if (winnerPriority != ignoredPriority) if (winnerPriority != ignoredPriority)
return $"it declares the higher priority ({winnerPriority} instead of {ignoredPriority})"; return $"it declares the higher priority ({winnerPriority} instead of {ignoredPriority})";
return $"both declare the same priority ({winnerPriority}), so the configuration plugin which started later wins"; return $"both declare the same priority ({winnerPriority}), so the plugin which started later wins";
} }
} }

View File

@ -0,0 +1,72 @@
using AIStudio.Models.Plugins;
using Lua;
namespace AIStudio.Tools.PluginSystem;
/// <summary>
/// A plugin which tells AI Studio about models it does not know, or knows wrongly.
/// </summary>
/// <remarks>
/// Organizations run models nobody outside them has ever heard of: their own fine-tunes, a model
/// behind an internal name, an engine an operator configured differently from the model card. Until
/// now the only way to tell AI Studio about those was the expert settings of each configured
/// provider, one person and one provider at a time.
///
/// A model plugin describes, and that is all it does. It names no endpoint, carries no key, runs no
/// code of its own and reaches nothing over the network, which is why it needs none of the checks an
/// assistant plugin goes through. Where it was deployed is what says how much it may claim, exactly
/// as for every other kind of plugin.
/// </remarks>
public sealed class PluginModels(bool isInternal, LuaState state, PluginType type) : PluginBase(isInternal, state, type), ILivePluginContentSource
{
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(PluginModels));
private readonly List<ModelDeclaration> declarations = [];
/// <summary>
/// The models this plugin declares.
/// </summary>
public IReadOnlyList<ModelDeclaration> Declarations => this.declarations;
/// <inheritdoc />
public int Priority { get; } = ReadPriority(state);
/// <summary>
/// Reads the MODELS table of the plugin.
/// </summary>
/// <remarks>
/// An entry which cannot be read is reported and skipped, and the rest of the table still
/// counts. A single mistyped capability in the twentieth entry must not take the nineteen
/// working ones with it -- the plugin would then be silently doing nothing at all.
/// </remarks>
public void TryLoad()
{
if (!this.State.Environment["MODELS"].TryRead<LuaTable>(out var modelsTable))
{
this.PluginIssues.Add(TB("The table MODELS does not exist or is using an invalid syntax."));
return;
}
for (var i = 1; i <= modelsTable.ArrayLength; i++)
{
if (!modelsTable[i].TryRead<LuaTable>(out var modelTable))
{
LOG.LogWarning("The table 'MODELS' entry at index {Index} is not a valid table (model plugin id: {PluginId}).", i, this.Id);
continue;
}
if (ModelDeclaration.TryParse(i, modelTable, this.Id, this.Name, LOG, out var declaration))
this.declarations.Add(declaration);
else
LOG.LogWarning("The table 'MODELS' entry at index {Index} does not contain a valid model declaration and is ignored (model plugin id: {PluginId}).", i, this.Id);
}
if (this.declarations.Count is 0)
LOG.LogWarning("The model plugin '{PluginId}' declares no model AI Studio could read. It has no effect.", this.Id);
}
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginModels).Namespace, nameof(PluginModels));
private static int ReadPriority(LuaState state) => state.Environment["PRIORITY"].TryRead<int>(out var priority) ? priority : 0;
}

View File

@ -8,4 +8,5 @@ public enum PluginType
ASSISTANT, ASSISTANT,
CONFIGURATION, CONFIGURATION,
THEME, THEME,
MODEL,
} }

View File

@ -10,7 +10,8 @@ public static class PluginTypeExtensions
PluginType.ASSISTANT => TB("Assistant plugin"), PluginType.ASSISTANT => TB("Assistant plugin"),
PluginType.CONFIGURATION => TB("Configuration plugin"), PluginType.CONFIGURATION => TB("Configuration plugin"),
PluginType.THEME => TB("Theme plugin"), PluginType.THEME => TB("Theme plugin"),
PluginType.MODEL => TB("Model plugin"),
_ => TB("Unknown plugin type"), _ => TB("Unknown plugin type"),
}; };
@ -20,7 +21,8 @@ public static class PluginTypeExtensions
PluginType.ASSISTANT => "assistants", PluginType.ASSISTANT => "assistants",
PluginType.CONFIGURATION => "configurations", PluginType.CONFIGURATION => "configurations",
PluginType.THEME => "themes", PluginType.THEME => "themes",
PluginType.MODEL => "models",
_ => "unknown", _ => "unknown",
}; };
} }

View File

@ -1,5 +1,3 @@
using AIStudio.Provider;
using static AIStudio.Provider.LLMProviders; using static AIStudio.Provider.LLMProviders;
using static AIStudio.Provider.ModelKind; using static AIStudio.Provider.ModelKind;

View File

@ -0,0 +1,180 @@
using AIStudio.Models;
using AIStudio.Models.Matching;
using AIStudio.Models.Plugins;
using AIStudio.Models.Registry;
using AIStudio.Provider;
namespace AIStudio.Tests.Models.Plugins;
/// <summary>
/// Checks where what an organization declares stands against what AI Studio works out itself.
/// </summary>
/// <remarks>
/// Each test builds a registry of its own rather than asking the one the app uses. What is being
/// checked is the order of the chain, and a test which had to name a real model to check it would
/// start failing the day somebody corrects that model's rule.
///
/// The one exception borrows the registry the app uses, because only that one knows the hosts. It
/// hands it back empty, and the fixture is kept out of any parallel run so that the borrowing
/// cannot reach a test asking the same registry about a real model.
/// </remarks>
[TestFixture]
[NonParallelizable]
public sealed class DeclaredModelsTests
{
private static readonly Guid PLUGIN_ID = new("11111111-1111-1111-1111-111111111111");
[Test]
public void WhatAnOrganizationDeclaresComesBeforeWhatTheRulesWorkOut()
{
var registry = ModelRegistry.Build([new AcmeFamily()], []);
registry.Declare([Declaring("acme-assistant", MatchKind.PREFIX, Capability.TEXT_INPUT | Capability.TEXT_OUTPUT | Capability.MULTIPLE_IMAGE_INPUT)]);
var profile = registry.Profile(LLMProviders.SELF_HOSTED, "acme-assistant-7b");
Assert.That(profile.Has(Capability.MULTIPLE_IMAGE_INPUT), Is.True, "Only the organization says this model reads images, and they are the ones running it.");
}
[Test]
public void ADeclarationIsTheWholeStatementAndNotAnAdditionToOne()
{
//
// The part an administrator has to be able to rely on. Their entry says what the model can
// do, so what AI Studio would have said instead is gone -- including the capabilities their
// entry does not mention. Adding to the built-in answer would make it impossible to take
// anything away, which is exactly what somebody correcting us is trying to do.
//
var registry = ModelRegistry.Build([new AcmeFamily()], []);
registry.Declare([Declaring("acme-assistant", MatchKind.PREFIX, Capability.TEXT_INPUT | Capability.TEXT_OUTPUT)]);
var profile = registry.Profile(LLMProviders.SELF_HOSTED, "acme-assistant-7b");
Assert.Multiple(() =>
{
Assert.That(profile.Has(Capability.FUNCTION_CALLING), Is.False, "The built-in rule grants this one, and the declaration does not.");
Assert.That(profile.Reasoning, Is.EqualTo(ReasoningSupport.NONE), "Nor does it reason, whatever the built-in rule says.");
});
}
[Test]
public void AModelNoDeclarationMentionsIsAnsweredByTheRulesAsBefore()
{
var registry = ModelRegistry.Build([new AcmeFamily()], []);
registry.Declare([Declaring("something-else", MatchKind.SEGMENT, Capability.TEXT_INPUT)]);
var profile = registry.Profile(LLMProviders.SELF_HOSTED, "acme-assistant-7b");
Assert.That(profile.Has(Capability.FUNCTION_CALLING), Is.True);
}
[Test]
public void TakingADeclarationAwayBringsTheBuiltInAnswerBack()
{
//
// This is what happens when an organization withdraws a configuration, or when somebody
// corrects their plugin and the plugins are reloaded. It is also the test that the kept
// answers are dropped along with the declarations they were worked out under: a cache which
// outlived them would go on answering with what a plugin said which is no longer there.
//
var registry = ModelRegistry.Build([new AcmeFamily()], []);
registry.Declare([Declaring("acme-assistant", MatchKind.PREFIX, Capability.TEXT_INPUT | Capability.TEXT_OUTPUT)]);
var whileDeclared = registry.Profile(LLMProviders.SELF_HOSTED, "acme-assistant-7b");
registry.Declare([]);
var afterwards = registry.Profile(LLMProviders.SELF_HOSTED, "acme-assistant-7b");
Assert.Multiple(() =>
{
Assert.That(whileDeclared.Has(Capability.FUNCTION_CALLING), Is.False);
Assert.That(afterwards.Has(Capability.FUNCTION_CALLING), Is.True);
});
}
[Test]
public void AmongTheDeclarationsTheOneSayingMoreAboutTheNameWins()
{
//
// Two plugins, or one plugin describing a family and then one of its variants. Nothing new
// is needed for this: the declarations go through the same engine as the built-in rules, so
// the specificity is computed here too and nobody writes an order.
//
var registry = ModelRegistry.Build([new AcmeFamily()], []);
registry.Declare(
[
Declaring("acme-assistant", MatchKind.PREFIX, Capability.TEXT_INPUT | Capability.TEXT_OUTPUT),
Declaring("acme-assistant-7b", MatchKind.EXACT, Capability.TEXT_INPUT | Capability.TEXT_OUTPUT | Capability.MULTIPLE_IMAGE_INPUT),
]);
Assert.Multiple(() =>
{
Assert.That(registry.Profile(LLMProviders.SELF_HOSTED, "acme-assistant-7b").Has(Capability.MULTIPLE_IMAGE_INPUT), Is.True);
Assert.That(registry.Profile(LLMProviders.SELF_HOSTED, "acme-assistant-3b").Has(Capability.MULTIPLE_IMAGE_INPUT), Is.False);
});
}
[Test]
public void ADeclarationIsMeasuredAgainstTheNameWithoutTheProvidersWrapping()
{
//
// An administrator writes the model's name, not the name plus whatever the gateway they
// reach it through puts in front of it. Unwrapping happens before anything is asked, so the
// same entry answers whichever way the model is reached.
//
var registry = ModelRegistry.Shared;
var declaration = Declaring("gpt-5.1", MatchKind.PREFIX, Capability.TEXT_INPUT | Capability.TEXT_OUTPUT | Capability.VIDEO_INPUT);
try
{
registry.Declare([declaration]);
Assert.Multiple(() =>
{
Assert.That(registry.Profile(LLMProviders.OPEN_AI, "gpt-5.1").Has(Capability.VIDEO_INPUT), Is.True);
Assert.That(registry.Profile(LLMProviders.OPEN_ROUTER, "openai/gpt-5.1").Has(Capability.VIDEO_INPUT), Is.True, "The same model, reached through a gateway which wraps the name.");
});
}
finally
{
//
// The registry the app uses is the only one which knows the hosts, so this test has to
// borrow it. Handing it back empty is what keeps the borrowing from reaching the tests
// which ask it about real models.
//
registry.Declare([]);
}
}
private static ModelDeclaration Declaring(string pattern, MatchKind matchKind, Capability capabilities) => new()
{
Pattern = new()
{
Kind = matchKind,
Text = pattern,
},
Change = new()
{
Adds = capabilities,
},
Source = new("https://intranet.invalid/ai", new DateOnly(2026, 9, 12), "What a company says about its own models."),
Origin = "Models of a company",
EnterpriseConfigurationPluginId = PLUGIN_ID,
};
/// <summary>
/// A family which says more about these models than the declarations of this test do.
/// </summary>
private sealed class AcmeFamily : ModelFamily
{
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
public override ModelSource Source => new("https://example.invalid/acme", new DateOnly(2026, 9, 12), "A family standing in for whatever AI Studio knows by itself.");
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("acme-assistant").AsPrefix()
.Capabilities(Capability.TEXT_INPUT | Capability.TEXT_OUTPUT | Capability.FUNCTION_CALLING)
.Apis(Capability.CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.ALWAYS);
}
}

View File

@ -0,0 +1,226 @@
using AIStudio.Models;
using AIStudio.Models.Matching;
using AIStudio.Models.Plugins;
using AIStudio.Provider;
using Lua;
using Lua.Standard;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Models.Plugins;
/// <summary>
/// Checks what AI Studio makes of a model an organization describes in a plugin of its own.
/// </summary>
/// <remarks>
/// The entries below are written the way they are written in a plugin.lua, and they are read
/// through a real Lua state rather than through a table put together in C#. What is being checked
/// is the wire format an administrator types, so anything between their file and the declaration
/// has to be part of the test.
/// </remarks>
[TestFixture]
public sealed class ModelDeclarationTests
{
private static readonly Guid PLUGIN_ID = new("11111111-1111-1111-1111-111111111111");
private const string ORIGIN = "Models of a company";
private const string A_COMPLETE_DECLARATION = """
["PATTERN"] = "acme-assistant",
["MATCH"] = "PREFIX",
["CAPABILITIES"] = { "TEXT_INPUT", "MULTIPLE_IMAGE_INPUT", "TEXT_OUTPUT", "FUNCTION_CALLING", "CHAT_COMPLETION_API" },
["REASONING"] = "ON_BY_DEFAULT",
["KIND"] = "CHAT",
["CONTEXT_WINDOW"] = 131072,
["CONTEXT_WINDOW_RAISABLE_TO"] = 262144,
["TOKENIZER_KIND"] = "HUGGING_FACE",
["TOKENIZER_ID"] = "acme/assistant",
["MAX_IMAGES_PER_MESSAGE"] = 1,
["MAX_IMAGES_PER_REQUEST"] = 8,
["SOURCE_URL"] = "https://intranet.invalid/ai/acme-assistant",
["SOURCE_CHECKED_ON"] = "2026-09-12",
["SOURCE_NOTE"] = "Internal model card: tools, images, 128k context",
""";
private const string THE_LEAST_A_DECLARATION_CAN_SAY = """
["PATTERN"] = "acme-assistant",
["CAPABILITIES"] = { "TEXT_INPUT", "TEXT_OUTPUT", "CHAT_COMPLETION_API" },
["SOURCE_URL"] = "https://intranet.invalid/ai/acme-assistant",
["SOURCE_CHECKED_ON"] = "2026-09-12",
""";
[Test]
public async Task ADeclarationIsReadTheWayItWasWritten()
{
var declaration = await ReadAsync(A_COMPLETE_DECLARATION);
Assert.That(declaration, Is.Not.Null);
Assert.Multiple(() =>
{
Assert.That(declaration!.Pattern.Text, Is.EqualTo("acme-assistant"));
Assert.That(declaration.Pattern.Kind, Is.EqualTo(MatchKind.PREFIX));
Assert.That(declaration.Change.Adds, Is.EqualTo(Capability.TEXT_INPUT | Capability.MULTIPLE_IMAGE_INPUT | Capability.TEXT_OUTPUT | Capability.FUNCTION_CALLING | Capability.CHAT_COMPLETION_API));
Assert.That(declaration.Change.Reasoning, Is.EqualTo(ReasoningSupport.ON_BY_DEFAULT));
Assert.That(declaration.Change.Kind, Is.EqualTo(ModelKind.CHAT));
Assert.That(declaration.Change.Context, Is.EqualTo(ContextWindow.Of(131_072, 262_144)));
Assert.That(declaration.Change.Tokenizer, Is.EqualTo(new TokenizerRef(TokenizerKind.HUGGING_FACE, "acme/assistant")));
Assert.That(declaration.Change.Images, Is.EqualTo(new ImageLimits(1, 8)));
Assert.That(declaration.Source.CheckedOn, Is.EqualTo(new DateOnly(2026, 9, 12)));
Assert.That(declaration.EnterpriseConfigurationPluginId, Is.EqualTo(PLUGIN_ID));
});
}
[Test]
public async Task WhatADeclarationLeavesOutIsTheSameAsWhatAFamilyLeavesOut()
{
var declaration = await ReadAsync(THE_LEAST_A_DECLARATION_CAN_SAY);
Assert.That(declaration, Is.Not.Null);
Assert.Multiple(() =>
{
Assert.That(declaration!.Pattern.Kind, Is.EqualTo(MatchKind.SEGMENT), "The kind to reach for by default, here as everywhere else.");
Assert.That(declaration.Change.Reasoning, Is.EqualTo(ReasoningSupport.NONE));
Assert.That(declaration.Change.Kind, Is.EqualTo(ModelKind.CHAT), "A model nobody said anything else about stays visible in the chat lists.");
Assert.That(declaration.Change.Context, Is.Null);
Assert.That(declaration.Change.Tokenizer, Is.Null);
Assert.That(declaration.Change.Images, Is.Null);
});
}
[Test]
public async Task ADeclarationWithoutCapabilitiesIsRefused()
{
//
// The one thing a declaration cannot leave out. It replaces what AI Studio would otherwise
// say about these models, so an entry naming only a context window would take away every
// capability the built-in rules knew -- and it would do so silently, because an entry which
// matches is an answer.
//
var declaration = await ReadAsync("""
["PATTERN"] = "acme-assistant",
["CONTEXT_WINDOW"] = 131072,
["SOURCE_URL"] = "https://intranet.invalid/ai/acme-assistant",
["SOURCE_CHECKED_ON"] = "2026-09-12",
""");
Assert.That(declaration, Is.Null);
}
[TestCase("""["PATTERN"] = "Acme-Assistant",""", TestName = "A pattern in capitals", Description = "Names arrive in lower case, so this could never match.")]
[TestCase("""["PATTERN"] = "acme_assistant",""", TestName = "A pattern with an underscore")]
[TestCase("""["PATTERN"] = "acme assistant",""", TestName = "A pattern with a space")]
[TestCase("""["PATTERN"] = "",""", TestName = "No pattern at all")]
public async Task APatternWhichCouldNeverMatchAnythingIsRefused(string pattern)
{
var declaration = await ReadAsync($$"""
{{pattern}}
["CAPABILITIES"] = { "TEXT_INPUT", "TEXT_OUTPUT" },
["SOURCE_URL"] = "https://intranet.invalid/ai/acme-assistant",
["SOURCE_CHECKED_ON"] = "2026-09-12",
""");
Assert.That(declaration, Is.Null, "A pattern which is not written the way a model name is written is a mistake, not a rule which happens to stay quiet.");
}
[TestCase("ALWAYS_REASONING")]
[TestCase("OPTIONAL_REASONING")]
[TestCase("REASONING_BY_DEFAULT")]
public async Task ReasoningStatedAsACapabilityIsRefusedRatherThanDropped(string reasoningWord)
{
//
// The three words 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. A profile drops them anyway, so accepting
// them would mean an administrator wrote something that never took effect.
//
var declaration = await ReadAsync($$"""
["PATTERN"] = "acme-assistant",
["CAPABILITIES"] = { "TEXT_INPUT", "TEXT_OUTPUT", "{{reasoningWord}}" },
["SOURCE_URL"] = "https://intranet.invalid/ai/acme-assistant",
["SOURCE_CHECKED_ON"] = "2026-09-12",
""");
Assert.That(declaration, Is.Null);
}
[TestCase("""["SOURCE_CHECKED_ON"] = "2026-09-12",""", TestName = "A page nobody named")]
[TestCase("""["SOURCE_URL"] = "https://intranet.invalid/ai",""", TestName = "A day nobody named")]
[TestCase("""["SOURCE_URL"] = "https://intranet.invalid/ai", ["SOURCE_CHECKED_ON"] = "12.09.2026",""", TestName = "A day written another way")]
public async Task ADeclarationHasToSayWhereItWasReadAndWhen(string source)
{
//
// The compiler asks a family in the source for this, and an organization's declaration
// outlives whoever wrote it just the same. Naming the page and the day is what lets the next
// administrator find out in a minute whether it still holds.
//
var declaration = await ReadAsync($$"""
["PATTERN"] = "acme-assistant",
["CAPABILITIES"] = { "TEXT_INPUT", "TEXT_OUTPUT" },
{{source}}
""");
Assert.That(declaration, Is.Null);
}
[TestCase("""["TOKENIZER_KIND"] = "HUGGING_FACE",""", TestName = "A tokenizer kind without an ID")]
[TestCase("""["TOKENIZER_ID"] = "acme/assistant",""", TestName = "A tokenizer ID without a kind")]
[TestCase("""["CONTEXT_WINDOW_RAISABLE_TO"] = 262144,""", TestName = "A ceiling without a window")]
[TestCase("""["CONTEXT_WINDOW"] = 262144, ["CONTEXT_WINDOW_RAISABLE_TO"] = 131072,""", TestName = "A ceiling below the window")]
[TestCase("""["CONTEXT_WINDOW"] = 0,""", TestName = "A window of no tokens")]
[TestCase("""["MAX_IMAGES_PER_REQUEST"] = -1,""", TestName = "Fewer than no images")]
[TestCase("""["KIND"] = "SOMETHING_ELSE",""", TestName = "A kind of model nobody knows")]
[TestCase("""["MATCH"] = "REGEX",""", TestName = "A way of matching which does not exist")]
[TestCase("""["ONLY_ON"] = "ACME_CLOUD",""", TestName = "A provider which does not exist")]
public async Task AnEntryWhichSaysSomethingUnreadableIsRefusedAsAWhole(string addition)
{
//
// Never 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 the unreadable half missing and
// nothing on screen saying so.
//
var declaration = await ReadAsync($"""
{THE_LEAST_A_DECLARATION_CAN_SAY}
{addition}
""");
Assert.That(declaration, Is.Null);
}
[Test]
public async Task TwoDeclarationsCollideExactlyWhenTheyClaimTheSameNames()
{
//
// What identifies a declaration is its pattern, because that is what a collision is here.
// Two of them claiming the same names would both enter the index and tie there, and a tie
// is something only a person can settle. Two about different names never meet.
//
var declaration = await ReadAsync(A_COMPLETE_DECLARATION);
var theSameNames = await ReadAsync(A_COMPLETE_DECLARATION.Replace("""["CONTEXT_WINDOW"] = 131072,""", """["CONTEXT_WINDOW"] = 65536,""", StringComparison.Ordinal));
var otherNames = await ReadAsync(A_COMPLETE_DECLARATION.Replace("""["MATCH"] = "PREFIX",""", """["MATCH"] = "SEGMENT",""", StringComparison.Ordinal));
Assert.Multiple(() =>
{
Assert.That(theSameNames?.Id, Is.EqualTo(declaration?.Id), "The same pattern, so one of the two has to win.");
Assert.That(otherNames?.Id, Is.Not.EqualTo(declaration?.Id), "Bound to the name differently, so they claim different sets of names.");
});
}
private static async Task<ModelDeclaration?> ReadAsync(string entry)
{
var state = LuaState.Create();
state.OpenBasicLibrary();
state.OpenTableLibrary();
await state.DoStringAsync($$"""
MODEL = {
{{entry}}
}
""");
if (!state.Environment["MODEL"].TryRead<LuaTable>(out var table))
throw new InvalidOperationException("The entry of this test is not a Lua table.");
return ModelDeclaration.TryParse(1, table, PLUGIN_ID, ORIGIN, NullLogger.Instance, out var declaration) ? declaration : null;
}
}

View File

@ -112,11 +112,4 @@ public sealed class PortingDifferenceTests
/// <param name="entry">The corpus entry to look up.</param> /// <param name="entry">The corpus entry to look up.</param>
/// <returns>True, when it stands in the list of models left to the default.</returns> /// <returns>True, when it stands in the list of models left to the default.</returns>
private static bool IsLeftToTheDefault(CorpusEntry entry) => LeftToTheDefault.ENTRIES.Any(left => left.Provider == entry.Provider && string.Equals(left.ModelId, entry.ModelId, StringComparison.Ordinal)); private static bool IsLeftToTheDefault(CorpusEntry entry) => LeftToTheDefault.ENTRIES.Any(left => left.Provider == entry.Provider && string.Equals(left.ModelId, entry.ModelId, StringComparison.Ordinal));
/// <summary>
/// Whether the audit found the current answer for this entry wrong.
/// </summary>
/// <param name="entry">The entry to look up.</param>
/// <returns>True, when the rebuild is meant to answer differently.</returns>
private static bool IsKnownToBeWrong(CorpusEntry entry) => ExpectedChanges.ENTRIES.Any(change => change.Provider == entry.Provider && change.ModelId == entry.ModelId);
} }