using System.Globalization; using System.Text; using System.Text.Json.Serialization; using AIStudio.Models; using AIStudio.Provider; using Lua; using LuaTable = Lua.LuaTable; namespace AIStudio.Settings; /// /// What a person stated about the model of their own provider instance, against what the rules /// worked out. Anything left unsaid keeps the automatic answer. /// /// /// The name says capabilities because that is all this could hold when it was written, and renaming /// it now would break every settings file and every rolled-out configuration which spells the word. /// What it holds is everything a person can say about the model behind their own provider: what it /// can do, how it reasons, how much it reads, and how many pictures it takes. /// /// The numbers carry the same key names a model plugin uses for the same questions, down to the /// spelling. The two surfaces answer different questions -- a plugin describes a model, this /// describes one installation of it -- but an administrator writing both should not have to learn /// two vocabularies to say the same thing twice. /// public sealed record ProviderCapabilityOverrides { /// /// How wide the window of this installation is, in tokens. /// private const string CONTEXT_WINDOW_KEY = "CONTEXT_WINDOW"; /// /// How many images one message may carry here. /// private const string MAX_IMAGES_PER_MESSAGE_KEY = "MAX_IMAGES_PER_MESSAGE"; /// /// How many images one request may carry here. /// private const string MAX_IMAGES_PER_REQUEST_KEY = "MAX_IMAGES_PER_REQUEST"; /// /// The keys which name a number rather than a capability. /// /// /// They share the table with the capability words, so the parser has to ask which sort of key /// it is looking at before it asks what the value should be: a number where a switch belongs is /// as wrong as a switch where a number belongs, and neither may quietly become the other. /// private static readonly IReadOnlyList NUMERIC_KEYS = [ CONTEXT_WINDOW_KEY, MAX_IMAGES_PER_MESSAGE_KEY, MAX_IMAGES_PER_REQUEST_KEY, ]; /// /// The capabilities a person switches on or off directly, without the reasoning words. /// /// /// How a model reasons is one answer out of four, not three flags which can contradict each /// other, so it is resolved on its own below. The three words stay in the list above because /// that is the vocabulary a settings file and a configuration plugin are written in. /// private static readonly IReadOnlyList DIRECTLY_SETTABLE_CAPABILITIES = [ Capability.AUDIO_INPUT, Capability.FUNCTION_CALLING, Capability.MULTIPLE_IMAGE_INPUT, Capability.SPEECH_INPUT, Capability.VIDEO_INPUT, ]; private static readonly IReadOnlyList SUPPORTED_CAPABILITIES = [ Capability.AUDIO_INPUT, Capability.FUNCTION_CALLING, Capability.MULTIPLE_IMAGE_INPUT, Capability.SPEECH_INPUT, Capability.VIDEO_INPUT, Capability.OPTIONAL_REASONING, Capability.ALWAYS_REASONING, Capability.REASONING_BY_DEFAULT ]; [JsonPropertyName("AUDIO_INPUT")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? AudioInput { get; init; } [JsonPropertyName("FUNCTION_CALLING")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? FunctionCalling { get; init; } [JsonPropertyName("MULTIPLE_IMAGE_INPUT")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? MultipleImageInput { get; init; } [JsonPropertyName("SPEECH_INPUT")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? SpeechInput { get; init; } [JsonPropertyName("VIDEO_INPUT")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? VideoInput { get; init; } [JsonPropertyName("OPTIONAL_REASONING")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? OptionalReasoning { get; init; } [JsonPropertyName("ALWAYS_REASONING")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? AlwaysReasoning { get; init; } [JsonPropertyName("REASONING_BY_DEFAULT")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? ReasoningByDefault { get; init; } /// /// How many tokens this installation reads and writes, or null to keep the automatic answer. /// /// /// One number, where the rules know two. What a model card calls "raisable to" is a statement /// about the model: somebody could configure the engine that way. A person filling this in has /// already configured it, or has not, and either way says what their installation does today. /// Stating a ceiling next to it would be describing a possibility they are the only one able to /// realize. /// [JsonPropertyName(CONTEXT_WINDOW_KEY)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? ContextWindowTokens { get; init; } /// /// How many images one message may carry, or null to keep the automatic answer. /// [JsonPropertyName(MAX_IMAGES_PER_MESSAGE_KEY)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? MaxImagesPerMessage { get; init; } /// /// How many images one request may carry, or null to keep the automatic answer. /// [JsonPropertyName(MAX_IMAGES_PER_REQUEST_KEY)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? MaxImagesPerRequest { get; init; } [JsonIgnore] public bool HasOverrides => this.AudioInput is not null || this.FunctionCalling is not null || this.MultipleImageInput is not null || this.SpeechInput is not null || this.VideoInput is not null || this.OptionalReasoning is not null || this.AlwaysReasoning is not null || this.ReasoningByDefault is not null || this.ContextWindowTokens is not null || this.MaxImagesPerMessage is not null || this.MaxImagesPerRequest is not null; public bool? GetOverride(Capability capability) => capability switch { Capability.AUDIO_INPUT => this.AudioInput, Capability.FUNCTION_CALLING => this.FunctionCalling, Capability.MULTIPLE_IMAGE_INPUT => this.MultipleImageInput, Capability.SPEECH_INPUT => this.SpeechInput, Capability.VIDEO_INPUT => this.VideoInput, Capability.OPTIONAL_REASONING => this.OptionalReasoning, Capability.ALWAYS_REASONING => this.AlwaysReasoning, Capability.REASONING_BY_DEFAULT => this.ReasoningByDefault, _ => null }; public ProviderCapabilityOverrides SetOverride(Capability capability, bool? value) => capability switch { Capability.AUDIO_INPUT => this with { AudioInput = value }, Capability.FUNCTION_CALLING => this with { FunctionCalling = value }, Capability.MULTIPLE_IMAGE_INPUT => this with { MultipleImageInput = value }, Capability.SPEECH_INPUT => this with { SpeechInput = value }, Capability.VIDEO_INPUT => this with { VideoInput = value }, Capability.OPTIONAL_REASONING => this with { OptionalReasoning = value }, Capability.ALWAYS_REASONING => this with { AlwaysReasoning = value }, Capability.REASONING_BY_DEFAULT => this with { ReasoningByDefault = value }, _ => this }; /// /// Reads the number a key stands for. /// /// One of the numeric keys. /// The number, or null when nobody stated it. private int? GetNumber(string key) => key switch { CONTEXT_WINDOW_KEY => this.ContextWindowTokens, MAX_IMAGES_PER_MESSAGE_KEY => this.MaxImagesPerMessage, MAX_IMAGES_PER_REQUEST_KEY => this.MaxImagesPerRequest, _ => null, }; /// /// States the number a key stands for. /// /// One of the numeric keys. /// The number, or null to keep the automatic answer. /// The overrides with that number in them. private ProviderCapabilityOverrides SetNumber(string key, int? value) => key switch { CONTEXT_WINDOW_KEY => this with { ContextWindowTokens = value }, MAX_IMAGES_PER_MESSAGE_KEY => this with { MaxImagesPerMessage = value }, MAX_IMAGES_PER_REQUEST_KEY => this with { MaxImagesPerRequest = value }, _ => this, }; /// /// Applies what a person said about their own installation to what the rules worked out. /// /// /// The topmost link of the chain: an explicit statement about one's own provider wins over /// everything the rules could know, because the person can see the installation and the rules /// cannot. /// /// What the rules worked out. /// The profile as this provider instance was told it is. public ModelProfile ApplyTo(in ModelProfile profile) => profile with { Capabilities = this.ApplyToCapabilities(profile.Capabilities), Reasoning = this.ResolveReasoning(profile.Reasoning), Context = this.ResolveContext(profile.Context), Images = this.ResolveImages(profile.Images), }; /// /// Works out how wide the window is, out of what the rules say and what a person said. /// /// /// A stated number replaces the window whole, the ceiling included. Keeping "raisable to /// 131,072" next to a person's own 16,384 would be reporting a possibility as a property of /// their installation, and whoever reads that number is asking what fits, not what could be /// made to fit. /// /// A number which is not a width at all is ignored rather than repaired. Both places a person /// can write one refuse it with a message, so one arriving here came out of a settings file /// somebody edited by hand, and the honest answer to that is the one nobody made up. /// /// What the rules worked out. /// The window after the overrides. private ContextWindow ResolveContext(ContextWindow stated) => this.ContextWindowTokens is { } tokens and > 0 ? ContextWindow.Of(tokens) : stated; /// /// Works out how many images fit, out of what the rules say and what a person said. /// /// /// Each of the two numbers stands for itself, the way each switch above does: stating one says /// nothing about the other, and the one left unsaid keeps whatever the rules worked out. The /// smaller of the two still decides what fits into a message, so a person who states the larger /// number alone may well see no change -- which is the correct answer, not a bug: they have not /// contradicted the limit that is actually in the way. /// /// What the rules worked out. /// The limits after the overrides. private ImageLimits ResolveImages(ImageLimits stated) => new(CountOfImages(this.MaxImagesPerMessage) ?? stated.MaxPerMessage, CountOfImages(this.MaxImagesPerRequest) ?? stated.MaxPerRequest); /// /// Takes a stated image limit, where it is one. /// /// /// Zero is a real limit here: an engine can be configured to take no pictures at all. A /// negative number is not a limit at all, and is ignored for the same reason a window of zero /// tokens is. /// /// What was stated. /// The limit, or null when nothing usable was stated. private static int? CountOfImages(int? limit) => limit >= 0 ? limit : null; /// /// Switches the plain capabilities on and off. /// /// What the rules worked out. /// The capabilities after the overrides. private Capability ApplyToCapabilities(Capability stated) { var capabilities = stated; foreach (var capability in DIRECTLY_SETTABLE_CAPABILITIES) switch (this.GetOverride(capability)) { case true: capabilities |= capability; break; case false: capabilities &= ~capability; break; } return capabilities; } /// /// Works out how a model reasons, out of what the rules say and what a person said. /// /// /// This replaced thirty lines which repaired states that cannot exist -- a model both always /// reasoning and reasoning on request -- by an answer which cannot be in two of them at once. /// The expert dialog writes all three words together, and every combination it produces means /// exactly what it meant before. /// /// One thing did change, and it is a defect going away. A word nobody said anything about used /// to destroy the answer: a provider carrying any override at all, say tool calling turned off, /// lost "reasoning on by default" on the way through, because the repair took the word away /// unless "reasoning on request" stood next to it -- which no rule ever states. Here a "no" only /// takes away what it names. /// /// How the rules say the model reasons. /// How it reasons after the overrides. private ReasoningSupport ResolveReasoning(ReasoningSupport stated) { // A "yes" is the whole answer, whatever else is written next to it: if (this.AlwaysReasoning is true) return ReasoningSupport.ALWAYS; if (this.ReasoningByDefault is true) return ReasoningSupport.ON_BY_DEFAULT; if (this.OptionalReasoning is true) return ReasoningSupport.OPTIONAL; // A "no" only contradicts the state it names: return stated switch { ReasoningSupport.ALWAYS => this.AlwaysReasoning is false ? ReasoningSupport.NONE : ReasoningSupport.ALWAYS, ReasoningSupport.ON_BY_DEFAULT => this.ReasoningByDefault is false || this.OptionalReasoning is false ? ReasoningSupport.NONE : ReasoningSupport.ON_BY_DEFAULT, ReasoningSupport.OPTIONAL => this.OptionalReasoning is false ? ReasoningSupport.NONE : ReasoningSupport.OPTIONAL, _ => ReasoningSupport.NONE, }; } public string ExportAsLuaTable(string indentation) { if (!this.HasOverrides) return string.Empty; var builder = new StringBuilder(); builder.AppendLine($@"{indentation}[""CapabilityOverrides""] = {{"); foreach (var capability in SUPPORTED_CAPABILITIES) { var overrideValue = this.GetOverride(capability); if (overrideValue is null) continue; builder.AppendLine($@"{indentation} [""{capability}""] = {overrideValue.Value.ToString().ToLowerInvariant()},"); } foreach (var key in NUMERIC_KEYS) { if (this.GetNumber(key) is not { } number) continue; builder.AppendLine($@"{indentation} [""{key}""] = {number.ToString(CultureInfo.InvariantCulture)},"); } builder.Append($@"{indentation}}},"); return builder.ToString(); } public static ProviderCapabilityOverrides? TryParseFromLuaTable(int idx, LuaTable providerTable, Guid configPluginId, ILogger logger) { if (!providerTable.TryGetValue("CapabilityOverrides", out var capabilityOverridesValue)) return null; if (capabilityOverridesValue.Type is not LuaValueType.Table || !capabilityOverridesValue.TryRead(out var capabilityOverridesTable)) { logger.LogWarning("The configured provider {ProviderIndex} contains an invalid CapabilityOverrides table. Automatic capability detection will be used instead. (Plugin ID: {PluginId})", idx, configPluginId); return null; } var result = new ProviderCapabilityOverrides(); var previousKey = LuaValue.Nil; while (capabilityOverridesTable.TryGetNext(previousKey, out var pair)) { previousKey = pair.Key; if (!pair.Key.TryRead(out var keyText)) { logger.LogWarning("The configured provider {ProviderIndex} contains a CapabilityOverrides entry with a non-string key. The entry will be ignored. (Plugin ID: {PluginId})", idx, configPluginId); continue; } if (TryMatchNumericKey(keyText, out var numericKey)) { if (!TryReadNumber(pair.Value, numericKey, out var number)) { logger.LogWarning("The configured provider {ProviderIndex} states a '{OverrideKey}' which is not {Expectation}. The automatic answer will be used for it. (Plugin ID: {PluginId})", idx, numericKey, ExpectationOf(numericKey), configPluginId); continue; } result = result.SetNumber(numericKey, number); continue; } if (!TryParseSupportedCapability(keyText, out var capability)) { logger.LogWarning("The configured provider {ProviderIndex} contains an unsupported override '{OverrideKey}'. The entry will be ignored. (Plugin ID: {PluginId})", idx, keyText, configPluginId); continue; } if (!pair.Value.TryRead(out var overrideValue)) { logger.LogWarning("The configured provider {ProviderIndex} contains a non-boolean capability override for '{CapabilityKey}'. Automatic capability detection will be used for that capability. (Plugin ID: {PluginId})", idx, keyText, configPluginId); continue; } result = result.SetOverride(capability, overrideValue); } return result.HasOverrides ? result : null; } /// /// Recognizes a key which names a number, whichever way it was spelled. /// /// /// Spelled loosely for the same reason the capability words are: a table written by hand is /// read by the app, not by a compiler, and rejecting "context_window" over its letters would be /// a riddle rather than a message. What comes back is the canonical spelling, so everything /// after this point deals with one name per question. /// /// The key as it was written. /// The canonical spelling of that key. /// True when the key names a number. private static bool TryMatchNumericKey(string key, out string numericKey) { foreach (var candidate in NUMERIC_KEYS) if (string.Equals(candidate, key, StringComparison.OrdinalIgnoreCase)) { numericKey = candidate; return true; } numericKey = string.Empty; return false; } /// /// Reads a number, where it is one this key accepts. /// /// /// A window has to be a width, so zero token is refused: nothing fits into it, and a provider /// which can hold nothing is not what anybody meant to state. A picture count of zero is a /// different matter and allowed because an engine really can be told to take no pictures. /// /// The value as it stands in the table. /// The canonical key it stands under. /// The number read. /// True, when the value is a number, this key accepts. private static bool TryReadNumber(LuaValue value, string numericKey, out int number) { if (!value.TryRead(out number)) return false; return numericKey is CONTEXT_WINDOW_KEY ? number > 0 : number >= 0; } /// /// What a key accepts, said in the words of a warning. /// /// The canonical key. /// The expectation. private static string ExpectationOf(string numericKey) => numericKey is CONTEXT_WINDOW_KEY ? "a number of tokens greater than zero" : "a number of images of zero or more"; private static bool TryParseSupportedCapability(string capabilityKey, out Capability capability) { capability = Capability.NONE; if (!Enum.TryParse(capabilityKey, true, out capability)) return false; return SUPPORTED_CAPABILITIES.Contains(capability); } }