2026-09-12 20:50:38 +02:00
using System.Globalization ;
2026-07-05 12:42:55 +02:00
using System.Text ;
using System.Text.Json.Serialization ;
2026-09-12 10:50:12 +02:00
using AIStudio.Models ;
2026-07-05 12:42:55 +02:00
using AIStudio.Provider ;
using Lua ;
using LuaTable = Lua . LuaTable ;
namespace AIStudio.Settings ;
/// <summary>
2026-09-12 20:50:38 +02:00
/// 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.
2026-07-05 12:42:55 +02:00
/// </summary>
2026-09-12 20:50:38 +02:00
/// <remarks>
/// 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.
/// </remarks>
2026-07-05 12:42:55 +02:00
public sealed record ProviderCapabilityOverrides
{
2026-09-12 20:50:38 +02:00
/// <summary>
/// How wide the window of this installation is, in tokens.
/// </summary>
private const string CONTEXT_WINDOW_KEY = "CONTEXT_WINDOW" ;
/// <summary>
/// How many images one message may carry here.
/// </summary>
private const string MAX_IMAGES_PER_MESSAGE_KEY = "MAX_IMAGES_PER_MESSAGE" ;
/// <summary>
/// How many images one request may carry here.
/// </summary>
private const string MAX_IMAGES_PER_REQUEST_KEY = "MAX_IMAGES_PER_REQUEST" ;
/// <summary>
/// The keys which name a number rather than a capability.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private static readonly IReadOnlyList < string > NUMERIC_KEYS =
[
CONTEXT_WINDOW_KEY,
MAX_IMAGES_PER_MESSAGE_KEY,
MAX_IMAGES_PER_REQUEST_KEY,
] ;
2026-09-12 10:50:12 +02:00
/// <summary>
/// The capabilities a person switches on or off directly, without the reasoning words.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private static readonly IReadOnlyList < Capability > DIRECTLY_SETTABLE_CAPABILITIES =
[
Capability.AUDIO_INPUT,
Capability.FUNCTION_CALLING,
Capability.MULTIPLE_IMAGE_INPUT,
Capability.SPEECH_INPUT,
Capability.VIDEO_INPUT,
] ;
2026-07-05 12:42:55 +02:00
private static readonly IReadOnlyList < Capability > SUPPORTED_CAPABILITIES =
[
Capability.AUDIO_INPUT,
2026-09-04 15:48:07 +02:00
Capability.FUNCTION_CALLING,
2026-07-05 12:42:55 +02:00
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 ; }
2026-09-04 15:48:07 +02:00
[JsonPropertyName("FUNCTION_CALLING")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? FunctionCalling { get ; init ; }
2026-07-05 12:42:55 +02:00
[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 ; }
2026-09-12 20:50:38 +02:00
/// <summary>
/// How many tokens this installation reads and writes, or null to keep the automatic answer.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[JsonPropertyName(CONTEXT_WINDOW_KEY)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? ContextWindowTokens { get ; init ; }
/// <summary>
/// How many images one message may carry, or null to keep the automatic answer.
/// </summary>
[JsonPropertyName(MAX_IMAGES_PER_MESSAGE_KEY)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? MaxImagesPerMessage { get ; init ; }
/// <summary>
/// How many images one request may carry, or null to keep the automatic answer.
/// </summary>
[JsonPropertyName(MAX_IMAGES_PER_REQUEST_KEY)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? MaxImagesPerRequest { get ; init ; }
2026-07-05 12:42:55 +02:00
[JsonIgnore]
public bool HasOverrides =>
this . AudioInput is not null ||
2026-09-04 15:48:07 +02:00
this . FunctionCalling is not null ||
2026-07-05 12:42:55 +02:00
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 ||
2026-09-12 20:50:38 +02:00
this . ReasoningByDefault is not null ||
this . ContextWindowTokens is not null ||
this . MaxImagesPerMessage is not null ||
this . MaxImagesPerRequest is not null ;
2026-07-05 12:42:55 +02:00
public bool? GetOverride ( Capability capability ) => capability switch
{
Capability . AUDIO_INPUT => this . AudioInput ,
2026-09-04 15:48:07 +02:00
Capability . FUNCTION_CALLING => this . FunctionCalling ,
2026-07-05 12:42:55 +02:00
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 },
2026-09-04 15:48:07 +02:00
Capability . FUNCTION_CALLING => this with { FunctionCalling = value },
2026-07-05 12:42:55 +02:00
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
};
2026-09-12 20:50:38 +02:00
/// <summary>
/// Reads the number a key stands for.
/// </summary>
/// <param name="key">One of the numeric keys.</param>
/// <returns>The number, or null when nobody stated it.</returns>
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 ,
};
/// <summary>
/// States the number a key stands for.
/// </summary>
/// <param name="key">One of the numeric keys.</param>
/// <param name="value">The number, or null to keep the automatic answer.</param>
/// <returns>The overrides with that number in them.</returns>
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 ,
};
2026-09-12 10:50:12 +02:00
/// <summary>
/// Applies what a person said about their own installation to what the rules worked out.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="profile">What the rules worked out.</param>
/// <returns>The profile as this provider instance was told it is.</returns>
public ModelProfile ApplyTo ( in ModelProfile profile ) => profile with
{
Capabilities = this . ApplyToCapabilities ( profile . Capabilities ),
Reasoning = this . ResolveReasoning ( profile . Reasoning ),
2026-09-12 20:50:38 +02:00
Context = this . ResolveContext ( profile . Context ),
Images = this . ResolveImages ( profile . Images ),
2026-09-12 10:50:12 +02:00
};
2026-09-12 20:50:38 +02:00
/// <summary>
/// Works out how wide the window is, out of what the rules say and what a person said.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="stated">What the rules worked out.</param>
/// <returns>The window after the overrides.</returns>
private ContextWindow ResolveContext ( ContextWindow stated ) => this . ContextWindowTokens is { } tokens and > 0 ? ContextWindow . Of ( tokens ) : stated ;
/// <summary>
/// Works out how many images fit, out of what the rules say and what a person said.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="stated">What the rules worked out.</param>
/// <returns>The limits after the overrides.</returns>
private ImageLimits ResolveImages ( ImageLimits stated ) => new ( CountOfImages ( this . MaxImagesPerMessage ) ?? stated . MaxPerMessage , CountOfImages ( this . MaxImagesPerRequest ) ?? stated . MaxPerRequest );
/// <summary>
/// Takes a stated image limit, where it is one.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="limit">What was stated.</param>
/// <returns>The limit, or null when nothing usable was stated.</returns>
private static int? CountOfImages ( int? limit ) => limit >= 0 ? limit : null ;
2026-09-12 10:50:12 +02:00
/// <summary>
/// Switches the plain capabilities on and off.
/// </summary>
/// <param name="stated">What the rules worked out.</param>
/// <returns>The capabilities after the overrides.</returns>
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 ;
}
/// <summary>
/// Works out how a model reasons, out of what the rules say and what a person said.
/// </summary>
/// <remarks>
/// This replaces thirty lines which repaired states that could not 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, so the five combinations it produces are
/// answered exactly as they are today.
///
/// One thing changes, 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 old 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.
/// </remarks>
/// <param name="stated">How the rules say the model reasons.</param>
/// <returns>How it reasons after the overrides.</returns>
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 ,
};
}
2026-07-05 12:42:55 +02:00
public List < Capability > ApplyTo ( IEnumerable < Capability > automaticCapabilities )
{
var mergedCapabilities = automaticCapabilities . Distinct (). ToList ();
foreach ( var capability in SUPPORTED_CAPABILITIES )
{
var overrideValue = this . GetOverride ( capability );
if ( overrideValue == true && ! mergedCapabilities . Contains ( capability ))
mergedCapabilities . Add ( capability );
else if ( overrideValue == false )
mergedCapabilities . Remove ( capability );
}
this . NormalizeReasoningCapabilities ( mergedCapabilities );
return mergedCapabilities ;
}
private void NormalizeReasoningCapabilities ( List < Capability > capabilities )
{
if ( this . AlwaysReasoning == true ||
this . AlwaysReasoning is not false &&
this . OptionalReasoning is not true &&
this . ReasoningByDefault is not true &&
capabilities . Contains ( Capability . ALWAYS_REASONING ))
{
capabilities . Remove ( Capability . OPTIONAL_REASONING );
capabilities . Remove ( Capability . REASONING_BY_DEFAULT );
return ;
}
if ( this . AlwaysReasoning == false ||
this . OptionalReasoning == true ||
this . ReasoningByDefault == true )
capabilities . Remove ( Capability . ALWAYS_REASONING );
if ( this . OptionalReasoning == false )
{
capabilities . Remove ( Capability . REASONING_BY_DEFAULT );
return ;
}
if ( this . ReasoningByDefault == true && ! capabilities . Contains ( Capability . OPTIONAL_REASONING ))
capabilities . Add ( Capability . OPTIONAL_REASONING );
if (! capabilities . Contains ( Capability . OPTIONAL_REASONING ))
capabilities . Remove ( Capability . REASONING_BY_DEFAULT );
}
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()}," );
}
2026-09-12 20:50:38 +02:00
foreach ( var key in NUMERIC_KEYS )
{
if ( this . GetNumber ( key ) is not { } number )
continue ;
builder . AppendLine ( $@"{indentation} [""{key}""] = {number.ToString(CultureInfo.InvariantCulture)}," );
}
2026-07-05 12:42:55 +02:00
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 < LuaTable >( 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 < string >( 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 ;
}
2026-09-12 20:50:38 +02:00
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 ;
}
2026-07-05 12:42:55 +02:00
if (! TryParseSupportedCapability ( keyText , out var capability ))
{
2026-09-12 20:50:38 +02:00
logger . LogWarning ( "The configured provider {ProviderIndex} contains an unsupported override '{OverrideKey}'. The entry will be ignored. (Plugin ID: {PluginId})" , idx , keyText , configPluginId );
2026-07-05 12:42:55 +02:00
continue ;
}
if (! pair . Value . TryRead < bool >( 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 ;
}
2026-09-12 20:50:38 +02:00
/// <summary>
/// Recognizes a key which names a number, whichever way it was spelled.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="key">The key as it was written.</param>
/// <param name="numericKey">The canonical spelling of that key.</param>
/// <returns>True when the key names a number.</returns>
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 ;
}
/// <summary>
/// Reads a number, where it is one this key accepts.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="value">The value as it stands in the table.</param>
/// <param name="numericKey">The canonical key it stands under.</param>
/// <param name="number">The number read.</param>
/// <returns>True, when the value is a number, this key accepts.</returns>
private static bool TryReadNumber ( LuaValue value , string numericKey , out int number )
{
2026-09-13 08:54:44 +02:00
if (! value . TryRead ( out number ))
2026-09-12 20:50:38 +02:00
return false ;
return numericKey is CONTEXT_WINDOW_KEY ? number > 0 : number >= 0 ;
}
/// <summary>
/// What a key accepts, said in the words of a warning.
/// </summary>
/// <param name="numericKey">The canonical key.</param>
/// <returns>The expectation.</returns>
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" ;
2026-07-05 12:42:55 +02:00
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 );
}
}