diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 1a8ff5a7..026c8fd1 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -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. 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 UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T1142248183"] = "Artists" @@ -11464,6 +11467,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T62 -- 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 UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTYPEEXTENSIONS::T1682350097"] = "Theme plugin" diff --git a/app/MindWork AI Studio/Models/Plugins/ModelDeclaration.cs b/app/MindWork AI Studio/Models/Plugins/ModelDeclaration.cs new file mode 100644 index 00000000..aad9d925 --- /dev/null +++ b/app/MindWork AI Studio/Models/Plugins/ModelDeclaration.cs @@ -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; + +/// +/// What an organization states about a set of model names, read from one of its model plugins. +/// +/// +/// 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. +/// +public sealed record ModelDeclaration : ILivePluginContent +{ + /// + /// Which model names this declaration answers for. + /// + public required MatchPattern Pattern { get; init; } + + /// + /// What it states about them. + /// + public required ModelProfileChange Change { get; init; } + + /// + /// Where that was read, and when somebody last looked. + /// + public required ModelSource Source { get; init; } + + /// + /// What the rule built from this declaration names as its origin, so a conflict can name both sides. + /// + public required string Origin { get; init; } + + /// + public Guid EnterpriseConfigurationPluginId { get; init; } + + /// + /// What identifies this declaration when two plugins collide. + /// + /// + /// 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. + /// + public string Id => this.Pattern.Signature(); + + /// + /// Turns the declaration into a rule of the matching engine. + /// + /// + /// 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. + /// + /// The rule. + public ModelRule ToRule() => new(this.Pattern, ModelRuleKind.SELECTOR, this.Change, this.Origin); + + /// + /// Reads one entry of a model plugin's MODELS table. + /// + /// + /// 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. + /// + /// Which entry of the table this is, so a warning can name it. + /// The entry. + /// The plugin which declared it. + /// What the resulting rule names as its origin. + /// Where to report what could not be read. + /// The declaration, when the entry could be read. + /// True, when the entry could be read. + 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(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(table, "ONLY_ON", index, pluginId, logger, out var onlyOn)) + return false; + + if (!TryReadOptionalEnum(table, "ONLY_FROM", index, pluginId, logger, out var onlyFrom)) + return false; + + if (!TryReadCapabilities(table, index, pluginId, logger, out var capabilities)) + return false; + + if (!TryReadEnum(table, "REASONING", index, pluginId, logger, out var reasoning, ReasoningSupport.NONE)) + return false; + + if (!TryReadEnum(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(out var read)) + return false; + + text = read; + return !string.IsNullOrWhiteSpace(text); + } + + /// + /// Reads a key which names one member of an enum, falling back to a default when it is absent. + /// + /// + /// 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. + /// + private static bool TryReadEnum(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(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()), pluginId); + return false; + } + + private static bool TryReadOptionalEnum(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(out var text) && Enum.TryParse(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()), 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(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(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; + } + + /// + /// Reads the capabilities, which every declaration has to state. + /// + /// + /// 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. + /// + 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(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(out var capabilityText) || !Enum.TryParse(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()), 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(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(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(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; + } + + /// + /// Reads one of the two image limits. + /// + /// + /// 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. + /// + 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(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; + } + + /// + /// Reads where the declaration was read from, which it has to name. + /// + /// + /// 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. + /// + 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; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Models/Registry/ModelRegistry.cs b/app/MindWork AI Studio/Models/Registry/ModelRegistry.cs index da9a2bba..ce667992 100644 --- a/app/MindWork AI Studio/Models/Registry/ModelRegistry.cs +++ b/app/MindWork AI Studio/Models/Registry/ModelRegistry.cs @@ -3,6 +3,7 @@ using System.Collections.Frozen; using AIStudio.Models.Hosting; using AIStudio.Models.Matching; +using AIStudio.Models.Plugins; using AIStudio.Provider; namespace AIStudio.Models.Registry; @@ -11,11 +12,12 @@ namespace AIStudio.Models.Registry; /// Everything the app knows about models, as one question with one answer. /// /// -/// Four things happen to a name here, and the order they happen in is the whole design. The host +/// A few things happen to a name here, and the order they happen in is the whole design. The host /// takes off whatever wrapping the provider put around the name, so that a rule can be written once -/// instead of once per provider. The rules answer the bare name, and the most specific of them -/// wins, computed rather than written down. The family which won may then work something out of the -/// name that no rule can express. And the host says what the way there took away. +/// instead of once per provider. What an organization declared about its own models answers first, +/// where it says anything. Otherwise the built-in rules answer the bare name, and the most specific +/// of them wins, computed rather than written down. The family which won may then work something +/// out of the name that no rule can express. And the host says what the way there took away. /// /// Nothing in here reaches for application state, so a test can build a registry and ask it /// questions without the app ever having started. @@ -34,16 +36,14 @@ public sealed class ModelRegistry private readonly FrozenDictionary familiesByName; /// - /// 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. /// /// - /// This is the reason the whole rebuild is worth doing at all. The question is asked from - /// components which re-render on every streamed chunk, and the expert dialog asks it about a - /// dozen times per render. A profile cannot be changed after it was built, so handing the same - /// one to every caller is safe -- unlike the old code, which handed out a list and had one - /// caller quietly change it. + /// The two belong together and are therefore replaced together. A cache which outlived the + /// declarations it was filled under would keep handing out what the rules said before a + /// plugin arrived, and a reader holding one half of a swap would mix the two. /// - private readonly ConcurrentDictionary<(LLMProviders Provider, string ModelId), ModelProfile> answered = new(); + private volatile Answers answers = new(null); private ModelRegistry(IReadOnlyList families, ModelFamilyIndex rules, ModelHostIndex hosts, FrozenDictionary familiesByName) { @@ -73,6 +73,29 @@ public sealed class ModelRegistry /// public ModelHostIndex Hosts { get; } + /// + /// The rules the running model plugins declare, in the order the index holds them. + /// + public IReadOnlyList Declared => this.answers.Declared?.Rules ?? []; + + /// + /// Takes over what the model plugins declare, replacing whatever they declared before. + /// + /// + /// 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. + /// + /// What the plugins declare, with each pattern claimed by one of them. + public void Declare(IReadOnlyList declarations) + { + this.answers = new(declarations.Count is 0 ? null : ModelFamilyIndex.Build(declarations.Select(declaration => declaration.ToRule()))); + } + /// /// Builds a registry over a set of families and hosts. /// @@ -112,7 +135,12 @@ public sealed class ModelRegistry if (NothingCanBeSaid(provider, modelId)) return ModelProfile.UNKNOWN; - return this.answered.GetOrAdd((provider, modelId), static (key, registry) => registry.Explain(key.Provider, key.ModelId).Profile, this); + // + // Read once, then used throughout: the plugins may be reloaded while this is running, and + // an answer worked out from one set of declarations belongs in the cache of that same set. + // + var current = this.answers; + return current.Cached.GetOrAdd((provider, modelId), static (key, state) => state.Registry.Explain(key.Provider, key.ModelId, state.Answers).Profile, (Registry: this, Answers: current)); } /// @@ -126,14 +154,39 @@ public sealed class ModelRegistry /// Who serves the model. /// The model ID exactly as that provider reports it. /// The resolution, including the profile as the provider serves it. - public ModelResolution Explain(LLMProviders provider, string modelId) + public ModelResolution Explain(LLMProviders provider, string modelId) => this.Explain(provider, modelId, this.answers); + + /// + /// Says what is known about a model, against one particular set of plugin declarations. + /// + /// Who serves the model. + /// The model ID exactly as that provider reports it. + /// The declarations to answer against, and the cache belonging to them. + /// The resolution, including the profile as the provider serves it. + private ModelResolution Explain(LLMProviders provider, string modelId, Answers current) { if (NothingCanBeSaid(provider, modelId)) return ModelResolution.NOTHING; var id = new ModelId(modelId); var bare = this.Hosts.Unwrap(id, provider, out var declaredVendor); - var resolution = this.Rules.Explain(bare, provider, declaredVendor ?? ModelVendor.UNKNOWN); + var vendor = declaredVendor ?? ModelVendor.UNKNOWN; + + // + // What an organization declared about a model comes before what the built-in rules work out + // of its name, and it comes instead of it rather than on top of it: a declaration is the + // whole statement about the models it matches. Letting the built-in rules add to it would + // mean a modifier nobody was thinking about could overrule what an organization stated -- + // "guard" would still turn their own chat model into a moderation model. + // + // What stays is the transport, because that is not a statement about the model at all: a + // gateway which cannot pass an API through does not pass it through, whoever describes the + // model behind it. + // + if (current.Declared?.Explain(bare, provider, vendor) is { IsKnown: true } declared) + return declared with { Profile = this.Hosts.ApplyTransport(declared.Profile, provider) }; + + var resolution = this.Rules.Explain(bare, provider, vendor); // // Only the family which chose the model refines it. A modifier adjusts an answer; it does @@ -162,4 +215,31 @@ public sealed class ModelRegistry /// The rule which chose the model. /// The family, or nothing when no rule chose. private ModelFamily? FamilyOf(ModelRule? selector) => selector is null ? null : this.familiesByName.GetValueOrDefault(selector.Origin); + + /// + /// What the registry answers with, and what it has answered so far. + /// + /// + /// 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. + /// + /// What the running model plugins declare, or null when they declare nothing. + private sealed class Answers(ModelFamilyIndex? declared) + { + /// + /// What the running model plugins declare. + /// + public ModelFamilyIndex? Declared { get; } = declared; + + /// + /// The answers already worked out, so that a name is measured against the rules once. + /// + public ConcurrentDictionary<(LLMProviders Provider, string ModelId), ModelProfile> Cached { get; } = new(); + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Pages/Plugins.razor b/app/MindWork AI Studio/Pages/Plugins.razor index 65223482..62204c14 100644 --- a/app/MindWork AI Studio/Pages/Plugins.razor +++ b/app/MindWork AI Studio/Pages/Plugins.razor @@ -95,7 +95,8 @@ } - @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 activationSwitchDisabled = this.IsActivationSwitchDisabled(context, isEnabled); diff --git a/app/MindWork AI Studio/Plugins/models/plugin.lua b/app/MindWork AI Studio/Plugins/models/plugin.lua new file mode 100644 index 00000000..82f691ae --- /dev/null +++ b/app/MindWork AI Studio/Plugins/models/plugin.lua @@ -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 = " - Models of " + +-- The description of the plugin: +DESCRIPTION = "Describes the models 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 = {""} + +-- The support contact for the plugin: +SUPPORT_CONTACT = "" + +-- The source URL for the plugin. Can be a HTTP(S) URL or a mailto link: +SOURCE_URL = "" + +-- 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", +-- } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/ILivePluginContentSource.cs b/app/MindWork AI Studio/Tools/PluginSystem/ILivePluginContentSource.cs new file mode 100644 index 00000000..98ef2a08 --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/ILivePluginContentSource.cs @@ -0,0 +1,19 @@ +namespace AIStudio.Tools.PluginSystem; + +/// +/// A plugin which contributes live content, and therefore takes part in deciding a collision. +/// +/// +/// 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. +/// +public interface ILivePluginContentSource +{ + /// + /// The priority this plugin declares. Zero when it declares none. + /// + public int Priority { get; } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index 48231102..f253116f 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -9,7 +9,7 @@ using Lua; 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 SettingsManager SettingsManagerAccess => Program.SERVICE_PROVIDER.GetRequiredService(); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs index ef6765fd..b7cfb431 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs @@ -427,7 +427,12 @@ public static partial class PluginFactory var assistantPlugin = new PluginAssistants(isInternal, state, type); assistantPlugin.TryLoad(); return assistantPlugin; - + + case PluginType.MODEL: + var modelPlugin = new PluginModels(isInternal, state, type); + modelPlugin.TryLoad(); + return modelPlugin; + default: return new NoPlugin("This plugin type is not supported yet. Please try again with a future version of AI Studio."); } diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Remove.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Remove.cs index 68620b57..b63809f3 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Remove.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Remove.cs @@ -1,3 +1,5 @@ +using AIStudio.Models.Registry; + namespace AIStudio.Tools.PluginSystem; 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 // several plugins: // + var unloadedAModelPlugin = false; foreach (var plugin in AVAILABLE_PLUGINS.Where(plugin => IsPathInside(configurationDirectory, plugin.LocalPath)).ToList()) { AVAILABLE_PLUGINS.Remove(plugin); @@ -71,6 +74,7 @@ public static partial class PluginFactory if (RUNNING_PLUGINS.FirstOrDefault(runningPlugin => runningPlugin.Id == plugin.Id) is { } runningPluginToRemove) { RUNNING_PLUGINS.Remove(runningPluginToRemove); + unloadedAModelPlugin |= runningPluginToRemove is PluginModels; // The plugin is unloaded, so its Lua runtime is of no use anymore: 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); } + // + // 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)) return; diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Starting.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Starting.cs index 865b001c..e38a270f 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Starting.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Starting.cs @@ -1,4 +1,5 @@ using System.Text; +using AIStudio.Models.Registry; using AIStudio.Settings; using AIStudio.Settings.DataModel; using AIStudio.Tools.PluginSystem.Assistants; @@ -92,7 +93,13 @@ public static partial class PluginFactory 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 (plugin is PluginConfiguration configPlugin) @@ -108,7 +115,15 @@ public static partial class PluginFactory } 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: await MessageBus.INSTANCE.SendMessage(null, Event.PLUGINS_RELOADED); return configObjects; diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs index a955a566..afd07fc5 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs @@ -1,3 +1,4 @@ +using AIStudio.Models.Plugins; using AIStudio.Settings; using AIStudio.Settings.DataModel; @@ -437,37 +438,53 @@ public static partial class PluginFactory public static IReadOnlyList GetMandatoryInfos() { - return ResolveLivePluginContent("mandatory info", plugin => plugin.MandatoryInfos).ToList(); + return ResolveLivePluginContent("mandatory info", plugin => plugin.MandatoryInfos).ToList(); } public static IReadOnlyList GetIntroductions() { - return ResolveLivePluginContent("introduction", plugin => plugin.Introductions) + return ResolveLivePluginContent("introduction", plugin => plugin.Introductions) .OrderBy(introduction => introduction.Index) .ThenBy(introduction => introduction.Id, StringComparer.Ordinal) .ToList(); } /// - /// 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. /// /// - /// The IDs of live content are chosen by whoever writes the configuration, so two configuration - /// plugins may use the same ID. We resolve such a collision the same way a collision on a setting - /// is resolved: a configuration which acts on behalf of the organization wins, so nobody can push - /// aside what an organization deployed. Among configurations of the same origin, the declared - /// priority decides, and when even that is equal, the plugin which started later wins.

