mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-27 02:33:38 +00:00
Let organizations describe their own models
This commit is contained in:
parent
f4078856d1
commit
6d80e6de8a
@ -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"
|
||||||
|
|
||||||
|
|||||||
404
app/MindWork AI Studio/Models/Plugins/ModelDeclaration.cs
Normal file
404
app/MindWork AI Studio/Models/Plugins/ModelDeclaration.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
186
app/MindWork AI Studio/Plugins/models/plugin.lua
Normal file
186
app/MindWork AI Studio/Plugins/models/plugin.lua
Normal 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",
|
||||||
|
-- }
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -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>();
|
||||||
|
|||||||
@ -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.");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
72
app/MindWork AI Studio/Tools/PluginSystem/PluginModels.cs
Normal file
72
app/MindWork AI Studio/Tools/PluginSystem/PluginModels.cs
Normal 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;
|
||||||
|
}
|
||||||
@ -8,4 +8,5 @@ public enum PluginType
|
|||||||
ASSISTANT,
|
ASSISTANT,
|
||||||
CONFIGURATION,
|
CONFIGURATION,
|
||||||
THEME,
|
THEME,
|
||||||
|
MODEL,
|
||||||
}
|
}
|
||||||
@ -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",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
180
app/Tests/Models/Plugins/DeclaredModelsTests.cs
Normal file
180
app/Tests/Models/Plugins/DeclaredModelsTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
226
app/Tests/Models/Plugins/ModelDeclarationTests.cs
Normal file
226
app/Tests/Models/Plugins/ModelDeclarationTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user