+ /// A declaration is identified by its pattern, so two plugins claiming exactly the same model + /// names are a collision like any other and are settled the same way. Two plugins describing + /// different models never meet, and both are heard. + ///
+ /// The declarations of all model plugins, with every pattern resolved to one winner. + public static IReadOnlyList GetModelDeclarations() + { + return ResolveLivePluginContent("model declaration", plugin => plugin.Declarations).ToList(); + } + + /// + /// Collects live content from all running plugins of one kind, so that each content ID appears exactly once. + /// + /// + /// 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.

/// 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. ///
/// The kind of content, used to report a collision in the log. - /// Selects the content of one configuration plugin. + /// Selects the content of one plugin. + /// The kind of plugin providing the content. /// The type of the live plugin content. - /// The content of all configuration plugins, with every ID resolved to one winner. - private static IEnumerable ResolveLivePluginContent(string contentKind, Func> selector) where T : ILivePluginContent + /// The content of all those plugins, with every ID resolved to one winner. + private static IEnumerable ResolveLivePluginContent(string contentKind, Func> selector) where TPlugin : PluginBase, ILivePluginContentSource where T : ILivePluginContent { var contentById = new Dictionary(StringComparer.Ordinal); - foreach (var plugin in RUNNING_PLUGINS.OfType()) + foreach (var plugin in RUNNING_PLUGINS.OfType()) { var authority = GetConfigurationAuthority(plugin.PluginPath); foreach (var content in selector(plugin)) @@ -484,14 +501,14 @@ public static partial class PluginFactory var ignoredPluginId = isTakingOver ? currentWinner.Content.EnterpriseConfigurationPluginId : content.EnterpriseConfigurationPluginId; 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 { var reason = isTakingOver ? DescribeConfigurationPrecedence(authority, plugin.Priority, currentWinner.Authority, currentWinner.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) @@ -506,7 +523,7 @@ public static partial class PluginFactory } /// - /// 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. /// /// /// 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) { 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) 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"; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginModels.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginModels.cs new file mode 100644 index 00000000..cc1d4033 --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginModels.cs @@ -0,0 +1,72 @@ +using AIStudio.Models.Plugins; + +using Lua; + +namespace AIStudio.Tools.PluginSystem; + +/// +/// A plugin which tells AI Studio about models it does not know, or knows wrongly. +/// +/// +/// 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. +/// +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 declarations = []; + + /// + /// The models this plugin declares. + /// + public IReadOnlyList Declarations => this.declarations; + + /// + public int Priority { get; } = ReadPriority(state); + + /// + /// Reads the MODELS table of the plugin. + /// + /// + /// 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. + /// + public void TryLoad() + { + if (!this.State.Environment["MODELS"].TryRead(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(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(out var priority) ? priority : 0; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginType.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginType.cs index 5730e62f..6afd73b5 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginType.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginType.cs @@ -8,4 +8,5 @@ public enum PluginType ASSISTANT, CONFIGURATION, THEME, + MODEL, } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginTypeExtensions.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginTypeExtensions.cs index b855a144..6b7d2104 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginTypeExtensions.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginTypeExtensions.cs @@ -10,7 +10,8 @@ public static class PluginTypeExtensions PluginType.ASSISTANT => TB("Assistant plugin"), PluginType.CONFIGURATION => TB("Configuration plugin"), PluginType.THEME => TB("Theme plugin"), - + PluginType.MODEL => TB("Model plugin"), + _ => TB("Unknown plugin type"), }; @@ -20,7 +21,8 @@ public static class PluginTypeExtensions PluginType.ASSISTANT => "assistants", PluginType.CONFIGURATION => "configurations", PluginType.THEME => "themes", - + PluginType.MODEL => "models", + _ => "unknown", }; } \ No newline at end of file diff --git a/app/Tests/Models/Corpus/ModelKindCorpus.cs b/app/Tests/Models/Corpus/ModelKindCorpus.cs index 0ed736f3..4b30075f 100644 --- a/app/Tests/Models/Corpus/ModelKindCorpus.cs +++ b/app/Tests/Models/Corpus/ModelKindCorpus.cs @@ -1,5 +1,3 @@ -using AIStudio.Provider; - using static AIStudio.Provider.LLMProviders; using static AIStudio.Provider.ModelKind; diff --git a/app/Tests/Models/Plugins/DeclaredModelsTests.cs b/app/Tests/Models/Plugins/DeclaredModelsTests.cs new file mode 100644 index 00000000..7faa3643 --- /dev/null +++ b/app/Tests/Models/Plugins/DeclaredModelsTests.cs @@ -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; + +/// +/// Checks where what an organization declares stands against what AI Studio works out itself. +/// +/// +/// 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. +/// +[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, + }; + + /// + /// A family which says more about these models than the declarations of this test do. + /// + 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); + } +} \ No newline at end of file diff --git a/app/Tests/Models/Plugins/ModelDeclarationTests.cs b/app/Tests/Models/Plugins/ModelDeclarationTests.cs new file mode 100644 index 00000000..361ccc8a --- /dev/null +++ b/app/Tests/Models/Plugins/ModelDeclarationTests.cs @@ -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; + +/// +/// Checks what AI Studio makes of a model an organization describes in a plugin of its own. +/// +/// +/// 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. +/// +[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 ReadAsync(string entry) + { + var state = LuaState.Create(); + state.OpenBasicLibrary(); + state.OpenTableLibrary(); + + await state.DoStringAsync($$""" + MODEL = { + {{entry}} + } + """); + + if (!state.Environment["MODEL"].TryRead(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; + } +} \ No newline at end of file diff --git a/app/Tests/Models/PortingDifferenceTests.cs b/app/Tests/Models/PortingDifferenceTests.cs index bb071954..05b60a63 100644 --- a/app/Tests/Models/PortingDifferenceTests.cs +++ b/app/Tests/Models/PortingDifferenceTests.cs @@ -112,11 +112,4 @@ public sealed class PortingDifferenceTests /// The corpus entry to look up. /// True, when it stands in the list of models left to the default. private static bool IsLeftToTheDefault(CorpusEntry entry) => LeftToTheDefault.ENTRIES.Any(left => left.Provider == entry.Provider && string.Equals(left.ModelId, entry.ModelId, StringComparison.Ordinal)); - - /// - /// Whether the audit found the current answer for this entry wrong. - /// - /// The entry to look up. - /// True, when the rebuild is meant to answer differently. - private static bool IsKnownToBeWrong(CorpusEntry entry) => ExpectedChanges.ENTRIES.Any(change => change.Provider == entry.Provider && change.ModelId == entry.ModelId); } \ No newline at end of file